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
56 changed files with 366192 additions and 1 deletions

8
.gitignore vendored
View File

@@ -20,3 +20,11 @@ Cargo.lock
# and can be added to the global gitignore or merged into this file. For a more nuclear # 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. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
.env
.env.*
!.env.example
!.env.*.example
# Ignore the .env file that contains sensitive information
log/*
!log/.keep

69
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +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": "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}"
}
]
}

18
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,18 @@
{
"cSpell.words": [
"DATABASENAME",
"dotenv",
"Herstellung",
"Insertable",
"Kochen",
"mordit",
"MSVC",
"Quelle",
"Raffination",
"rustc",
"serde",
"Stück",
"VARCHAR",
"Verwendung"
]
}

30
Cargo.toml Normal file
View File

@@ -0,0 +1,30 @@
[package]
name = "no-man-sky"
version = "0.1.0"
edition = "2024"
publish = ["merlin"]
description = "NoManSky Server/Client utility"
license = "MIT"
repository = "ssh://git@gitea.merlinserver.de:2222/Stefan/no-man-sky-wiki.git"
[dependencies]
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"
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" }
#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"

View File

@@ -1,3 +1,5 @@
# no-man-sky-wiki # no-man-sky-wiki
No-man-sky resource parser & resource lib No-man-sky resource parser & resource lib
Test1

9
diesel.toml Normal file
View File

@@ -0,0 +1,9 @@
# For documentation on how to configure this file,
# see https://diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/db/schema.rs"
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
[migrations_directory]
dir = "/home/stefan/projects/rust/no-man-sky-wiki/migrations"

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

0
migrations/.keep Normal file
View File

View File

@@ -0,0 +1,6 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
DROP FUNCTION IF EXISTS diesel_set_updated_at();

View File

@@ -0,0 +1,36 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
-- Sets up a trigger for the given table to automatically set a column called
-- `updated_at` whenever the row is modified (unless `updated_at` was included
-- in the modified columns)
--
-- # Example
--
-- ```sql
-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
--
-- SELECT diesel_manage_updated_at('users');
-- ```
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
BEGIN
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
BEGIN
IF (
NEW IS DISTINCT FROM OLD AND
NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
) THEN
NEW.updated_at := current_timestamp;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

View File

@@ -0,0 +1,11 @@
-- This file should undo anything in `up.sql`
DROP INDEX IF EXISTS idx_icon_name;
DROP INDEX IF EXISTS idx_resource_name;
DROP INDEX IF EXISTS idx_resource_title;
DROP INDEX IF EXISTS idx_recipe_type;
DROP TABLE IF EXISTS "ingredient";
DROP TABLE IF EXISTS "recipe";
DROP TABLE IF EXISTS "resource";
DROP TABLE IF EXISTS "icon";

View File

@@ -0,0 +1,59 @@
CREATE TABLE "icon"(
"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" JSONB
);
CREATE TABLE "resource"(
"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" JSONB,
FOREIGN KEY ("icon") REFERENCES "icon"("id")
);
CREATE TABLE "recipe"(
"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 DEFAULT gen_random_uuid () NOT NULL PRIMARY KEY,
"resource" UUID NOT NULL,
"quantity" INT4 NOT NULL,
"state" JSONB,
"recipe" UUID NOT NULL,
FOREIGN KEY ("resource") REFERENCES "resource"("id"),
FOREIGN KEY ("recipe") REFERENCES "recipe"("id")
);
CREATE INDEX IF NOT EXISTS idx_icon_name
ON icon USING btree
(name COLLATE pg_catalog."default" ASC NULLS LAST);
CREATE INDEX IF NOT EXISTS idx_recipe_type
ON recipe USING btree
(recipe_type COLLATE pg_catalog."default" ASC NULLS LAST);
CREATE INDEX IF NOT EXISTS idx_resource_name
ON resource USING btree
(name COLLATE pg_catalog."default" ASC NULLS LAST);
CREATE INDEX IF NOT EXISTS idx_resource_title
ON resource USING btree
(title COLLATE pg_catalog."default" ASC NULLS LAST);

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"

188
snipped.html Normal file
View File

@@ -0,0 +1,188 @@
<span
class="resource-substanzmithoherenergie"
style="border: 1px solid #d3d3d3"
>
<span class="mw-valign-text-bottom" typeof="mw:File">
<a href="/de/wiki/Diwasserstoff" title="Diwasserstoff">
<img
class="mw-file-element lazyload"
data-image-key="SUBSTANCE.LAUNCHSUB.1.png"
data-image-name="SUBSTANCE.LAUNCHSUB.1.png"
data-relevant="1"
data-src="https://static.wikia.nocookie.net/nomanssky_gamepedia/images/0/03/SUBSTANCE.LAUNCHSUB.1.png/revision/latest/scale-to-width-down/18?cb=20180725071716"
decoding="async"
height="18"
loading="lazy"
src="data:image/gif;base64,R0lGODlhAQABAIABAAAAAP///yH5BAEAAAEALAAAAAABAAEAQAICTAEAOw%3D%3D"
width="18"
/>
</a>
</span>
</span>
<a href="/de/wiki/Diwasserstoff" title="Diwasserstoff">
<span class="itemlink ajaxttlink">Diwasserstoff</span>
</a>
x1&nbsp;&nbsp;+&nbsp;&nbsp;
<span
class="resource-konzentrierterflüssigtreibstoff"
style="border: 1px solid #d3d3d3"
>
<span class="mw-valign-text-bottom" typeof="mw:File">
<a href="/de/wiki/Sauerstoff" title="Sauerstoff">
<img
class="mw-file-element lazyload"
data-image-key="SUBSTANCE.AIR.1.png"
data-image-name="SUBSTANCE.AIR.1.png"
data-relevant="1"
data-src="https://static.wikia.nocookie.net/nomanssky_gamepedia/images/e/ec/SUBSTANCE.AIR.1.png/revision/latest/scale-to-width-down/18?cb=20221007220715"
decoding="async"
height="18"
loading="lazy"
src="data:image/gif;base64,R0lGODlhAQABAIABAAAAAP///yH5BAEAAAEALAAAAAABAAEAQAICTAEAOw%3D%3D"
width="18"
/>
</a>
</span>
</span>
<a href="/de/wiki/Sauerstoff" title="Sauerstoff">
<span class="itemlink ajaxttlink">Sauerstoff</span>
</a>
x1&nbsp;&nbsp;&nbsp;&nbsp;
<span
class="resource-aquatischesmineral-extrakt"
style="border: 1px solid #d3d3d3"
>
<span class="mw-valign-text-bottom" typeof="mw:File">
<a href="/de/wiki/Salz" title="Salz">
<img
class="mw-file-element lazyload"
data-image-key="SUBSTANCE.WATER.1.png"
data-image-name="SUBSTANCE.WATER.1.png"
data-relevant="1"
data-src="https://static.wikia.nocookie.net/nomanssky_gamepedia/images/9/9f/SUBSTANCE.WATER.1.png/revision/latest/scale-to-width-down/18?cb=20180726042119"
decoding="async"
height="18"
loading="lazy"
src="data:image/gif;base64,R0lGODlhAQABAIABAAAAAP///yH5BAEAAAEALAAAAAABAAEAQAICTAEAOw%3D%3D"
width="18"
/>
</a>
</span>
</span>
<strong class="mw-selflink selflink">
<span class="itemlink ajaxttlink">Salz</span>
</strong>
x1&nbsp;&nbsp;
<small>( <i>"Schnelle Formation/Verdunstung"</i>,&nbsp;0,08 sek./Stück)</small>
<ul>
<li>
<span
class="resource-verarbeiteteswassermineral"
style="border: 1px solid #d3d3d3"
><span class="mw-valign-text-bottom" typeof="mw:File"
><a href="/de/wiki/Chlor" title="Chlor"
><img
src="data:image/gif;base64,R0lGODlhAQABAIABAAAAAP///yH5BAEAAAEALAAAAAABAAEAQAICTAEAOw%3D%3D"
decoding="async"
loading="lazy"
width="18"
height="18"
class="mw-file-element lazyload"
data-image-name="SUBSTANCE.WATER.2.png"
data-image-key="SUBSTANCE.WATER.2.png"
data-relevant="1"
data-src="https://static.wikia.nocookie.net/nomanssky_gamepedia/images/2/25/SUBSTANCE.WATER.2.png/revision/latest/scale-to-width-down/18?cb=20180726040634" /></a></span
></span>
<a href="/de/wiki/Chlor" title="Chlor"
><span class="itemlink ajaxttlink">Chlor</span></a
>
x1&#160;&#160;&#8594;&#160;&#160;
<span
class="resource-aquatischesmineral-extrakt"
style="border: 1px solid #d3d3d3"
><span class="mw-valign-text-bottom" typeof="mw:File"
><a href="/de/wiki/Salz" title="Salz"
><img
src="data:image/gif;base64,R0lGODlhAQABAIABAAAAAP///yH5BAEAAAEALAAAAAABAAEAQAICTAEAOw%3D%3D"
decoding="async"
loading="lazy"
width="18"
height="18"
class="mw-file-element lazyload"
data-image-name="SUBSTANCE.WATER.1.png"
data-image-key="SUBSTANCE.WATER.1.png"
data-relevant="1"
data-src="https://static.wikia.nocookie.net/nomanssky_gamepedia/images/9/9f/SUBSTANCE.WATER.1.png/revision/latest/scale-to-width-down/18?cb=20180726042119" /></a></span
></span>
<strong class="mw-selflink selflink"
><span class="itemlink ajaxttlink">Salz</span></strong
>
x2&#160;&#160;<small>(<i>"Salzproduktion"</i>,&#160;0,24 sek./Stück)</small>
</li>
<li>
<span
class="resource-substanzmithoherenergie"
style="border: 1px solid #d3d3d3"
><span class="mw-valign-text-bottom" typeof="mw:File"
><a href="/de/wiki/Diwasserstoff" title="Diwasserstoff"
><img
src="data:image/gif;base64,R0lGODlhAQABAIABAAAAAP///yH5BAEAAAEALAAAAAABAAEAQAICTAEAOw%3D%3D"
decoding="async"
loading="lazy"
width="18"
height="18"
class="mw-file-element lazyload"
data-image-name="SUBSTANCE.LAUNCHSUB.1.png"
data-image-key="SUBSTANCE.LAUNCHSUB.1.png"
data-relevant="1"
data-src="https://static.wikia.nocookie.net/nomanssky_gamepedia/images/0/03/SUBSTANCE.LAUNCHSUB.1.png/revision/latest/scale-to-width-down/18?cb=20180725071716" /></a></span
></span>
<a href="/de/wiki/Diwasserstoff" title="Diwasserstoff"
><span class="itemlink ajaxttlink">Diwasserstoff</span></a
>
x1&#160;&#160;+&#160;&#160;
<span
class="resource-konzentrierterflüssigtreibstoff"
style="border: 1px solid #d3d3d3"
><span class="mw-valign-text-bottom" typeof="mw:File"
><a href="/de/wiki/Sauerstoff" title="Sauerstoff"
><img
src="data:image/gif;base64,R0lGODlhAQABAIABAAAAAP///yH5BAEAAAEALAAAAAABAAEAQAICTAEAOw%3D%3D"
decoding="async"
loading="lazy"
width="18"
height="18"
class="mw-file-element lazyload"
data-image-name="SUBSTANCE.AIR.1.png"
data-image-key="SUBSTANCE.AIR.1.png"
data-relevant="1"
data-src="https://static.wikia.nocookie.net/nomanssky_gamepedia/images/e/ec/SUBSTANCE.AIR.1.png/revision/latest/scale-to-width-down/18?cb=20221007220715" /></a></span
></span>
<a href="/de/wiki/Sauerstoff" title="Sauerstoff"
><span class="itemlink ajaxttlink">Sauerstoff</span></a
>
x1&#160;&#160;&#8594;&#160;&#160;<span
class="resource-aquatischesmineral-extrakt"
style="border: 1px solid #d3d3d3"
><span class="mw-valign-text-bottom" typeof="mw:File"
><a href="/de/wiki/Salz" title="Salz"
><img
src="data:image/gif;base64,R0lGODlhAQABAIABAAAAAP///yH5BAEAAAEALAAAAAABAAEAQAICTAEAOw%3D%3D"
decoding="async"
loading="lazy"
width="18"
height="18"
class="mw-file-element lazyload"
data-image-name="SUBSTANCE.WATER.1.png"
data-image-key="SUBSTANCE.WATER.1.png"
data-relevant="1"
data-src="https://static.wikia.nocookie.net/nomanssky_gamepedia/images/9/9f/SUBSTANCE.WATER.1.png/revision/latest/scale-to-width-down/18?cb=20180726042119" /></a></span
></span>
<strong class="mw-selflink selflink"
><span class="itemlink ajaxttlink">Salz</span></strong
>
x1&#160;&#160;<small
>(<i>"Schnelle Formation/Verdunstung"</i>,&#160;0,08 sek./Stück)</small
>
</li>
</ul>

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()
}

50
src/db/mod.rs Normal file
View File

@@ -0,0 +1,50 @@
pub mod 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()
}
}

102
src/db/models.rs Normal file
View File

@@ -0,0 +1,102 @@
// Generated by diesel_ext
#![allow(unused)]
#![allow(clippy::all)]
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: String,
pub url: String,
pub path: String,
pub width: i32,
pub height: i32,
pub state: Option<serde_json::Value>,
}
#[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<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(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(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<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>,
}

63
src/db/schema.rs Normal file
View File

@@ -0,0 +1,63 @@
// @generated automatically by Diesel CLI.
diesel::table! {
icon (id) {
id -> Uuid,
#[max_length = 255]
name -> Varchar,
#[max_length = 255]
content_type -> Varchar,
#[max_length = 1024]
path -> Varchar,
#[max_length = 2048]
url -> Varchar,
width -> Int4,
height -> Int4,
state -> Nullable<Jsonb>,
}
}
diesel::table! {
ingredient (id) {
id -> Uuid,
resource -> Uuid,
quantity -> Int4,
state -> Nullable<Jsonb>,
recipe -> Uuid,
}
}
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>,
}
}
diesel::table! {
resource (id) {
id -> Uuid,
#[max_length = 255]
name -> Varchar,
#[max_length = 255]
title -> Varchar,
#[max_length = 512]
url -> Nullable<Varchar>,
icon -> Nullable<Uuid>,
state -> Nullable<Jsonb>,
}
}
diesel::joinable!(ingredient -> recipe (recipe));
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,);

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)
}
}

56
src/main.rs Normal file
View File

@@ -0,0 +1,56 @@
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 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");
}
}
async fn _run_client(cli: &CallArguments) -> Result<(), Box<dyn std::error::Error>> {
run_client(cli).await
}
async fn _run_server() -> Result<(), Box<dyn std::error::Error>> {
run_server().await
}
fn read(path: &str) -> Result<String, std::io::Error> {
let mut file = File::open(path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}

587
src/parse/mod.rs Normal file
View File

@@ -0,0 +1,587 @@
use crate::types::{
Duration, Icon, Ingredient, Recipe, RecipeType, Resource, ResourceState, ResourceWithQuantity,
};
use regex::Regex;
use select::{
document::Document,
node::Node,
predicate::{Attr, Name, Or},
};
use std::collections::HashMap;
#[derive(Debug, PartialEq, Eq)]
enum ParseType {
Link { url: String, title: String },
Img(Icon),
Count(u32),
Resource(String),
ResourceAdd,
ResourceLast,
DashDash,
Duration(Duration),
}
type ResourceTmp = (
Option<String>, // name
Option<String>, // title
Option<Icon>, // icon
Option<u32>, // count
Option<Duration>, // duration
);
pub fn parse(html: &str) -> Vec<Recipe> {
let document = Document::from(html);
let mut map_resource: HashMap<String, Resource> = HashMap::new();
let mut recipes: Vec<Recipe> = Vec::new();
parse_source(&document, &mut map_resource, &mut recipes);
parse_dst(
&document,
"Raffination",
RecipeType::Refining,
&mut map_resource,
&mut recipes,
);
parse_dst(
&document,
"Herstellung",
RecipeType::Production,
&mut map_resource,
&mut recipes,
);
parse_dst(
&document,
"Raffination_2",
RecipeType::Refining,
&mut map_resource,
&mut recipes,
);
parse_dst(
&document,
"Kochen",
RecipeType::Cooking,
&mut map_resource,
&mut recipes,
);
recipes
}
fn first<T>(iter: &mut impl Iterator<Item = T>) -> Option<T> {
iter.next()
}
fn first_child_element(node: Node<'_>) -> Option<Node<'_>> {
if node.children().next().is_none() {
return None;
}
let mut next = node.children().next();
while next.is_some() {
let child = next.unwrap();
if child.name().is_some() {
return Some(child);
}
next = child.next();
}
None
}
fn parse_resource_items(
resource_items: Vec<ParseType>,
recipe_type: RecipeType,
map_resource: &mut HashMap<String, Resource>,
) -> Option<Recipe> {
let mut tmp_resource: ResourceTmp = (None, None, None, None, None);
let mut ingredient_to_add: Vec<Ingredient> = Vec::new();
let mut not_add = false;
for item in resource_items.iter() {
match item {
ParseType::Link { url, title } => {
if tmp_resource.0.is_none() {
tmp_resource.0 = Some(title.to_string());
}
if tmp_resource.1.is_none() {
tmp_resource.1 = Some(url.to_string());
}
// println!("Link: {} - {}", url, title);
}
ParseType::Img(icon) => {
if tmp_resource.2.is_none() {
tmp_resource.2 = Some(icon.clone());
}
}
ParseType::Count(count) => {
if tmp_resource.3.is_none() {
tmp_resource.3 = Some(*count);
}
}
ParseType::Resource(resource) => {
// println!("Resource: {}", resource);
if tmp_resource.0.is_none() {
tmp_resource.0 = Some(resource.to_string());
}
}
ParseType::ResourceAdd => {
if !not_add {
add(&tmp_resource, map_resource, &mut ingredient_to_add);
}
not_add = false;
tmp_resource = (None, None, None, None, None); // Reset for next resource
// println!("ResourceAdd");
}
ParseType::ResourceLast => {
if !not_add {
add(&tmp_resource, map_resource, &mut ingredient_to_add);
}
not_add = false;
tmp_resource = (None, None, None, None, None); // Reset for next resource
// println!("ResourceLast");
}
ParseType::Duration(duration) => {
// println!(">>> Duration: {} {}", duration, unit);
if tmp_resource.4.is_none() {
tmp_resource.4 = Some(duration.clone());
}
}
ParseType::DashDash => {
not_add = false;
tmp_resource = (None, None, None, None, None); // Reset for next resource
}
}
}
let (_, ingredient) = create_resource_and_ingredient(&tmp_resource, map_resource);
if !ingredient_to_add.is_empty() {
let recipe = crate::types::Recipe {
recipe_type: recipe_type,
resource: ResourceWithQuantity::from_ingredient(ingredient),
duration: tmp_resource.4.unwrap_or(Duration {
millis: 0,
unit: "Stück".to_string(),
}),
ingredients: ingredient_to_add,
};
return Some(recipe);
}
None
}
fn print_recipe(recipes: &Vec<Recipe>, recipe_type: RecipeType) {
for recipe in recipes
.iter()
.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.name, recipe.resource.quantity, icon
);
println!("Duration: {} ms", recipe.duration.millis);
println!("Ingredients:");
for ingredient in &recipe.ingredients {
println!(
"- {} ({} x {})",
ingredient.resource.name, ingredient.quantity, ingredient.resource.title
);
}
println!();
}
}
fn parse_dst(
document: &Document,
id: &str,
recipe_type: RecipeType,
map_resource: &mut HashMap<String, Resource>,
recipes: &mut Vec<Recipe>,
) -> bool {
let mut dest = document.find(Attr("id", id));
let dest = first(&mut dest);
if dest.is_none() {
return false;
}
let dst = dest.unwrap();
let mut dst = dst.parent().unwrap();
let mut elt_ul = None;
while dst.next().is_some() {
dst = dst.next().unwrap();
if let Some(name) = dst.name() {
if name == "ul" {
elt_ul = Some(dst);
break;
}
if name == "h2" {
break;
}
let first_child = first_child_element(dst);
if name == "h3"
&& first_child.is_some()
&& first_child.unwrap().name().unwrap() == "span"
&& first_child.unwrap().attr("id").is_some()
{
break;
}
}
}
if elt_ul.is_none() {
return false;
}
let elt_ul = elt_ul.unwrap();
let li = elt_ul.find(Name("li"));
for item in li {
if let Some(recipe) = parse_li_to_resource(&item, recipe_type.clone(), map_resource) {
recipes.push(recipe);
}
}
return true;
}
fn parse_source(
document: &Document,
map_resource: &mut HashMap<String, Resource>,
recipes: &mut Vec<Recipe>,
) -> bool {
let mut source = document.find(Attr("id", "Quelle"));
let source = first(&mut source);
if source.is_none() {
return false;
}
let source = source.unwrap();
let mut source = source.parent().unwrap();
let mut c = 0;
let mut elt_ul = None;
while source.next().is_some() {
source = source.next().unwrap();
if let Some(name) = source.name() {
if name == "ul" {
c += 1;
if c > 1 {
elt_ul = Some(source);
break;
}
}
if name == "h2" {
break;
}
}
}
if elt_ul.is_none() {
return false;
}
let elt_ul = elt_ul.unwrap();
let li = elt_ul.find(Name("li"));
for item in li {
if let Some(recipe) = parse_li_to_resource(&item, RecipeType::Refining, map_resource) {
recipes.push(recipe);
}
}
return true;
}
fn parse_li_to_resource(
item: &Node<'_>,
recipe_type: RecipeType,
map_resource: &mut HashMap<String, Resource>,
) -> Option<Recipe> {
if item.children().next().is_none() {
return None;
}
let mut resource_items: Vec<ParseType> = Vec::new();
let selector = item.find(Or(
Name("strong"),
Or(Or(Name("span"), Name("a")), Or(Name("img"), Name("small"))),
));
for child in selector {
let name = child.name().unwrap();
if name == "a" && child.attr("href").is_some() && child.attr("title").is_some() {
let txt = get_text_next(&child);
resource_items.push(ParseType::Link {
url: child.attr("href").unwrap().to_string(),
title: child.attr("title").unwrap().to_string(),
});
if !txt.is_empty() {
parse_text(&txt, &mut resource_items);
}
if txt == "--" {
resource_items.push(ParseType::DashDash);
}
} else if name == "img"
&& child.attr("data-src").is_some()
&& child.attr("width").is_some()
&& child.attr("height").is_some()
&& child.attr("data-image-name").is_some()
{
let url = child.attr("data-src").unwrap();
let name = child.attr("data-image-name").unwrap();
let width = child.attr("width").unwrap().parse().unwrap_or(0);
let height = child.attr("height").unwrap().parse().unwrap_or(0);
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
}));
} else if name == "span"
&& !child.text().is_empty()
&& (child.parent().unwrap().name().unwrap() == "strong"
|| child.parent().unwrap().name().unwrap() == "span")
{
let txt = child.text().trim().to_string();
resource_items.push(ParseType::Resource(txt));
let txt = get_text_next(&child.parent().unwrap());
parse_text(&txt, &mut resource_items);
} else if name == "strong" {
// let txt = get_text_next(&child);
// parse_text(&txt, &mut resource_items);
} else if name == "small" {
let txt = get_text(&child);
parse_text(&txt, &mut resource_items);
}
}
parse_resource_items(resource_items, recipe_type, map_resource)
}
fn to_name(name: &str) -> String {
normalize_text(name).replace(" ", "_").to_lowercase()
}
fn create_resource_and_ingredient(
tmp_resource: &ResourceTmp,
map_resource: &mut HashMap<String, Resource>,
) -> (Resource, Ingredient) {
let title = tmp_resource.0.as_ref().unwrap().clone();
let name = to_name(&title);
let url = tmp_resource.1.clone();
let icon = tmp_resource.2.clone();
let count = tmp_resource.3.unwrap_or(1);
let mut resource = Resource {
name: name.clone(),
title,
url,
icon,
state: ResourceState::Unparsed,
};
if map_resource.contains_key(&name) {
let res = map_resource.get_mut(&name).unwrap();
if res.url.is_some() {
resource.url = res.url.clone();
}
if res.icon.is_some() {
resource.icon = res.icon.clone();
}
}
let ingredient = Ingredient {
resource: resource.clone(),
quantity: count,
};
(resource, ingredient)
}
fn add(
tmp_resource: &ResourceTmp,
map_resource: &mut HashMap<String, Resource>,
ingredient_to_add: &mut Vec<Ingredient>,
) {
if tmp_resource.0.is_none() {
return;
}
let (resource, ingredient) = create_resource_and_ingredient(tmp_resource, map_resource);
map_resource.insert(resource.name.clone(), resource);
ingredient_to_add.push(ingredient);
}
fn normalize_text(text: &str) -> String {
let mut text = text
.trim()
.replace('\n', " ")
.replace('\r', " ")
.replace('\t', " ");
for c in text.clone().chars() {
if c.is_control() || c.is_whitespace() {
text = text.replace(c, " ");
}
}
while text.contains(" ") {
text = text.replace(" ", " ");
}
text
}
fn get_text(node: &Node<'_>) -> String {
let mut text = String::new();
if node.as_text().is_some() {
text.push_str(node.as_text().unwrap().to_string().as_str());
}
if node.children().next().is_some() {
for child in node.descendants() {
if child.as_text().is_some() {
text.push_str(child.as_text().unwrap().to_string().as_str());
}
}
}
if node.next().is_some() {
let mut next = node.next();
while next.is_some() {
let next_node = next.unwrap();
if next_node.as_text().is_some() {
text.push_str(next_node.as_text().unwrap());
} else {
break;
}
next = next_node.next();
}
}
return normalize_text(&text);
}
fn get_text_next(node: &Node<'_>) -> String {
if node.as_text().is_some() {
return normalize_text(&node.as_text().unwrap());
}
let next = node.next();
if !next.is_some() {
return String::new();
}
let next = next.unwrap();
if next.as_text().is_some() {
let mut text = next.as_text().unwrap().trim().to_string();
let mut next = next.next();
while next.is_some() {
let node = next.unwrap();
next = node.next();
if node.as_text().is_some() {
text.push_str(node.as_text().unwrap().trim());
} else if node.name().is_some() && node.name().unwrap() == "i" {
text.push_str(node.text().trim());
} else {
break;
}
}
return normalize_text(&text);
}
String::new()
}
fn parse_text(text: &str, resource_items: &mut Vec<ParseType>) {
let reg_count_next = Regex::new(r"^\s*x(?<count>\d+)\s+(?<end>[→+])\s*$").unwrap();
let reg_count = Regex::new(r"^\s*x(?<count>\d+)\s*.*$").unwrap();
let reg_duration =
Regex::new(r"^.*\(.*(?<duration>\d+(|,\d+))\ssek\./(?<unit>\w+)\s*\)$").unwrap();
if let Some(res) = reg_count_next.captures(text) {
let count = res.name("count").unwrap().as_str().parse().unwrap_or(0);
resource_items.push(ParseType::Count(count));
let end = res.name("end").unwrap().as_str().to_string();
if end == "+" {
resource_items.push(ParseType::ResourceAdd);
} else {
resource_items.push(ParseType::ResourceLast);
}
return;
}
if let Some(res) = reg_count.captures(text) {
let count = res.name("count").unwrap().as_str().parse().unwrap_or(0);
resource_items.push(ParseType::Count(count));
}
if let Some(res) = reg_duration.captures(text) {
let duration_str = res.name("duration").unwrap().as_str();
let duration: f64 = duration_str.replace(',', ".").parse().unwrap_or(0.0);
let unit = res.name("unit").unwrap().as_str().to_string();
let duration: u64 = (duration * 1000.0) as u64; // Convert to milliseconds
resource_items.push(ParseType::Duration(Duration {
millis: duration,
unit,
}));
}
}

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);
}
}
}

8
src/test.html Normal file
View File

@@ -0,0 +1,8 @@
<html>
<body>
<img
src="data:image/gif;base64,R0lGODlhAQABAIABAAAAAP///yH5BAEAAAEALAAAAAABAAEAQAICTAEAOw%3D%3D"
width="18"
/>
</body>
</html>

2
src/types/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod types;
pub use types::*;

356
src/types/types.rs Normal file
View File

@@ -0,0 +1,356 @@
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),
Unparsed,
}
#[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,
content_type: self.content_type.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,
pub url: Option<String>,
pub icon: Option<Icon>,
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,
pub quantity: u32,
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum RecipeType {
Production,
Refining,
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,
pub unit: String,
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct Recipe {
pub recipe_type: RecipeType,
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,
}
}
}

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)?)
}

4
src/util/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
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
}

3347
test.html Normal file

File diff suppressed because one or more lines are too long

3501
test_mordit.html Normal file

File diff suppressed because one or more lines are too long

3877
test_vc.html Normal file

File diff suppressed because one or more lines are too long