Store to db

This commit is contained in:
2025-08-23 19:23:36 +02:00
parent 1089196226
commit e6f2397da6
45 changed files with 354354 additions and 124 deletions

5
.gitignore vendored
View File

@@ -24,4 +24,7 @@ Cargo.lock
.env.*
!.env.example
!.env.*.example
# Ignore the .env file that contains sensitive information
# Ignore the .env file that contains sensitive information
log/*
!log/.keep

110
.vscode/launch.json vendored
View File

@@ -1,45 +1,69 @@
{
// Verwendet IntelliSense zum Ermitteln möglicher Attribute.
// Zeigen Sie auf vorhandene Attribute, um die zugehörigen Beschreibungen anzuzeigen.
// Weitere Informationen finden Sie unter https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'no-man-sky'",
"cargo": {
"args": [
"build",
"--bin=no-man-sky",
"--package=no-man-sky"
],
"filter": {
"name": "no-man-sky",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in executable 'no-man-sky'",
"cargo": {
"args": [
"test",
"--no-run",
"--bin=no-man-sky",
"--package=no-man-sky"
],
"filter": {
"name": "no-man-sky",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
// Verwendet IntelliSense zum Ermitteln möglicher Attribute.
// Zeigen Sie auf vorhandene Attribute, um die zugehörigen Beschreibungen anzuzeigen.
// Weitere Informationen finden Sie unter https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Server NoManSky",
"cargo": {
"args": ["build", "--bin=no-man-sky", "--package=no-man-sky"],
"filter": {
"name": "no-man-sky",
"kind": "bin"
}
]
}
},
"args": ["-s"],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Client No-man-sky",
"cargo": {
"args": ["build", "--bin=no-man-sky", "--package=no-man-sky"],
"filter": {
"name": "no-man-sky",
"kind": "bin"
}
},
"args": ["add", "Platin"],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Help No-man-sky ",
"cargo": {
"args": ["build", "--bin=no-man-sky", "--package=no-man-sky"],
"filter": {
"name": "no-man-sky",
"kind": "bin"
}
},
"args": ["--help"],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in executable 'no-man-sky'",
"cargo": {
"args": [
"test",
"--no-run",
"--bin=no-man-sky",
"--package=no-man-sky"
],
"filter": {
"name": "no-man-sky",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
}
]
}

View File

@@ -1,8 +1,11 @@
{
"cSpell.words": [
"DATABASENAME",
"dotenv",
"Herstellung",
"Insertable",
"Kochen",
"mordit",
"MSVC",
"Quelle",
"Raffination",

View File

@@ -3,19 +3,28 @@ name = "no-man-sky"
version = "0.1.0"
edition = "2024"
publish = ["merlin"]
description = "Utility functions to read environment with fallback and values from a file"
description = "NoManSky Server/Client utility"
license = "MIT"
repository = "ssh://git@gitea.merlinserver.de:2222/Stefan/no-man-sky-wiki.git"
[dependencies]
diesel = { version= "2.2.10", features = ["serde_json", "postgres", "uuid"]}
anyhow = "1.0.98"
axum = "0.8.4"
clap = { version = "4.5.43", features = ["derive"] }
diesel = { version= "2.2.10", features = ["serde_json", "postgres", "uuid", "r2d2"]}
diesel_migrations = "2.2.0"
dotenv = "0.15.0"
env_logger = "0.11.8"
log = "0.4.27"
merlin_env_helper = { version = "0.2.0", registry = "merlin" }
log4rs = "1.3.0"
merlin_env_helper = { version = "0.4.0", registry = "merlin" }
once_cell = "1.21.3"
regex = "1.11.1"
reqwest = {version="0.12.15", features=["blocking"]}
reqwest = {version="0.12.15" }
#scraper = "0.23.1"
select = "0.6.1"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
tokio = { version = "1.46.1", features = ["full"] }
toml = "0.8.23"
url = "2.5.4"
uuid = "1.17.0"

BIN
images/resources/NANITE.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

44
log4jrs.yaml Normal file
View File

@@ -0,0 +1,44 @@
refresh_rate: 120 seconds
appenders:
stdout:
kind: console
target: stdout
encoder:
pattern: "{d(%Y-%m-%d %H:%M:%S)(local)} {l} {t} {h({m})}{n}"
filters:
- kind: target_filter
level: [error, warn]
negation: true
stderr:
kind: console
target: stderr
encoder:
pattern: "{d(%Y-%m-%d %H:%M:%S)(local)} {l} {t} {h({m})}{n}"
filters:
- kind: target_filter
level: [error, warn]
negation: false
no-man-sky-file:
kind: rolling_file
path: "logs/no-man-sky.log"
encoder:
pattern: "{d(%Y-%m-%d %H:%M:%S)(local)} {l} {t} {m}{n}"
policy:
kind: compound
trigger:
kind: time
interval: 1 day
modulate: false
max_random_delay: 0
roller:
kind: fixed_window
base: 1
count: 5
pattern: "logs/no-man-sky.{}.log"
root:
level: info
appenders:
- stdout
- stderr
- no-man-sky-file

0
logs/.keep Normal file
View File

352447
logs/no-man-sky.log Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,8 @@
CREATE TABLE "icon"(
"id" UUID NOT NULL PRIMARY KEY,
"id" UUID DEFAULT gen_random_uuid () NOT NULL PRIMARY KEY,
"name" VARCHAR(255) NOT NULL,
"content_type" VARCHAR(255),
"path" VARCHAR(512) NOT NULL,
"url" VARCHAR(512),
"width" INT4,
"height" INT4,
@@ -9,7 +10,7 @@ CREATE TABLE "icon"(
);
CREATE TABLE "resource"(
"id" UUID NOT NULL PRIMARY KEY,
"id" UUID DEFAULT gen_random_uuid () NOT NULL PRIMARY KEY,
"name" VARCHAR(255) NOT NULL,
"title" VARCHAR(255) NOT NULL,
"url" VARCHAR(512),
@@ -19,16 +20,18 @@ CREATE TABLE "resource"(
);
CREATE TABLE "recipe"(
"id" UUID NOT NULL PRIMARY KEY,
"id" UUID DEFAULT gen_random_uuid () NOT NULL PRIMARY KEY,
"resource" UUID NOT NULL,
"quantity" INT4 NOT NULL,
"recipe_type" VARCHAR(50) NOT NULL,
"duration" INT4 NOT NULL,
"unit" VARCHAR(50) NOT NULL,
"state" JSONB,
FOREIGN KEY ("resource") REFERENCES "resource"("id")
);
CREATE TABLE "ingredient"(
"id" UUID NOT NULL PRIMARY KEY,
"id" UUID DEFAULT gen_random_uuid () NOT NULL PRIMARY KEY,
"resource" UUID NOT NULL,
"quantity" INT4 NOT NULL,
"state" JSONB,

16
no_man_sky.toml Normal file
View File

@@ -0,0 +1,16 @@
[main]
image_store_directory = "./images"
image_resource_store_directory = "./images/resources"
base_url = "https://nomanssky.fandom.com/de/wiki/"
[server]
port = 8080
host = "localhost"
[db]
url = "localhost"
port = 5432
database = "no_man_sky"
max_connections = 5
user = "set by env"
password = "set by env"

123
src/client/mod.rs Normal file
View File

@@ -0,0 +1,123 @@
use reqwest::Client;
use crate::{
configuration::CONFIGURATION,
types::{CallArguments, ClientCommands},
};
pub async fn run_client(
arguments: &CallArguments,
) -> Result<(), Box<dyn std::error::Error + 'static>> {
if arguments.server {
eprintln!("Server mode is enabled, but this is a client function.");
return Err("Server mode cannot be used in client context".into());
}
if arguments.client_commands.is_none() {
eprintln!("No client command provided.");
return Err("No client command provided".into());
}
match arguments.client_commands.as_ref().unwrap() {
ClientCommands::Add { name } => _add(name).await,
ClientCommands::Get { id_or_name } => _get(id_or_name).await,
}
}
fn _get_url(sub_path: &str, id: &str) -> String {
let config = CONFIGURATION.get().unwrap();
let mut url = format!("http://{}:{}", config.server.host, config.server.port);
if !sub_path.is_empty() {
if sub_path.starts_with('/') {
url.push_str(sub_path);
} else {
url.push('/');
url.push_str(sub_path);
}
}
if url.ends_with('/') && !id.is_empty() {
url = url[..url.len() - 1].to_string();
}
if !id.is_empty() {
if !id.starts_with("/") {
url.push('/');
}
url.push_str(id);
}
url
}
async fn _get(_id_or_name: &str) -> Result<(), Box<dyn std::error::Error + 'static>> {
let url = _get_url("resource", _id_or_name);
let parsed_url = url::Url::parse(&url);
if parsed_url.is_err() {
eprintln!("Invalid URL: {}", url);
return Err(format!("Invalid URL: {}", url).into());
}
let parsed_url = parsed_url.unwrap();
let client = Client::builder()
.danger_accept_invalid_certs(true)
.build()
.unwrap_or_default();
let response = client.get(parsed_url.as_str()).send().await;
match response {
Ok(resp) => {
println!(
"StatusCode: {} {}",
resp.status(),
resp.status().is_success()
);
resp.status().is_success();
}
Err(err) => {
println!("code:{},error:{}", err.status().unwrap_or_default(), err);
}
}
Ok(())
}
async fn _add(name: &str) -> Result<(), Box<dyn std::error::Error + 'static>> {
let url = _get_url("resource", name);
let parsed_url = url::Url::parse(&url);
if parsed_url.is_err() {
eprintln!("Invalid URL: {}", url);
return Err(format!("Invalid URL: {}", url).into());
}
let parsed_url = parsed_url.unwrap();
let client = Client::builder()
.danger_accept_invalid_certs(true)
.build()
.unwrap_or_default();
let response = client.post(parsed_url.as_str()).send().await;
match response {
Ok(resp) => {
println!(
"StatusCode: {} {}\n=>{}",
resp.status(),
resp.status().is_success(),
resp.text().await.unwrap_or_default()
);
}
Err(err) => {
println!("code:{},error:{}", err.status().unwrap_or_default(), err);
}
}
Ok(())
}

274
src/configuration/mod.rs Normal file
View File

@@ -0,0 +1,274 @@
use merlin_env_helper::{EnvKey, get_env_value};
use once_cell::sync::OnceCell;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use crate::util::validate_url;
pub static CONFIGURATION: OnceCell<Configuration> = OnceCell::new();
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct BaseConfiguration {
pub image_store_directory: String,
pub image_resource_store_directory: String,
pub base_url: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct DatabaseConfiguration {
pub url: String,
pub port: u16,
pub database: String,
pub user: Option<String>,
pub password: Option<String>,
pub max_connections: u32,
}
impl DatabaseConfiguration {
pub fn connection_string(&self) -> String {
format!(
"postgres://{}:{}@{}:{}/{}",
self.user.as_ref().unwrap(),
self.password.as_ref().unwrap(),
self.url,
self.port,
self.database
)
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ServerConfiguration {
pub host: String,
pub port: u16,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Configuration {
pub main: BaseConfiguration,
pub server: ServerConfiguration,
pub db: DatabaseConfiguration,
}
impl Default for BaseConfiguration {
fn default() -> Self {
BaseConfiguration {
image_store_directory: "./images".into(),
image_resource_store_directory: "./images/resources".into(),
base_url: "http://localhost".into(),
}
}
}
impl Default for ServerConfiguration {
fn default() -> Self {
ServerConfiguration {
host: "localhost".into(),
port: 8080,
}
}
}
impl Default for DatabaseConfiguration {
fn default() -> Self {
DatabaseConfiguration {
url: "localhost".into(),
port: 5432,
user: None,
password: None,
max_connections: 5,
database: "no_man_sky".into(),
}
}
}
impl Default for Configuration {
fn default() -> Self {
Configuration {
main: BaseConfiguration::default(),
server: ServerConfiguration::default(),
db: DatabaseConfiguration::default(),
}
}
}
impl Configuration {
pub async fn new() -> Self {
let mut config = &mut Configuration::default();
if Path::new("no_man_sky.toml").exists() {
// If the config file exists, load it.
let config_content = fs::read_to_string("no_man_sky.toml")
.unwrap_or_else(|e| panic!("Failed to read config file: {}", e));
let config_to_merge: Configuration =
toml::from_str(&config_content).unwrap_or_else(|e| {
panic!("Failed to parse config file: {}", e);
});
merge(&mut config, &config_to_merge);
}
let image_store_directory = get_env_value(&EnvKey::key_with_fallback(
"IMAGE_STORE_DIRECTORY",
&config.main.image_store_directory,
))
.expect("Failed to get IMAGE_STORE_DIRECTORY from environment");
let image_resource_store_directory = get_env_value(&EnvKey::key_with_fallback(
"IMAGE_RESOURCE_STORE_DIRECTORY",
&config.main.image_resource_store_directory,
))
.expect("Failed to get IMAGE_STORE_DIRECTORY from environment");
validate_configuration(Configuration {
main: {
BaseConfiguration {
image_store_directory: image_store_directory,
image_resource_store_directory: image_resource_store_directory,
base_url: get_env_value(&EnvKey::key_with_fallback(
"MAIN_BASE_URL",
&config.main.base_url,
))
.expect("Failed to get MAIN_BASE_URL from environment"),
}
},
server: {
ServerConfiguration {
host: get_env_value(&EnvKey::key_with_fallback(
"SERVER_HOST",
&config.server.host,
))
.expect("Failed to get SERVER_HOST from environment"),
port: get_env_value(&EnvKey::key_with_fallback(
"SERVER_PORT",
&config.server.port.to_string(),
))
.expect("Failed to get SERVER_PORT from environment")
.parse::<u16>()
.expect("Failed to parse SERVER_PORT"),
}
},
db: {
DatabaseConfiguration {
url: get_env_value(&EnvKey::key_with_fallback("DB_URL", &config.db.url))
.expect("Failed to ger DB_URL from environment."),
port: get_env_value(&EnvKey::key_with_fallback(
"DB_PORT",
&config.db.port.to_string(),
))
.expect("Failed to get DB_URL from environment")
.parse::<u16>()
.expect("Failed to parse DB_PORT"),
database: get_env_value(&EnvKey::key_with_fallback(
"DB_DATABASE",
&config.db.database,
))
.expect("Failed to ger DB_DATABASE from environment."),
user: Some(
get_env_value(&EnvKey::secure_with_fallback(
"DB_USER",
"DB_USER_FILE",
&config.db.user.as_ref().unwrap(),
))
.expect("Failed to ger DB_URL from environment."),
),
password: Some(
get_env_value(&EnvKey::secure_with_fallback(
"DB_PASSWORD",
"DB_PASSWORD_FILE",
&config.db.password.as_ref().unwrap(),
))
.expect("Failed to ger DB_URL from environment."),
),
max_connections: get_env_value(&EnvKey::key_with_fallback(
"DB_MAX_CONNECTIONS",
&config.db.max_connections.to_string(),
))
.expect("Failed to get DB_MAX_CONNECTIONS from environment")
.parse::<u32>()
.expect("Failed to parse DB_MAX_CONNECTIONS"),
}
},
})
.await
}
}
fn merge(config: &mut Configuration, config_content: &Configuration) {
config.main.image_store_directory = config_content.main.image_store_directory.clone();
config.main.base_url = config_content.main.base_url.clone();
config.server.host = config_content.server.host.clone();
config.server.port = config_content.server.port;
config.db.url = config_content.db.url.clone();
config.db.port = config_content.db.port;
config.db.max_connections = config_content.db.max_connections;
config.db.database = config_content.db.database.clone();
if config_content.db.user.is_some() {
config.db.user = config_content.db.user.clone();
}
if config_content.db.password.is_some() {
config.db.password = config_content.db.password.clone();
}
}
async fn validate_configuration(configuration: Configuration) -> Configuration {
if !validate_url(&configuration.main.base_url).await {
panic!("Invalid base URL: {}", configuration.main.base_url);
}
if !validate_directory(&configuration.main.image_store_directory) {
panic!(
"Image store directory does not exist or is not a directory: {}",
configuration.main.image_store_directory
);
}
if !validate_directory(&configuration.main.image_resource_store_directory) {
panic!(
"Image resource store directory does not exist or is not a directory: {}",
configuration.main.image_resource_store_directory
);
}
if !validate_directory(&configuration.main.image_store_directory) {
panic!(
"Image store directory does not exist or is not a directory: {}",
configuration.main.image_store_directory
);
}
if configuration.db.url.is_empty() {
panic!("Database URL cannot be empty.");
}
if configuration.db.port == 0 {
panic!("Database port cannot be zero.");
}
if configuration.db.user.is_none()
|| configuration.db.user.as_ref().unwrap().trim().is_empty()
|| configuration.db.password.as_ref().unwrap().trim() == "set by env"
{
panic!("Database user cannot be empty or must be set by environment.");
}
if configuration.db.password.is_none()
|| configuration
.db
.password
.as_ref()
.unwrap()
.trim()
.is_empty()
|| configuration.db.password.as_ref().unwrap().trim() == "set by env"
{
panic!("Database password cannot be empty or must be set by environment.");
}
configuration
}
fn validate_directory(dir: &str) -> bool {
let dir = Path::new(dir);
dir.exists() && dir.is_dir()
}

View File

@@ -1,32 +1,50 @@
pub mod models;
pub mod schema;
use diesel::{Connection, PgConnection};
use merlin_env_helper::{EnvKey, get_env_value};
pub use models::*;
pub mod util;
fn _read_key() -> String {
get_env_value(&EnvKey::secure_key("DATABASE_URL", "DATABASE_URL_FILE"))
.expect("Failed to read DATABASE_URL")
use diesel::{
PgConnection,
r2d2::{ConnectionManager, Pool, PooledConnection},
};
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
use once_cell::sync::OnceCell;
use std::error::Error;
pub use util::*;
pub type PgPool = Pool<ConnectionManager<PgConnection>>;
pub type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>;
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
pub static DB_POOL: OnceCell<DbConnectionPool> = OnceCell::new();
#[derive(Debug)]
pub struct DbConnectionPool {
pool: PgPool,
}
pub fn establish_connection() -> PgConnection {
let database_url = _read_key();
PgConnection::establish(&database_url)
.unwrap_or_else(|_| panic!("Error connecting to {}", database_url))
}
impl DbConnectionPool {
pub fn new(database_url: &str, max_connections: u32) -> Self {
let manager = ConnectionManager::<PgConnection>::new(database_url);
let pool = Pool::builder()
.max_size(max_connections)
.build(manager)
.expect("Failed to create connection pool");
pub fn test() {
let conn = &mut establish_connection();
use diesel::prelude::*;
DbConnectionPool { pool }
}
use crate::db::schema::icon::dsl::*;
pub fn run_migrations(&self) -> Result<(), Box<dyn Error + Send + Sync + 'static>> {
// This will run the necessary migrations.
//
// See the documentation for `MigrationHarness` for
// all available methods.
self.get_connection()?.run_pending_migrations(MIGRATIONS)?;
let results: Vec<DbIcon> = icon
.select(DbIcon::as_select())
.load(conn)
.expect("Error loading icons");
Ok(())
}
for resource in results {
println!("Found resource: {:?}", resource);
pub fn get_connection(&self) -> Result<PgPooledConnection, diesel::r2d2::PoolError> {
self.pool.get()
}
}

View File

@@ -4,24 +4,38 @@
#![allow(clippy::all)]
use diesel::{pg::Pg, prelude::*};
use uuid::Uuid;
#[derive(Queryable, Selectable, Debug)]
#[derive(Identifiable, Queryable, Selectable, AsChangeset, Debug, Clone, PartialEq, Eq, Hash)]
#[diesel(table_name = crate::db::schema::icon)]
#[diesel(check_for_backend(Pg))]
pub struct DbIcon {
pub id: Uuid,
pub name: String,
pub content_type: Option<String>,
pub url: Option<String>,
pub width: Option<i32>,
pub height: Option<i32>,
pub content_type: String,
pub url: String,
pub path: String,
pub width: i32,
pub height: i32,
pub state: Option<serde_json::Value>,
}
#[derive(Queryable, Selectable, Debug)]
#[derive(Insertable, Debug)]
#[diesel(table_name = crate::db::schema::icon)]
pub struct NewDbIcon {
pub name: String,
pub content_type: String,
pub path: String,
pub url: String,
pub width: i32,
pub height: i32,
pub state: Option<serde_json::Value>,
}
#[derive(Identifiable, Queryable, AsChangeset, Selectable, Associations, Debug, Clone)]
#[diesel(table_name = crate::db::schema::ingredient)]
#[diesel(belongs_to(DbResource, foreign_key = resource))]
#[diesel(belongs_to(DbRecipe, foreign_key = recipe))]
#[diesel(check_for_backend(Pg))]
pub struct DbIngredient {
pub id: Uuid,
@@ -30,20 +44,43 @@ pub struct DbIngredient {
pub state: Option<serde_json::Value>,
pub recipe: Uuid,
}
#[derive(Insertable, Clone)]
#[diesel(table_name = crate::db::schema::ingredient)]
pub struct NewDbIngredient {
pub resource: Uuid,
pub quantity: i32,
pub state: Option<serde_json::Value>,
pub recipe: Uuid,
}
#[derive(Queryable, Selectable, Debug)]
#[derive(Identifiable, Queryable, AsChangeset, Associations, Selectable, Debug)]
#[diesel(table_name = crate::db::schema::recipe)]
#[diesel(belongs_to(DbResource, foreign_key = resource))]
#[diesel(check_for_backend(Pg))]
pub struct DbRecipe {
pub id: Uuid,
pub resource: Uuid,
pub quantity: i32,
pub recipe_type: String,
pub duration: i32,
pub unit: String,
pub state: Option<serde_json::Value>,
}
#[derive(Queryable, Selectable, Debug)]
#[derive(Insertable, Debug, Clone)]
#[diesel(table_name = crate::db::schema::recipe)]
pub struct NewDbRecipe {
pub resource: Uuid,
pub quantity: i32,
pub recipe_type: String,
pub duration: i32,
pub unit: String,
pub state: Option<serde_json::Value>,
}
#[derive(Identifiable, Queryable, AsChangeset, Associations, Selectable, Debug, Clone)]
#[diesel(table_name = crate::db::schema::resource)]
#[diesel(belongs_to(DbIcon, foreign_key = icon))]
#[diesel(check_for_backend(Pg))]
pub struct DbResource {
pub id: Uuid,
@@ -53,3 +90,13 @@ pub struct DbResource {
pub icon: Option<Uuid>,
pub state: Option<serde_json::Value>,
}
#[derive(Debug, Insertable, Clone, PartialEq)]
#[diesel(table_name = crate::db::schema::resource)]
pub struct NewDbResource {
pub name: String,
pub title: String,
pub url: Option<String>,
pub icon: Option<Uuid>,
pub state: Option<serde_json::Value>,
}

View File

@@ -6,11 +6,13 @@ diesel::table! {
#[max_length = 255]
name -> Varchar,
#[max_length = 255]
content_type -> Nullable<Varchar>,
#[max_length = 512]
url -> Nullable<Varchar>,
width -> Nullable<Int4>,
height -> Nullable<Int4>,
content_type -> Varchar,
#[max_length = 1024]
path -> Varchar,
#[max_length = 2048]
url -> Varchar,
width -> Int4,
height -> Int4,
state -> Nullable<Jsonb>,
}
}
@@ -29,9 +31,12 @@ diesel::table! {
recipe (id) {
id -> Uuid,
resource -> Uuid,
quantity -> Int4,
#[max_length = 50]
recipe_type -> Varchar,
duration -> Int4,
#[max_length = 50]
unit -> Varchar,
state -> Nullable<Jsonb>,
}
}

350
src/db/util.rs Normal file
View File

@@ -0,0 +1,350 @@
use diesel::prelude::*;
use log::error;
use uuid::Uuid;
use crate::{
db::{
PgPooledConnection,
models::{
DbIcon, DbIngredient, DbRecipe, DbResource, NewDbIcon, NewDbIngredient, NewDbRecipe,
NewDbResource,
},
schema::ingredient::resource,
},
types::{ID, IdOrNone},
};
fn get_connection() -> PgPooledConnection {
crate::db::DB_POOL.get().unwrap().get_connection().unwrap()
}
pub fn get_icon_by_id(icon_id: Uuid) -> Result<Option<DbIcon>, Box<dyn std::error::Error>> {
// Implement the logic to find and return the icon based on the name
use crate::db::schema::icon::*;
let conn: &mut PgConnection = &mut get_connection();
match dsl::icon.find(icon_id).first::<DbIcon>(conn) {
Ok(icon_by_id) => Ok(Some(icon_by_id)),
Err(diesel::result::Error::NotFound) => Ok(None),
Err(e) => {
error!("Error fetching icon by ID: {}", e);
Err(Box::new(e))
}
}
}
pub fn get_icon_by_name(icon_name: &str) -> Result<Option<DbIcon>, Box<dyn std::error::Error>> {
// Implement the logic to find and return the icon based on the name
use crate::db::schema::icon::*;
let conn: &mut PgConnection = &mut get_connection();
match dsl::icon
.filter(dsl::name.eq(icon_name))
.limit(1)
.load::<DbIcon>(conn)
{
Ok(it) => {
if it.len() > 0 {
Ok(Some(it[0].clone()))
} else {
Ok(None)
}
}
Err(diesel::result::Error::NotFound) => Ok(None),
Err(e) => {
error!("Error fetching icon by name: {}", e);
Err(Box::new(e))
}
}
}
pub fn add_icon(new_icon: &NewDbIcon) -> Result<IdOrNone, Box<dyn std::error::Error>> {
use crate::db::schema::icon::*;
let conn: &mut PgConnection = &mut get_connection();
if let Some(existing_icon) = get_icon_by_name(&new_icon.name)? {
Ok(IdOrNone::EXISTING(existing_icon.id))
} else {
match diesel::insert_into(dsl::icon)
.values(new_icon)
.returning(dsl::id)
.get_result::<Uuid>(conn)
{
Ok(new_icon_id) => Ok(IdOrNone::NEW(new_icon_id)),
Err(e) => {
error!("Error adding new icon: {}", e);
Err(Box::new(e))
}
}
}
}
pub fn update_icon(update_icon: &DbIcon) -> Result<(), Box<dyn std::error::Error>> {
use crate::db::schema::icon::*;
let conn: &mut PgConnection = &mut get_connection();
if let Some(existing_icon) = get_icon_by_name(&update_icon.name)? {
match diesel::update(dsl::icon.find(existing_icon.id))
.set(update_icon)
.execute(conn)
{
Ok(_) => Ok(()),
Err(e) => {
error!("Error updating icon: {}", e);
Err(Box::new(e))
}
}
} else {
Err(Box::new(diesel::result::Error::NotFound))
}
}
pub fn get_resource_by_id(
resource_id: Uuid,
) -> Result<Option<DbResource>, Box<dyn std::error::Error>> {
// Implement the logic to find and return the icon based on the name
use crate::db::schema::resource::*;
let conn: &mut PgConnection = &mut get_connection();
match dsl::resource.find(resource_id).first::<DbResource>(conn) {
Ok(it) => Ok(Some(it)),
Err(diesel::result::Error::NotFound) => Ok(None),
Err(e) => {
error!("Error fetching resource by ID: {}", e);
Err(Box::new(e))
}
}
}
pub fn get_resource_by_name(
resource_name: &str,
) -> Result<Option<DbResource>, Box<dyn std::error::Error>> {
// Implement the logic to find and return the icon based on the name
use crate::db::schema::resource::*;
let conn: &mut PgConnection = &mut get_connection();
match dsl::resource
.filter(name.eq(resource_name))
.limit(1)
.load::<DbResource>(conn)
{
Ok(it) => {
if it.len() > 0 {
Ok(Some(it[0].clone()))
} else {
Ok(None)
}
}
Err(diesel::result::Error::NotFound) => Ok(None),
Err(e) => {
error!("Error fetching resource by name: {}", e);
Err(Box::new(e))
}
}
}
pub fn add_resource(new_resource: &NewDbResource) -> Result<IdOrNone, Box<dyn std::error::Error>> {
use crate::db::schema::resource::*;
let conn: &mut PgConnection = &mut get_connection();
if let Some(existing_resource) = get_resource_by_name(&new_resource.name)? {
Ok(IdOrNone::EXISTING(existing_resource.id))
} else {
match diesel::insert_into(dsl::resource)
.values(new_resource)
.returning(dsl::id)
.get_result::<Uuid>(conn)
{
Ok(new_resource_id) => Ok(IdOrNone::NEW(new_resource_id)),
Err(e) => {
error!("Error adding new resource: {}", e);
Err(Box::new(e))
}
}
}
}
pub fn update_resource(update_resource: &DbResource) -> Result<(), Box<dyn std::error::Error>> {
use crate::db::schema::resource::*;
let conn: &mut PgConnection = &mut get_connection();
if let Some(existing_resource) = get_resource_by_name(&update_resource.name)? {
diesel::update(dsl::resource.find(existing_resource.id))
.set(update_resource)
.execute(conn)?;
Ok(())
} else {
Err(Box::new(diesel::result::Error::NotFound))
}
}
pub fn get_ingredient_by_id(
ingredient_id: &Uuid,
) -> Result<Option<DbIngredient>, Box<dyn std::error::Error>> {
// Implement the logic to find and return the icon based on the name
use crate::db::schema::ingredient::*;
let conn: &mut PgConnection = &mut get_connection();
match dsl::ingredient
.find(ingredient_id)
.first::<DbIngredient>(conn)
{
Ok(it) => Ok(Some(it)),
Err(diesel::result::Error::NotFound) => Ok(None),
Err(e) => {
error!("Error fetching ingredient by ID: {}", e);
Err(Box::new(e))
}
}
}
pub fn add_ingredient(
new_ingredient: &NewDbIngredient,
) -> Result<IdOrNone, Box<dyn std::error::Error>> {
use crate::db::schema::ingredient::*;
let conn: &mut PgConnection = &mut get_connection();
match diesel::insert_into(dsl::ingredient)
.values(new_ingredient)
.returning(dsl::id)
.get_result::<Uuid>(conn)
{
Ok(it) => Ok(IdOrNone::NEW(it)),
Err(e) => {
error!("Error adding new ingredient: {}", e);
Err(Box::new(e))
}
}
}
pub fn update_ingredient(
update_ingredient: &DbIngredient,
) -> Result<(), Box<dyn std::error::Error>> {
use crate::db::schema::ingredient::*;
let conn: &mut PgConnection = &mut get_connection();
if let Some(existing_ingredient) = get_ingredient_by_id(&update_ingredient.id)? {
match diesel::update(dsl::ingredient)
.filter(dsl::id.eq(existing_ingredient.id))
.set(update_ingredient)
.execute(conn)
{
Ok(_) => Ok(()),
Err(e) => {
error!("Error updating ingredient: {}", e);
Err(Box::new(e))
}
}
} else {
Err(Box::new(diesel::result::Error::NotFound))
}
}
pub fn get_recipe_by_id(by_id: &Uuid) -> Result<Option<DbRecipe>, Box<dyn std::error::Error>> {
// Implement the logic to find and return the icon based on the name
use crate::db::schema::recipe::dsl::*;
let conn: &mut PgConnection = &mut get_connection();
match recipe.find(by_id).first::<DbRecipe>(conn) {
Ok(it) => Ok(Some(it)),
Err(diesel::result::Error::NotFound) => Ok(None),
Err(e) => {
error!("Error fetching recipe by ID: {}", e);
Err(Box::new(e))
}
}
}
pub fn add_recipe(new_recipe: &NewDbRecipe) -> Result<Uuid, Box<dyn std::error::Error>> {
use crate::db::schema::recipe::*;
let conn: &mut PgConnection = &mut get_connection();
match diesel::insert_into(dsl::recipe)
.values(new_recipe)
.returning(dsl::id)
.get_result::<Uuid>(conn)
{
Ok(new_id) => Ok(new_id),
Err(e) => {
error!("Error adding new recipe: {}", e);
Err(Box::new(e))
}
}
}
pub fn update_recipe(update_recipe: &DbRecipe) -> Result<(), Box<dyn std::error::Error>> {
use crate::db::schema::recipe::*;
let conn: &mut PgConnection = &mut get_connection();
if let Some(existing_recipe) = get_recipe_by_id(&update_recipe.id)? {
match diesel::update(dsl::recipe.find(existing_recipe.id))
.set(update_recipe)
.execute(conn)
{
Ok(_) => Ok(()),
Err(e) => {
error!("Error updating recipe: {}", e);
Err(Box::new(e))
}
}
} else {
Err(Box::new(diesel::result::Error::NotFound))
}
}
pub fn find_recipes(
by_recipe_type: &str,
resource_id: &Uuid,
) -> Result<Vec<DbRecipe>, Box<dyn std::error::Error>> {
use crate::db::schema::recipe::*;
let conn: &mut PgConnection = &mut get_connection();
let results = dsl::recipe
.filter(dsl::recipe_type.eq(by_recipe_type))
.filter(dsl::resource.eq(resource_id))
.load::<DbRecipe>(conn)?;
Ok(results)
}
pub fn find_ingredients_for_recipe(
recipe: &DbRecipe,
other: &Vec<String>,
) -> Result<Option<usize>, Box<dyn std::error::Error>> {
use crate::db::schema::*;
let conn: &mut PgConnection = &mut get_connection();
let result_filtered = ingredient::table
.inner_join(resource::table)
.filter(ingredient::recipe.eq(recipe.id))
.filter(resource::name.eq_any(other))
.count()
.get_result::<i64>(conn)?;
let results_all = ingredient::table
.inner_join(resource::table)
.count()
.get_result::<i64>(conn)?;
if result_filtered == results_all {
Ok(Some(results_all as usize))
} else {
Ok(None)
}
}

View File

@@ -1,35 +1,51 @@
mod client;
mod configuration;
mod db;
mod parse;
mod server;
mod types;
mod util;
use std::{fs::File, io::Read};
use clap::Parser;
use dotenv::dotenv;
use parse::parse;
use crate::db::test;
use crate::{
client::run_client,
configuration::{CONFIGURATION, Configuration},
server::run_server,
types::CallArguments,
util::initialize_logging,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
#[tokio::main]
async fn main() {
dotenv().ok();
env_logger::init();
let html = read("test_mordit.html")?;
parse(&html);
test();
Ok(())
initialize_logging("log4jrs.yaml").expect("Failed to initialize logging with log4rs");
let cli = CallArguments::parse();
let _config = Configuration::new().await;
if let Err(conf) = CONFIGURATION.set(_config) {
eprintln!("Failed to set configuration: {:?}", conf);
return;
}
let _config = CONFIGURATION.get().unwrap();
if cli.server {
_run_server().await.expect("Failed to run server");
} else {
_run_client(&cli).await.expect("Failed to run client");
}
}
fn _download_file(url: &str, _path: &str) -> Result<String, Box<dyn std::error::Error>> {
// Some simple CLI args requirements...
async fn _run_client(cli: &CallArguments) -> Result<(), Box<dyn std::error::Error>> {
run_client(cli).await
}
eprintln!("Fetching {url:?}...");
// reqwest::blocking::get() is a convenience function.
//
// In most cases, you should create/build a reqwest::Client and reuse
// it for all requests.
let res = reqwest::blocking::get(url)?;
let body = res.text()?;
Ok(body)
async fn _run_server() -> Result<(), Box<dyn std::error::Error>> {
run_server().await
}
fn read(path: &str) -> Result<String, std::io::Error> {

View File

@@ -1,4 +1,6 @@
use crate::types::{Duration, Icon, Ingredient, Recipe, RecipeType, Resource, ResourceState};
use crate::types::{
Duration, Icon, Ingredient, Recipe, RecipeType, Resource, ResourceState, ResourceWithQuantity,
};
use regex::Regex;
use select::{
document::Document,
@@ -27,7 +29,7 @@ type ResourceTmp = (
Option<Duration>, // duration
);
pub fn parse(html: &str) {
pub fn parse(html: &str) -> Vec<Recipe> {
let document = Document::from(html);
let mut map_resource: HashMap<String, Resource> = HashMap::new();
@@ -67,9 +69,7 @@ pub fn parse(html: &str) {
&mut recipes,
);
print_recipe(&recipes, RecipeType::Refining);
print_recipe(&recipes, RecipeType::Production);
print_recipe(&recipes, RecipeType::Cooking);
recipes
}
fn first<T>(iter: &mut impl Iterator<Item = T>) -> Option<T> {
@@ -173,7 +173,7 @@ fn parse_resource_items(
if !ingredient_to_add.is_empty() {
let recipe = crate::types::Recipe {
recipe_type: recipe_type,
resource: ingredient,
resource: ResourceWithQuantity::from_ingredient(ingredient),
duration: tmp_resource.4.unwrap_or(Duration {
millis: 0,
unit: "Stück".to_string(),
@@ -193,10 +193,16 @@ fn print_recipe(recipes: &Vec<Recipe>, recipe_type: RecipeType) {
.filter(|recipe| recipe.recipe_type == recipe_type)
{
println!("Recipe Type: {:?}", recipe.recipe_type);
let icon = match recipe.resource.icon {
Some(ref icon) => format!("{}", icon),
None => "(none)".to_string(),
};
println!(
"Resource: {} ({})",
recipe.resource.resource.name, recipe.resource.quantity
"Resource: {} ({}) => {}",
recipe.resource.name, recipe.resource.quantity, icon
);
println!("Duration: {} ms", recipe.duration.millis);
println!("Ingredients:");
for ingredient in &recipe.ingredients {
@@ -220,7 +226,6 @@ fn parse_dst(
let dest = first(&mut dest);
if dest.is_none() {
eprintln!("No element found with the selector '#{}'", id);
return false;
}
@@ -368,6 +373,7 @@ fn parse_li_to_resource(
resource_items.push(ParseType::Img(Icon {
name: name.to_string(),
url: url.to_string(),
path: None, // Path will be set later
width,
height,
content_type: "image/png".to_string(), // Assuming PNG, adjust as needed

34
src/server/mod.rs Normal file
View File

@@ -0,0 +1,34 @@
pub mod router;
pub mod util;
use crate::{
configuration::CONFIGURATION,
db::{self, DB_POOL},
};
use log::info;
use router::*;
use util::*;
pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
// initialize database
let app = create_router().unwrap();
let config = CONFIGURATION.get().unwrap();
let db_pool =
db::DbConnectionPool::new(&config.db.connection_string(), config.db.max_connections);
let addr = format!("{}:{}", config.server.host, config.server.port);
db_pool
.run_migrations()
.expect("Failed to run database migrations");
DB_POOL
.set(db_pool)
.expect("Failed to set database connection pool");
info!("Database connection pool initialized");
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
info!("NoManSky server listening on {}", &addr);
axum::serve(listener, app).await.unwrap();
info!("NoManSky server listening stopped");
Ok(())
}

73
src/server/router.rs Normal file
View File

@@ -0,0 +1,73 @@
use std::{collections::HashMap, os::unix::process};
use axum::{
Router,
extract::{Path, Query},
response::Result,
routing::get,
};
use log::info;
use crate::{
configuration::CONFIGURATION,
parse::parse,
server::{download_content, util::process_recipe},
types::ServerError,
};
pub fn create_router() -> Result<Router, Box<dyn std::error::Error>> {
let router = Router::new()
.route("/", get(root))
.route("/resource/{name}", get(get_resource).post(add_resource));
Ok(router)
}
async fn root() -> &'static str {
"NoManSky Server!"
}
async fn get_resource(
Path(name): Path<String>,
Query(query): Query<HashMap<String, String>>,
) -> Result<String> {
let result = format!("Resource name: {:?}, Query: {:?}", name, query);
println!("get_resource: {}", result);
Ok(result)
}
async fn add_resource(
Path(name): Path<String>,
Query(_query): Query<HashMap<String, String>>,
) -> Result<String, ServerError> {
let url;
let config = CONFIGURATION.get().unwrap();
if name.starts_with("http") {
// If the name starts with "http", treat it as a URL
url = name.clone();
} else {
// Otherwise, construct a URL from the name
url = format!(
"{}{}{}",
config.main.base_url,
if config.main.base_url.ends_with("/") {
""
} else {
"/"
},
name
);
}
info!("Try to download resource {}", url);
let response = download_content(&url).await?;
let recipes = parse(&response);
for recipe in recipes {
process_recipe(&recipe).await?;
}
Ok(format!("Resource {} added.", name))
}

290
src/server/util.rs Normal file
View File

@@ -0,0 +1,290 @@
use std::{ffi::OsStr, io::Cursor, path::PathBuf};
use log::info;
use uuid::Uuid;
use crate::{
configuration::CONFIGURATION,
db::{
self, add_icon, add_ingredient, add_recipe, add_resource, find_ingredients_for_recipe,
find_recipes, get_icon_by_name, get_resource_by_name,
models::{NewDbIcon, NewDbRecipe, NewDbResource},
},
types::{ID, Icon, IdOrNone, Ingredient, Recipe, Resource},
};
pub async fn download_content(url: &str) -> Result<String, anyhow::Error> {
// Some simple CLI args requirements...
let res = reqwest::get(url).await;
if let Err(e) = res {
info!("Failed to download file from {}: {}", url, e);
anyhow::bail!("Failed to download file from {}: {}", url, e);
}
let res = res.unwrap();
let body = res.text().await;
match body {
Ok(body) => {
info!("Downloaded file from {} successfully", url);
Ok(body)
}
Err(e) => {
info!("Failed to download file from {}: {}", url, e);
anyhow::bail!("Failed to download file from {}: {}", url, e);
}
}
}
fn get_icon_file(icon: &Icon) -> PathBuf {
let mut file = PathBuf::from(
CONFIGURATION
.get()
.unwrap()
.main
.image_resource_store_directory
.clone(),
);
file.push(icon.name.clone());
let mut counter = 1;
let file_name = file.as_path().file_stem().unwrap_or(OsStr::new("icon"));
let extension = file.as_path().extension().unwrap_or(OsStr::new(""));
let mut file = file.clone();
while file.as_path().exists() {
let new_file_name = format!(
"{}-{}.{}",
file_name.to_string_lossy(),
counter,
extension.to_string_lossy()
);
counter += 1;
file.set_file_name(new_file_name);
}
file.to_path_buf()
}
async fn download_file(url: &str, file_name: &PathBuf) -> Result<(), anyhow::Error> {
let response = match reqwest::get(url).await {
Ok(res) => res,
Err(e) => {
info!("Failed to download file from {}: {}", url, e);
anyhow::bail!("Failed to download file from {}: {}", url, e);
}
};
let mut file = std::fs::File::create(file_name.as_path())?;
let mut content = Cursor::new(response.bytes().await?);
std::io::copy(&mut content, &mut file)?;
Ok(())
}
pub async fn process_recipe(recipe: &Recipe) -> Result<ID, anyhow::Error> {
let resource_uuid = get_or_add_resource(&recipe.resource.to_resource()).await?;
match resource_uuid {
IdOrNone::NONE => {
anyhow::bail!("Error adding resource: {}", recipe.resource.name);
}
_ => get_or_add_recipe(&recipe, &resource_uuid.to_id()).await,
}
}
async fn get_or_add_recipe(recipe: &Recipe, resource_id: &ID) -> Result<ID, anyhow::Error> {
let recipes = match find_recipes(recipe.recipe_type.as_str(), &resource_id.as_uuid()) {
Ok(recipes) => recipes,
Err(e) => {
anyhow::bail!("Error finding recipes: {}", e);
}
};
let ids: Vec<String> = recipe
.ingredients
.iter()
.map(|i| i.resource.name.clone())
.collect();
for recipe_to_check in recipes {
if recipe_to_check.quantity == recipe.resource.quantity as i32
&& recipe_to_check.duration == recipe.duration.millis as i32
&& recipe_to_check.unit == recipe.duration.unit
{
match find_ingredients_for_recipe(&recipe_to_check, &ids) {
Ok(ingredients) => {
// Wenn alle Zutaten vorhanden sind existiert das Rezept
if ingredients.is_some() && ingredients.unwrap() == recipe.ingredients.len() {
return Ok(ID::EXISTING(recipe_to_check.id));
}
}
Err(e) => {
anyhow::bail!("Error finding ingredients for recipe: {}", e);
}
}
}
}
let new_recipe = NewDbRecipe {
resource: resource_id.as_uuid(),
quantity: recipe.resource.quantity as i32,
duration: recipe.duration.millis as i32,
unit: recipe.duration.unit.clone(),
state: None,
recipe_type: recipe.recipe_type.as_string().clone(),
};
let recipe_id = match add_recipe(&new_recipe) {
Ok(recipe_id) => {
info!(
"Added recipe {} to database with ID {}",
recipe.resource.name, recipe_id
);
recipe_id
}
Err(e) => {
anyhow::bail!(
"Failed to add recipe for resource {} to database: {}",
recipe.resource.name,
e
);
}
};
for ingredient in &recipe.ingredients {
add_ingredient_for_recipe(ingredient, &recipe_id).await?;
}
Ok(ID::NEW(recipe_id))
}
async fn add_ingredient_for_recipe(
ingredient: &Ingredient,
recipe_id: &Uuid,
) -> Result<(), anyhow::Error> {
let resource_id = match get_or_add_resource(&ingredient.resource).await {
Ok(uuid) => match uuid {
IdOrNone::EXISTING(id) => id,
IdOrNone::NEW(id) => id,
IdOrNone::NONE => {
anyhow::bail!(
"Error adding resource for ingredient {}: None",
ingredient.resource.name
);
}
},
Err(e) => {
anyhow::bail!(
"Error adding resource for ingredient {}: {}",
ingredient.resource.name,
e
);
}
};
let new_ingredient = db::models::NewDbIngredient {
recipe: *recipe_id,
resource: resource_id,
quantity: ingredient.quantity as i32,
state: None,
};
match add_ingredient(&new_ingredient) {
Ok(_) => Ok(()),
Err(e) => {
anyhow::bail!("Error adding ingredient for recipe {}: {}", recipe_id, e);
}
}
}
async fn get_or_add_resource(resource: &Resource) -> Result<IdOrNone, anyhow::Error> {
let resource_id = match get_resource_by_name(&resource.name) {
Ok(res) => res,
Err(e) => {
anyhow::bail!("Error fetching resource by name: {}", e);
}
};
if let Some(resource) = resource_id {
return Ok(IdOrNone::EXISTING(resource.id));
}
let icon_id = match get_or_add_icon(&resource.icon).await? {
IdOrNone::EXISTING(icon_id) => Some(icon_id),
IdOrNone::NEW(icon_id) => Some(icon_id),
IdOrNone::NONE => None,
};
let new_resource = NewDbResource {
name: resource.name.clone(),
title: resource.title.clone(),
url: resource.url.clone(),
icon: icon_id,
state: None,
};
match add_resource(&new_resource) {
Ok(resource_id) => {
info!(
"Added resource {} to database with ID {}",
new_resource.name, resource_id
);
Ok(resource_id)
}
Err(e) => {
anyhow::bail!(
"Failed to add resource {} to database: {}",
new_resource.name,
e
);
}
}
}
async fn get_or_add_icon(icon: &Option<Icon>) -> Result<IdOrNone, anyhow::Error> {
if icon.is_none() {
return Ok(IdOrNone::NONE);
}
let icon = icon.as_ref().unwrap();
let db_icon = match get_icon_by_name(&icon.name) {
Ok(db_icon) => db_icon,
Err(e) => {
anyhow::bail!("Error fetching icon by name: {}", e);
}
};
if let Some(icon) = db_icon {
return Ok(IdOrNone::EXISTING(icon.id));
}
let file = get_icon_file(icon);
println!("Downloading icon {} to {}", icon.name, file.display());
download_file(&icon.url, &file).await?;
let new_icon: NewDbIcon = NewDbIcon {
name: icon.name.clone(),
url: icon.url.clone(),
path: file.to_string_lossy().to_string(),
content_type: icon.content_type.clone(),
width: icon.width as i32,
height: icon.height as i32,
state: None,
};
match add_icon(&new_icon) {
Ok(icon) => {
info!("Added icon {} to database with ID {}", new_icon.name, icon);
Ok(icon)
}
Err(e) => {
anyhow::bail!("Failed to add icon {} to database: {}", new_icon.name, e);
}
}
}

View File

@@ -1,3 +1,12 @@
use std::fmt::{self, Display};
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use clap::{Parser, Subcommand};
use uuid::Uuid;
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum ResourceState {
Parsed(bool),
@@ -6,16 +15,30 @@ pub enum ResourceState {
#[derive(Debug, PartialEq, Eq)]
pub struct Icon {
pub name: String,
pub path: Option<String>,
pub url: String,
pub width: u32,
pub height: u32,
pub content_type: String,
}
impl std::default::Default for Icon {
fn default() -> Self {
Icon {
name: String::new(),
path: None,
url: String::new(),
width: 0,
height: 0,
content_type: String::new(),
}
}
}
impl Clone for Icon {
fn clone(&self) -> Self {
Icon {
name: self.name.clone(),
path: self.path.clone(),
url: self.url.clone(),
width: self.width,
height: self.height,
@@ -24,7 +47,17 @@ impl Clone for Icon {
}
}
#[derive(Debug, PartialEq, Eq, Clone)]
impl Display for Icon {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Icon {{ name: {}, url: {}, width: {}, height: {}, content_type: {} }}",
self.name, self.url, self.width, self.height, self.content_type
)
}
}
#[derive(Debug, Eq, Clone)]
pub struct Resource {
pub name: String,
pub title: String,
@@ -33,6 +66,127 @@ pub struct Resource {
pub state: ResourceState,
}
impl Resource {
pub fn new(name: String, title: String, url: Option<String>, icon: Option<Icon>) -> Self {
Resource {
name,
title,
url,
icon,
state: ResourceState::Unparsed,
}
}
pub fn from_resource_with_quantity(resource: ResourceWithQuantity) -> Self {
Resource {
name: resource.name,
title: resource.title,
url: resource.url,
icon: resource.icon,
state: resource.state,
}
}
}
impl PartialEq for Resource {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
&& self.title == other.title
&& self.url == other.url
&& self.icon == other.icon
&& self.state == other.state
}
}
impl PartialEq<&ResourceWithQuantity> for Resource {
fn eq(&self, other: &&ResourceWithQuantity) -> bool {
self.name == other.name
&& self.title == other.title
&& self.url == other.url
&& self.icon == other.icon
&& self.state == other.state
}
}
#[derive(Debug, Eq, Clone)]
pub struct ResourceWithQuantity {
pub name: String,
pub title: String,
pub url: Option<String>,
pub icon: Option<Icon>,
pub state: ResourceState,
pub quantity: u32,
}
impl ResourceWithQuantity {
pub fn from_resource(resource: Resource, quantity: u32) -> Self {
ResourceWithQuantity {
name: resource.name,
title: resource.title,
url: resource.url,
icon: resource.icon,
state: resource.state,
quantity,
}
}
pub fn from_ingredient(ingredient: Ingredient) -> Self {
ResourceWithQuantity {
name: ingredient.resource.name,
title: ingredient.resource.title,
url: ingredient.resource.url,
icon: ingredient.resource.icon,
state: ingredient.resource.state,
quantity: ingredient.quantity,
}
}
pub fn new(
name: String,
title: String,
url: Option<String>,
icon: Option<Icon>,
quantity: u32,
) -> Self {
ResourceWithQuantity {
name,
title,
url,
icon,
state: ResourceState::Unparsed,
quantity,
}
}
pub fn to_resource(&self) -> Resource {
Resource {
name: self.name.clone(),
title: self.title.clone(),
url: self.url.clone(),
icon: self.icon.clone(),
state: self.state.clone(),
}
}
}
impl PartialEq for ResourceWithQuantity {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
&& self.title == other.title
&& self.url == other.url
&& self.icon == other.icon
&& self.state == other.state
&& self.quantity == other.quantity
}
}
impl PartialEq<&Resource> for ResourceWithQuantity {
fn eq(&self, other: &&Resource) -> bool {
self.name == other.name
&& self.title == other.title
&& self.url == other.url
&& self.icon == other.icon
&& self.state == other.state
}
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct Ingredient {
pub resource: Resource,
@@ -46,6 +200,32 @@ pub enum RecipeType {
Cooking,
}
impl RecipeType {
pub fn from_str(s: &str) -> Option<RecipeType> {
match s {
"production" => Some(RecipeType::Production),
"refining" => Some(RecipeType::Refining),
"cooking" => Some(RecipeType::Cooking),
_ => None,
}
}
pub fn as_string(&self) -> String {
match self {
RecipeType::Production => String::from("production"),
RecipeType::Refining => String::from("refining"),
RecipeType::Cooking => String::from("cooking"),
}
}
pub fn as_str(&self) -> &str {
match self {
RecipeType::Production => "production",
RecipeType::Refining => "refining",
RecipeType::Cooking => "cooking",
}
}
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct Duration {
pub millis: u64,
@@ -55,7 +235,122 @@ pub struct Duration {
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct Recipe {
pub recipe_type: RecipeType,
pub resource: Ingredient,
pub resource: ResourceWithQuantity,
pub duration: Duration,
pub ingredients: Vec<Ingredient>,
}
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
pub struct CallArguments {
#[arg(short, long, default_value_t = false)]
pub server: bool,
#[command(subcommand)]
pub client_commands: Option<ClientCommands>,
}
#[derive(Subcommand, Debug)]
pub enum ClientCommands {
/// Adds files to myapp
Add {
name: String,
},
Get {
id_or_name: String,
},
}
pub struct ServerError(anyhow::Error);
// Tell axum how to convert `AppError` into a response.
impl IntoResponse for ServerError {
fn into_response(self) -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error: {}", self.0),
)
.into_response()
}
}
impl<E> From<E> for ServerError
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
Self(err.into())
}
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum IdOrNone {
NEW(Uuid),
EXISTING(Uuid),
NONE,
}
impl fmt::Display for IdOrNone {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
IdOrNone::NEW(id) => write!(f, "+{}", id.urn().to_string()),
IdOrNone::EXISTING(id) => write!(f, "{}", id.urn().to_string()),
IdOrNone::NONE => write!(f, "None"),
}
}
}
impl IdOrNone {
pub fn to_id(&self) -> ID {
match self {
IdOrNone::NEW(id) => ID::NEW(*id),
IdOrNone::EXISTING(id) => ID::EXISTING(*id),
_ => panic!("Cannot convert ID to IdOrNone"),
}
}
pub fn from_id(id: ID) -> IdOrNone {
match id {
ID::NEW(id) => IdOrNone::NEW(id),
ID::EXISTING(id) => IdOrNone::EXISTING(id),
}
}
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum ID {
NEW(Uuid),
EXISTING(Uuid),
}
impl fmt::Display for ID {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
ID::NEW(id) => write!(f, "+{}", id.urn().to_string()),
ID::EXISTING(id) => write!(f, "{}", id.urn().to_string()),
}
}
}
impl ID {
pub fn to_id_none(&self) -> IdOrNone {
match self {
ID::NEW(id) => IdOrNone::NEW(*id),
ID::EXISTING(id) => IdOrNone::EXISTING(*id),
}
}
pub fn from_id(id: IdOrNone) -> ID {
match id {
IdOrNone::NEW(id) => ID::NEW(id),
IdOrNone::EXISTING(id) => ID::EXISTING(id),
IdOrNone::NONE => panic!("Cannot convert ID::NONE to EXISTING_ID"),
}
}
pub fn as_uuid(&self) -> Uuid {
match self {
ID::NEW(id) => *id,
ID::EXISTING(id) => *id,
}
}
}

View File

@@ -1,3 +0,0 @@
pub fn parse_command_line_args() -> Vec<String> {
std::env::args().skip(1).collect()
}

79
src/util/log_filter.rs Normal file
View File

@@ -0,0 +1,79 @@
use log::{Level, Record};
use log4rs::{
config::{Deserialize, Deserializers},
filter::{Filter, Response},
};
#[derive(serde::Deserialize)]
pub struct TargetFilterConfig {
negation: bool,
level: Vec<String>,
}
#[derive(Debug)]
pub struct TargetFilter {
level: Vec<Level>,
negation: bool,
}
impl TargetFilter {
pub fn new(level: Vec<Level>, negation: bool) -> TargetFilter {
TargetFilter { level, negation }
}
}
impl Filter for TargetFilter {
fn filter(&self, record: &Record) -> Response {
if self.level.contains(&record.level()) {
// Always allow error messages
if !self.negation {
Response::Accept
} else {
Response::Reject
}
} else {
if self.negation {
Response::Accept
} else {
Response::Reject
}
}
}
}
pub struct TargetFilterDeserializer;
impl Deserialize for TargetFilterDeserializer {
type Trait = dyn Filter;
type Config = TargetFilterConfig;
fn deserialize(
&self,
config: TargetFilterConfig,
_: &Deserializers,
) -> anyhow::Result<Box<Self::Trait>> {
Ok(Box::new(TargetFilter::new(
config
.level
.iter()
.map(|lvl| match lvl.to_lowercase().as_str() {
"error" => Level::Error,
"warn" => Level::Warn,
"info" => Level::Info,
"debug" => Level::Debug,
"trace" => Level::Trace,
_ => panic!("Unknown log level {}", lvl), // Default to Off if level is not recognized
})
.collect(),
config.negation,
)))
}
}
pub fn initialize_logging(log_file: &str) -> Result<(), anyhow::Error> {
let mut target_filter = Deserializers::new();
target_filter.insert("target_filter", TargetFilterDeserializer);
Ok(log4rs::init_file(log_file, target_filter)?)
}

View File

@@ -1,2 +1,4 @@
pub mod cmd;
pub use cmd::*;
pub mod log_filter;
pub mod net;
pub use log_filter::*;
pub use net::*;

72
src/util/net.rs Normal file
View File

@@ -0,0 +1,72 @@
use reqwest::{
Client,
header::{HeaderMap, HeaderValue},
};
pub async fn download_file(url: &str, _path: &str) -> Result<String, Box<dyn std::error::Error>> {
// Some simple CLI args requirements...
// reqwest::blocking::get() is a convenience function.
//
// In most cases, you should create/build a reqwest::Client and reuse
// it for all requests.
let res = reqwest::get(url).await?;
let body = res.text().await?;
Ok(body)
}
pub async fn validate_url(url: &str) -> bool {
// Check if the URL is valid
let parsed_url = url::Url::parse(url);
if parsed_url.is_err() {
eprintln!("Invalid URL: {}", url);
return false;
}
let parsed_url = parsed_url.unwrap();
// Check if the URL has a valid scheme
if !["http", "https"].contains(&parsed_url.scheme()) {
eprintln!("Unsupported URL scheme: {}", parsed_url.scheme());
return false;
}
let client = Client::builder()
.danger_accept_invalid_certs(true)
.build()
.unwrap_or_default();
let response = client
.get(parsed_url.as_str())
.headers(construct_headers())
.send()
.await;
match response {
Ok(resp) => {
println!(
"StatusCode: {} {}",
resp.status(),
resp.status().is_success()
);
resp.status().is_success()
}
Err(err) => {
println!("code:{},error:{}", err.status().unwrap_or_default(), err);
false
}
}
}
fn construct_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert("User-Agent", HeaderValue::from_static("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36"));
headers.insert("Accept", HeaderValue::from_static("text/html"));
headers.insert(
"Referer",
HeaderValue::from_static("https://www.google.com"),
);
headers
}