Compare commits

..

11 Commits

Author SHA1 Message Date
e6f2397da6 Store to db 2025-08-23 19:23:36 +02:00
1089196226 Update 2025-06-17 14:46:48 +02:00
Stefan Menner
5a4bbc5297 update 2025-06-16 16:52:00 +02:00
6e7ac29a27 Update for db 2025-06-16 08:03:02 +02:00
15d8f488d6 t2 2025-06-07 12:05:07 +02:00
c21d895f35 t 2025-06-07 12:02:25 +02:00
Stefan Menner
8fc8dc1cc9 Update parser 2025-06-03 17:38:11 +02:00
d2589affe5 Parser update 2025-06-01 21:32:06 +02:00
4f55658b87 update 2025-05-29 11:43:28 +02:00
Stefan Menner
6b4cc88efa Update 2025-05-28 17:16:10 +02:00
ce3cf003ff Change metadata 2025-05-27 15:43:44 +02:00
46 changed files with 354407 additions and 133 deletions

1
.env
View File

@@ -1 +0,0 @@
DATABASE_URL=postgres://postgres:.admin1@localhost/no_man_sky2

13
.gitignore vendored
View File

@@ -20,8 +20,11 @@ Cargo.lock
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
.env
.env.*
!.env.example
!.env.*.example
# Ignore the .env file that contains sensitive information
.env
.env.*
!.env.example
!.env.*.example
# 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,11 +1,18 @@
{
"cSpell.words": [
"DATABASENAME",
"dotenv",
"Herstellung",
"Insertable",
"Kochen",
"mordit",
"MSVC",
"Quelle",
"Raffination",
"rustc",
"serde",
"Stück",
"VARCHAR",
"Verwendung"
]
}

View File

@@ -3,16 +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/merlin_env_helper.git"
repository = "ssh://git@gitea.merlinserver.de:2222/Stefan/no-man-sky-wiki.git"
[dependencies]
diesel = { version= "2.2.10", features = ["serde_json", "postgres", "uuid"]}
env_logger = "0.11.8"
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"
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,37 +1,40 @@
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,
"state" JSON
"state" JSONB
);
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),
"icon" UUID,
"state" JSON,
"state" JSONB,
FOREIGN KEY ("icon") REFERENCES "icon"("id")
);
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,
"state" JSON,
"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" JSON,
"state" JSONB,
"recipe" UUID NOT NULL,
FOREIGN KEY ("resource") REFERENCES "resource"("id"),
FOREIGN KEY ("recipe") REFERENCES "recipe"("id")

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,2 +1,50 @@
pub mod models;
pub use models::*;
pub mod schema;
pub mod util;
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,
}
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");
DbConnectionPool { pool }
}
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)?;
Ok(())
}
pub fn get_connection(&self) -> Result<PgPooledConnection, diesel::r2d2::PoolError> {
self.pool.get()
}
}

View File

@@ -2,46 +2,101 @@
#![allow(unused)]
#![allow(clippy::all)]
use diesel::{
prelude::Queryable,
sql_types::{Json, Uuid},
};
#[derive(Queryable, Debug)]
pub struct RowIcon {
use diesel::{pg::Pg, prelude::*};
use uuid::Uuid;
#[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 state: Option<Json>,
pub content_type: String,
pub url: String,
pub path: String,
pub width: i32,
pub height: i32,
pub state: Option<serde_json::Value>,
}
#[derive(Queryable, Debug)]
pub struct RowIngredient {
#[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,
pub resource: Uuid,
pub quantity: i32,
pub state: Option<Json>,
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, Debug)]
pub struct RowRecipe {
#[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 state: Option<Json>,
pub unit: String,
pub state: Option<serde_json::Value>,
}
#[derive(Queryable, Debug)]
pub struct RowResource {
#[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,
pub name: String,
pub title: String,
pub url: Option<String>,
pub icon: Option<Uuid>,
pub state: Option<Json>,
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,12 +6,14 @@ 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>,
state -> Nullable<Json>,
content_type -> Varchar,
#[max_length = 1024]
path -> Varchar,
#[max_length = 2048]
url -> Varchar,
width -> Int4,
height -> Int4,
state -> Nullable<Jsonb>,
}
}
@@ -20,7 +22,7 @@ diesel::table! {
id -> Uuid,
resource -> Uuid,
quantity -> Int4,
state -> Nullable<Json>,
state -> Nullable<Jsonb>,
recipe -> Uuid,
}
}
@@ -29,10 +31,13 @@ diesel::table! {
recipe (id) {
id -> Uuid,
resource -> Uuid,
quantity -> Int4,
#[max_length = 50]
recipe_type -> Varchar,
duration -> Int4,
state -> Nullable<Json>,
#[max_length = 50]
unit -> Varchar,
state -> Nullable<Jsonb>,
}
}
@@ -46,7 +51,7 @@ diesel::table! {
#[max_length = 512]
url -> Nullable<Varchar>,
icon -> Nullable<Uuid>,
state -> Nullable<Json>,
state -> Nullable<Jsonb>,
}
}
@@ -55,9 +60,4 @@ diesel::joinable!(ingredient -> resource (resource));
diesel::joinable!(recipe -> resource (resource));
diesel::joinable!(resource -> icon (icon));
diesel::allow_tables_to_appear_in_same_query!(
icon,
ingredient,
recipe,
resource,
);
diesel::allow_tables_to_appear_in_same_query!(icon, ingredient, recipe, resource,);

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,30 +1,51 @@
mod client;
mod configuration;
mod db;
mod parse;
mod server;
mod types;
mod util;
use std::{fs::File, io::Read};
use parse::parse;
use clap::Parser;
use dotenv::dotenv;
fn main() -> Result<(), Box<dyn std::error::Error>> {
env_logger::init();
let html = read("test_mordit.html")?;
parse(&html);
Ok(())
use crate::{
client::run_client,
configuration::{CONFIGURATION, Configuration},
server::run_server,
types::CallArguments,
util::initialize_logging,
};
#[tokio::main]
async fn main() {
dotenv().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
}