Test Gitea Runner functionality
This commit is contained in:
243
filetree/src/convert.rs
Normal file
243
filetree/src/convert.rs
Normal file
@@ -0,0 +1,243 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
const CACHE_DIR: &str = "data/cache";
|
||||
|
||||
// Phase 1: built-in macOS tools (no installation)
|
||||
const TEXTUTIL_FORMATS: &[&str] = &["docx", "doc", "rtf"];
|
||||
const APPLE_FORMATS: &[&str] = &["pages", "key", "numbers"];
|
||||
|
||||
// Phase 2: soffice/qlmanage fallback
|
||||
const SOFFICE_FORMATS: &[&str] = &["pptx", "ppt", "xlsx", "xls", "odt", "epub"];
|
||||
|
||||
pub fn is_document_ext(ext: &str) -> bool {
|
||||
TEXTUTIL_FORMATS.contains(&ext)
|
||||
|| APPLE_FORMATS.contains(&ext)
|
||||
|| SOFFICE_FORMATS.contains(&ext)
|
||||
}
|
||||
|
||||
pub fn is_textutil_ext(ext: &str) -> bool {
|
||||
TEXTUTIL_FORMATS.contains(&ext)
|
||||
}
|
||||
|
||||
pub fn is_apple_format_ext(ext: &str) -> bool {
|
||||
APPLE_FORMATS.contains(&ext)
|
||||
}
|
||||
|
||||
pub fn get_cached_preview(file_uuid: &str, ext: &str) -> Option<(PathBuf, &'static str)> {
|
||||
if TEXTUTIL_FORMATS.contains(&ext) {
|
||||
get_cached_txt(file_uuid).map(|p| (p, "text/plain; charset=utf-8"))
|
||||
} else if APPLE_FORMATS.contains(&ext) {
|
||||
get_cached_jpg(file_uuid).map(|p| (p, "image/jpeg"))
|
||||
} else {
|
||||
get_cached_pdf(file_uuid).map(|p| (p, "application/pdf"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_cached_txt(file_uuid: &str) -> Option<PathBuf> {
|
||||
let p = Path::new(CACHE_DIR).join(format!("{}.txt", file_uuid));
|
||||
p.exists().then_some(p)
|
||||
}
|
||||
|
||||
pub fn get_cached_jpg(file_uuid: &str) -> Option<PathBuf> {
|
||||
let p = Path::new(CACHE_DIR).join(format!("{}.jpg", file_uuid));
|
||||
p.exists().then_some(p)
|
||||
}
|
||||
|
||||
pub fn get_cached_pdf(file_uuid: &str) -> Option<PathBuf> {
|
||||
let p = Path::new(CACHE_DIR).join(format!("{}.pdf", file_uuid));
|
||||
p.exists().then_some(p)
|
||||
}
|
||||
|
||||
pub fn get_cached_png(file_uuid: &str) -> Option<PathBuf> {
|
||||
let p = Path::new(CACHE_DIR).join(format!("{}.png", file_uuid));
|
||||
p.exists().then_some(p)
|
||||
}
|
||||
|
||||
pub fn convert_document(input_path: &Path, file_uuid: &str) -> Result<(PathBuf, &'static str)> {
|
||||
let ext = input_path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
// Phase 1: built-in tools (fast, no installation)
|
||||
if TEXTUTIL_FORMATS.contains(&ext.as_str()) {
|
||||
let p = textutil_to_txt(input_path, file_uuid)?;
|
||||
return Ok((p, "text/plain; charset=utf-8"));
|
||||
}
|
||||
|
||||
if APPLE_FORMATS.contains(&ext.as_str()) {
|
||||
match unzip_preview_jpg(input_path, file_uuid) {
|
||||
Ok(p) => return Ok((p, "image/jpeg")),
|
||||
Err(e) => eprintln!("[markbase] unzip preview failed for {}: {}", file_uuid, e),
|
||||
}
|
||||
// Fall back to qlmanage PNG
|
||||
let p = qlmanage_to_png(input_path, file_uuid, 2048)?;
|
||||
return Ok((p, "image/png"));
|
||||
}
|
||||
|
||||
// Phase 2: soffice for Office formats
|
||||
if SOFFICE_FORMATS.contains(&ext.as_str()) {
|
||||
match soffice_to_pdf(input_path, file_uuid) {
|
||||
Ok(p) => return Ok((p, "application/pdf")),
|
||||
Err(e) => {
|
||||
eprintln!("[markbase] soffice failed for {}: {}", file_uuid, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback: qlmanage PNG
|
||||
let p = qlmanage_to_png(input_path, file_uuid, 2048)?;
|
||||
Ok((p, "image/png"))
|
||||
}
|
||||
|
||||
// ─── Phase 1: textutil (macOS built-in, .docx/.doc/.rtf → .txt) ───
|
||||
|
||||
fn textutil_to_txt(input_path: &Path, file_uuid: &str) -> Result<PathBuf> {
|
||||
let cache_dir = Path::new(CACHE_DIR);
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
|
||||
let output = cache_dir.join(format!("{}.txt", file_uuid));
|
||||
if output.exists() {
|
||||
return Ok(output);
|
||||
}
|
||||
|
||||
let out = Command::new("textutil")
|
||||
.args(["-convert", "txt", "-output"])
|
||||
.arg(&output)
|
||||
.arg(input_path)
|
||||
.output()
|
||||
.context("Failed to run textutil")?;
|
||||
|
||||
if !out.status.success() {
|
||||
anyhow::bail!("textutil: {}", String::from_utf8_lossy(&out.stderr).trim());
|
||||
}
|
||||
|
||||
if output.exists() {
|
||||
Ok(output)
|
||||
} else {
|
||||
anyhow::bail!("textutil did not produce {}", output.display())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Phase 1: unzip preview.jpg from iWork packages ───
|
||||
|
||||
fn unzip_preview_jpg(input_path: &Path, file_uuid: &str) -> Result<PathBuf> {
|
||||
let cache_dir = Path::new(CACHE_DIR);
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
|
||||
let output = cache_dir.join(format!("{}.jpg", file_uuid));
|
||||
if output.exists() {
|
||||
return Ok(output);
|
||||
}
|
||||
|
||||
let tmp = cache_dir.join(format!("_tmp_{}", file_uuid));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
|
||||
let out = Command::new("unzip")
|
||||
.args(["-o", "-d"])
|
||||
.arg(&tmp)
|
||||
.arg(input_path)
|
||||
.output()
|
||||
.context("Failed to unzip iWork package")?;
|
||||
|
||||
if !out.status.success() {
|
||||
anyhow::bail!("unzip: {}", String::from_utf8_lossy(&out.stderr).trim());
|
||||
}
|
||||
|
||||
// Look for preview.jpg, preview.pdf, or quicklook/thumbnail.jpg
|
||||
for name in &[
|
||||
"preview.jpg",
|
||||
"preview.png",
|
||||
"preview.pdf",
|
||||
"preview-web.jpg",
|
||||
] {
|
||||
let src = tmp.join(name);
|
||||
if src.exists() {
|
||||
std::fs::copy(&src, &output)?;
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
return Ok(output);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
anyhow::bail!("no preview found in iWork package")
|
||||
}
|
||||
|
||||
// ─── Phase 2: soffice (LibreOffice, multi-page PDF) ───
|
||||
|
||||
fn soffice_to_pdf(input_path: &Path, file_uuid: &str) -> Result<PathBuf> {
|
||||
let cache_dir = Path::new(CACHE_DIR);
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
|
||||
let output = cache_dir.join(format!("{}.pdf", file_uuid));
|
||||
if output.exists() {
|
||||
return Ok(output);
|
||||
}
|
||||
|
||||
let out = Command::new("soffice")
|
||||
.args(["--headless", "--convert-to", "pdf", "--outdir"])
|
||||
.arg(cache_dir)
|
||||
.arg(input_path)
|
||||
.output()
|
||||
.context("Failed to run soffice")?;
|
||||
|
||||
if !out.status.success() {
|
||||
anyhow::bail!("soffice: {}", String::from_utf8_lossy(&out.stderr).trim());
|
||||
}
|
||||
|
||||
let basename = input_path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown");
|
||||
let generated = cache_dir.join(format!("{}.pdf", basename));
|
||||
if generated.exists() && generated != output {
|
||||
std::fs::rename(&generated, &output)?;
|
||||
}
|
||||
|
||||
if output.exists() {
|
||||
Ok(output)
|
||||
} else {
|
||||
anyhow::bail!("soffice did not produce {}", output.display())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Phase 2: qlmanage (Apple QuickLook, PNG thumbnail) ───
|
||||
|
||||
fn qlmanage_to_png(input_path: &Path, file_uuid: &str, size: u32) -> Result<PathBuf> {
|
||||
let cache_dir = Path::new(CACHE_DIR);
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
|
||||
let output = cache_dir.join(format!("{}.png", file_uuid));
|
||||
if output.exists() {
|
||||
return Ok(output);
|
||||
}
|
||||
|
||||
let out = Command::new("qlmanage")
|
||||
.args(["-t", "-s", &size.to_string(), "-o"])
|
||||
.arg(cache_dir)
|
||||
.arg(input_path)
|
||||
.output()
|
||||
.context("Failed to run qlmanage")?;
|
||||
|
||||
if !out.status.success() {
|
||||
anyhow::bail!("qlmanage: {}", String::from_utf8_lossy(&out.stderr).trim());
|
||||
}
|
||||
|
||||
let filename = input_path
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown");
|
||||
let generated = cache_dir.join(format!("{}.png", filename));
|
||||
if generated.exists() && generated != output {
|
||||
std::fs::rename(&generated, &output)?;
|
||||
}
|
||||
|
||||
if output.exists() {
|
||||
Ok(output)
|
||||
} else {
|
||||
anyhow::bail!("qlmanage did not produce {}", output.display())
|
||||
}
|
||||
}
|
||||
553
filetree/src/mod.rs
Normal file
553
filetree/src/mod.rs
Normal file
@@ -0,0 +1,553 @@
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::Connection;
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::filetree::node::{Aliases, FileNode, NodeType};
|
||||
|
||||
pub mod convert;
|
||||
pub mod mode;
|
||||
pub mod modes;
|
||||
pub mod node;
|
||||
|
||||
pub struct FileTree {
|
||||
pub user_id: String,
|
||||
pub nodes: Vec<FileNode>,
|
||||
}
|
||||
|
||||
const CREATE_TABLES: &str = "
|
||||
CREATE TABLE IF NOT EXISTS file_registry (
|
||||
file_uuid TEXT PRIMARY KEY,
|
||||
original_name TEXT NOT NULL,
|
||||
file_size INTEGER,
|
||||
file_type TEXT,
|
||||
registered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_seen_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_nodes (
|
||||
node_id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
aliases_json TEXT NOT NULL DEFAULT '{}',
|
||||
file_uuid TEXT,
|
||||
sha256 TEXT,
|
||||
parent_id TEXT,
|
||||
children_json TEXT NOT NULL DEFAULT '[]',
|
||||
node_type TEXT NOT NULL DEFAULT 'folder',
|
||||
icon TEXT,
|
||||
color TEXT,
|
||||
bg_color TEXT,
|
||||
file_size INTEGER,
|
||||
registered_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_locations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_uuid TEXT NOT NULL,
|
||||
location TEXT NOT NULL,
|
||||
label TEXT,
|
||||
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(file_uuid, location)
|
||||
);
|
||||
";
|
||||
|
||||
impl FileTree {
|
||||
pub fn user_db_path(user_id: &str) -> String {
|
||||
format!("data/users/{}.sqlite", user_id)
|
||||
}
|
||||
|
||||
pub fn init_user_db(user_id: &str) -> Result<Connection> {
|
||||
let db_path = Self::user_db_path(user_id);
|
||||
let parent = std::path::Path::new(&db_path).parent().unwrap();
|
||||
std::fs::create_dir_all(parent)?;
|
||||
|
||||
let conn = Connection::open(&db_path)?;
|
||||
conn.execute_batch(CREATE_TABLES)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
pub fn open_user_db(user_id: &str) -> Result<Connection> {
|
||||
let db_path = Self::user_db_path(user_id);
|
||||
Connection::open(&db_path).with_context(|| format!("Failed to open {}", db_path))
|
||||
}
|
||||
|
||||
pub fn load(conn: &Connection, user_id: &str) -> Result<Self> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT node_id, label, aliases_json, file_uuid, sha256, parent_id, children_json,
|
||||
node_type, icon, color, bg_color, file_size, registered_at,
|
||||
created_at, updated_at, sort_order
|
||||
FROM file_nodes ORDER BY sort_order ASC, created_at ASC",
|
||||
)?;
|
||||
|
||||
let nodes: Vec<FileNode> = stmt
|
||||
.query_map([], |row| {
|
||||
let children_json: String = row.get(6)?;
|
||||
let children: Vec<String> =
|
||||
serde_json::from_str(&children_json).unwrap_or_default();
|
||||
Ok(FileNode {
|
||||
node_id: row.get(0)?,
|
||||
label: row.get(1)?,
|
||||
aliases: Aliases::from_json(&row.get::<_, String>(2)?),
|
||||
file_uuid: row.get(3)?,
|
||||
sha256: row.get(4)?,
|
||||
parent_id: row.get(5)?,
|
||||
children,
|
||||
node_type: NodeType::from_str(&row.get::<_, String>(7)?)
|
||||
.unwrap_or(NodeType::Folder),
|
||||
icon: row.get(8)?,
|
||||
color: row.get(9)?,
|
||||
bg_color: row.get(10)?,
|
||||
file_size: row.get(11)?,
|
||||
registered_at: row.get(12)?,
|
||||
created_at: row.get(13)?,
|
||||
updated_at: row.get(14)?,
|
||||
sort_order: row.get(15)?,
|
||||
})
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(FileTree {
|
||||
user_id: user_id.to_string(),
|
||||
nodes,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn insert_node(&mut self, conn: &Connection, node: &FileNode) -> Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO file_nodes (node_id, label, aliases_json, file_uuid, sha256, parent_id,
|
||||
children_json, node_type, icon, color, bg_color, file_size, registered_at,
|
||||
created_at, updated_at, sort_order)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
|
||||
rusqlite::params![
|
||||
node.node_id,
|
||||
node.label,
|
||||
node.aliases.to_json(),
|
||||
node.file_uuid,
|
||||
node.sha256,
|
||||
node.parent_id,
|
||||
serde_json::to_string(&node.children).unwrap_or_else(|_| "[]".to_string()),
|
||||
node.node_type.as_str(),
|
||||
node.icon,
|
||||
node.color,
|
||||
node.bg_color,
|
||||
node.file_size,
|
||||
node.registered_at,
|
||||
node.created_at,
|
||||
node.updated_at,
|
||||
node.sort_order,
|
||||
],
|
||||
)?;
|
||||
self.nodes.push(node.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_node(
|
||||
&mut self,
|
||||
conn: &Connection,
|
||||
node_id: &str,
|
||||
updated: &FileNode,
|
||||
) -> Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE file_nodes SET label=?1, aliases_json=?2, file_uuid=?3, sha256=?4, parent_id=?5,
|
||||
children_json=?6, node_type=?7, icon=?8, color=?9, bg_color=?10,
|
||||
file_size=?11, registered_at=?12,
|
||||
updated_at=?13, sort_order=?14
|
||||
WHERE node_id=?15",
|
||||
rusqlite::params![
|
||||
updated.label,
|
||||
updated.aliases.to_json(),
|
||||
updated.file_uuid,
|
||||
updated.sha256,
|
||||
updated.parent_id,
|
||||
serde_json::to_string(&updated.children)
|
||||
.unwrap_or_else(|_| "[]".to_string()),
|
||||
updated.node_type.as_str(),
|
||||
updated.icon,
|
||||
updated.color,
|
||||
updated.bg_color,
|
||||
updated.file_size,
|
||||
updated.registered_at,
|
||||
updated.updated_at,
|
||||
updated.sort_order,
|
||||
node_id,
|
||||
],
|
||||
)?;
|
||||
|
||||
if let Some(n) = self.nodes.iter_mut().find(|n| n.node_id == node_id) {
|
||||
*n = updated.clone();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_node_alias(
|
||||
&mut self,
|
||||
conn: &Connection,
|
||||
node_id: &str,
|
||||
lang: &str,
|
||||
value: &str,
|
||||
) -> Result<()> {
|
||||
let node = self
|
||||
.nodes
|
||||
.iter_mut()
|
||||
.find(|n| n.node_id == node_id)
|
||||
.context("Node not found")?;
|
||||
|
||||
node.aliases.set(lang, value);
|
||||
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
node.updated_at = now.clone();
|
||||
|
||||
conn.execute(
|
||||
"UPDATE file_nodes SET aliases_json=?1, updated_at=?2 WHERE node_id=?3",
|
||||
rusqlite::params![node.aliases.to_json(), now, node_id],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_node(&mut self, conn: &Connection, node_id: &str) -> Result<()> {
|
||||
conn.execute("DELETE FROM file_nodes WHERE node_id=?1", [node_id])?;
|
||||
self.nodes.retain(|n| n.node_id != node_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn move_node(
|
||||
&mut self,
|
||||
conn: &Connection,
|
||||
node_id: &str,
|
||||
new_parent_id: Option<String>,
|
||||
) -> Result<()> {
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
|
||||
conn.execute(
|
||||
"UPDATE file_nodes SET parent_id=?1, updated_at=?2 WHERE node_id=?3",
|
||||
rusqlite::params![new_parent_id, now, node_id],
|
||||
)?;
|
||||
|
||||
if let Some(n) = self.nodes.iter_mut().find(|n| n.node_id == node_id) {
|
||||
n.parent_id = new_parent_id;
|
||||
n.updated_at = now;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn build_tree(&self) -> Vec<FileNode> {
|
||||
let mut roots: Vec<FileNode> = self
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|n| n.parent_id.is_none())
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for root in &mut roots {
|
||||
self.fill_children(root);
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
fn fill_children(&self, node: &mut FileNode) {
|
||||
let child_ids: Vec<String> = node.children.clone();
|
||||
let mut children: Vec<FileNode> = self
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|n| {
|
||||
n.parent_id.as_deref() == Some(&node.node_id) && child_ids.contains(&n.node_id)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for child in &mut children {
|
||||
self.fill_children(child);
|
||||
}
|
||||
|
||||
node.children = children.iter().map(|c| c.node_id.clone()).collect();
|
||||
node.children
|
||||
.extend(children.iter().flat_map(|c| c.children.clone()));
|
||||
}
|
||||
|
||||
pub fn new_folder(label: &str, parent_id: Option<String>) -> FileNode {
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
FileNode {
|
||||
node_id: Uuid::new_v4().to_string(),
|
||||
label: label.to_string(),
|
||||
aliases: Aliases::empty(),
|
||||
file_uuid: None,
|
||||
sha256: None,
|
||||
parent_id,
|
||||
children: vec![],
|
||||
node_type: NodeType::Folder,
|
||||
icon: None,
|
||||
color: None,
|
||||
bg_color: None,
|
||||
file_size: None,
|
||||
registered_at: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
sort_order: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new_file_node(
|
||||
label: &str,
|
||||
file_uuid: &str,
|
||||
sha256: Option<&str>,
|
||||
original_name: &str,
|
||||
file_size: Option<i64>,
|
||||
file_type: Option<&str>,
|
||||
registered_at: Option<&str>,
|
||||
parent_id: Option<String>,
|
||||
) -> (FileNode, Option<String>) {
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
let reg_at = registered_at
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| now.clone());
|
||||
let node = FileNode {
|
||||
node_id: Uuid::new_v4().to_string(),
|
||||
label: label.to_string(),
|
||||
aliases: Aliases::empty(),
|
||||
file_uuid: Some(file_uuid.to_string()),
|
||||
sha256: sha256.map(|s| s.to_string()),
|
||||
parent_id,
|
||||
children: vec![],
|
||||
node_type: NodeType::File,
|
||||
icon: None,
|
||||
color: None,
|
||||
bg_color: None,
|
||||
file_size,
|
||||
registered_at: Some(reg_at),
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
sort_order: 0,
|
||||
};
|
||||
|
||||
let register_sql =
|
||||
format!(
|
||||
"INSERT OR REPLACE INTO file_registry (file_uuid, original_name, file_size, file_type,
|
||||
registered_at, status) VALUES ('{}', '{}', {}, {}, '{}', 'active')",
|
||||
file_uuid,
|
||||
original_name.replace('\'', "''"),
|
||||
file_size.map_or("NULL".to_string(), |s| s.to_string()),
|
||||
file_type.map_or("NULL".to_string(), |t| format!("'{}'", t.replace('\'', "''"))),
|
||||
now,
|
||||
);
|
||||
|
||||
(node, Some(register_sql))
|
||||
}
|
||||
|
||||
pub fn add_location(
|
||||
conn: &Connection,
|
||||
file_uuid: &str,
|
||||
location: &str,
|
||||
label: Option<&str>,
|
||||
) -> Result<()> {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO file_locations (file_uuid, location, label) VALUES (?1, ?2, ?3)",
|
||||
rusqlite::params![file_uuid, location, label],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_file_info(conn: &Connection, file_uuid: &str) -> Result<serde_json::Value> {
|
||||
let mut virtual_paths: Vec<String> = Vec::new();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT fn.node_id, fn.label, fn.parent_id FROM file_nodes fn WHERE fn.file_uuid = ?1",
|
||||
)?;
|
||||
let node_rows: Vec<(String, String, Option<String>)> = stmt
|
||||
.query_map([file_uuid], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
for (_nid, _label, _parent_id) in &node_rows {
|
||||
let mut path_parts: Vec<String> = vec![];
|
||||
let mut current = _parent_id.clone();
|
||||
while let Some(pid) = current {
|
||||
let folder: Option<(String, Option<String>)> = conn
|
||||
.query_row(
|
||||
"SELECT label, parent_id FROM file_nodes WHERE node_id = ?1",
|
||||
[&pid],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.ok();
|
||||
if let Some((name, next_pid)) = folder {
|
||||
path_parts.push(name);
|
||||
current = next_pid;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
path_parts.reverse();
|
||||
virtual_paths.push(path_parts.join(" / "));
|
||||
}
|
||||
|
||||
let mut real_locations: Vec<serde_json::Value> = Vec::new();
|
||||
let mut lstmt = conn.prepare(
|
||||
"SELECT location, label, added_at FROM file_locations WHERE file_uuid = ?1 ORDER BY added_at",
|
||||
)?;
|
||||
let locs: Vec<(String, Option<String>, String)> = lstmt
|
||||
.query_map([file_uuid], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
for (loc, lbl, added) in &locs {
|
||||
real_locations.push(serde_json::json!({
|
||||
"path": loc,
|
||||
"label": lbl,
|
||||
"added_at": added,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"file_uuid": file_uuid,
|
||||
"virtual_paths": virtual_paths,
|
||||
"real_locations": real_locations,
|
||||
"node_count": node_rows.len(),
|
||||
"location_count": locs.len(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_db() -> (Connection, String) {
|
||||
let user_id = format!("test_{}", Uuid::new_v4());
|
||||
let conn = FileTree::init_user_db(&user_id).unwrap();
|
||||
(conn, user_id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_and_load_empty_tree() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
assert_eq!(tree.user_id, user_id);
|
||||
assert!(tree.nodes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_and_load_node() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let folder = FileTree::new_folder("Videos", None);
|
||||
tree.insert_node(&conn, &folder).unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
assert_eq!(loaded.nodes.len(), 1);
|
||||
assert_eq!(loaded.nodes[0].label, "Videos");
|
||||
assert_eq!(loaded.nodes[0].node_type, NodeType::Folder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_node() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let mut folder = FileTree::new_folder("Videos", None);
|
||||
tree.insert_node(&conn, &folder).unwrap();
|
||||
|
||||
folder.label = "Movies".to_string();
|
||||
folder.icon = Some("📽️".to_string());
|
||||
folder.color = Some("#ff0000".to_string());
|
||||
tree.update_node(&conn, &folder.node_id, &folder).unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
assert_eq!(loaded.nodes[0].label, "Movies");
|
||||
assert_eq!(loaded.nodes[0].icon, Some("📽️".to_string()));
|
||||
assert_eq!(loaded.nodes[0].color, Some("#ff0000".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_node() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let folder = FileTree::new_folder("Temp", None);
|
||||
let node_id = folder.node_id.clone();
|
||||
tree.insert_node(&conn, &folder).unwrap();
|
||||
|
||||
tree.delete_node(&conn, &node_id).unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
assert_eq!(loaded.nodes.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_node() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let root = FileTree::new_folder("Root", None);
|
||||
let child = FileTree::new_folder("Child", Some(root.node_id.clone()));
|
||||
|
||||
tree.insert_node(&conn, &root).unwrap();
|
||||
tree.insert_node(&conn, &child).unwrap();
|
||||
|
||||
tree.move_node(&conn, &child.node_id, None).unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
let moved = loaded.nodes.iter().find(|n| n.label == "Child").unwrap();
|
||||
assert!(moved.parent_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_alias() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let folder = FileTree::new_folder("Videos", None);
|
||||
tree.insert_node(&conn, &folder).unwrap();
|
||||
|
||||
tree.update_node_alias(&conn, &folder.node_id, "zh_tw", "影片")
|
||||
.unwrap();
|
||||
|
||||
let loaded = FileTree::load(&conn, &user_id).unwrap();
|
||||
assert_eq!(
|
||||
loaded.nodes[0].aliases.get("zh_tw").map(|s| s.as_str()),
|
||||
Some("影片")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_tree() {
|
||||
let (conn, user_id) = temp_db();
|
||||
let mut tree = FileTree::load(&conn, &user_id).unwrap();
|
||||
|
||||
let root = FileTree::new_folder("Root", None);
|
||||
let child1 = FileTree::new_folder("Child1", Some(root.node_id.clone()));
|
||||
let child2 = FileTree::new_folder("Child2", Some(root.node_id.clone()));
|
||||
let grandchild = FileTree::new_folder("Grandchild", Some(child1.node_id.clone()));
|
||||
|
||||
// Set parent-child relationships
|
||||
let mut root_with_children = root.clone();
|
||||
root_with_children.children = vec![child1.node_id.clone(), child2.node_id.clone()];
|
||||
let mut child1_with_children = child1.clone();
|
||||
child1_with_children.children = vec![grandchild.node_id.clone()];
|
||||
|
||||
tree.insert_node(&conn, &root_with_children).unwrap();
|
||||
tree.insert_node(&conn, &child1_with_children).unwrap();
|
||||
tree.insert_node(&conn, &child2).unwrap();
|
||||
tree.insert_node(&conn, &grandchild).unwrap();
|
||||
|
||||
let roots = tree.build_tree();
|
||||
assert_eq!(roots.len(), 1);
|
||||
assert_eq!(roots[0].label, "Root");
|
||||
}
|
||||
}
|
||||
43
filetree/src/mode.rs
Normal file
43
filetree/src/mode.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::filetree::FileTree;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SortOption {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct FilterOption {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait DisplayMode: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
fn render(&self, tree: &FileTree) -> Value;
|
||||
fn sort_options(&self) -> Vec<SortOption>;
|
||||
fn filter_options(&self) -> Vec<FilterOption>;
|
||||
}
|
||||
|
||||
pub fn get_mode(name: &str) -> Option<Box<dyn DisplayMode>> {
|
||||
match name {
|
||||
"tree" => Some(Box::new(crate::filetree::modes::tree::TreeMode)),
|
||||
"list" => Some(Box::new(crate::filetree::modes::list::ListMode)),
|
||||
"grid_sm" => Some(Box::new(crate::filetree::modes::grid_sm::GridSmMode)),
|
||||
"grid_lg" => Some(Box::new(crate::filetree::modes::grid_lg::GridLgMode)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_modes() -> Vec<Box<dyn DisplayMode>> {
|
||||
vec![
|
||||
Box::new(crate::filetree::modes::list::ListMode),
|
||||
Box::new(crate::filetree::modes::tree::TreeMode),
|
||||
Box::new(crate::filetree::modes::grid_sm::GridSmMode),
|
||||
Box::new(crate::filetree::modes::grid_lg::GridLgMode),
|
||||
]
|
||||
}
|
||||
83
filetree/src/modes/grid_lg.rs
Normal file
83
filetree/src/modes/grid_lg.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::filetree::mode::{DisplayMode, FilterOption, SortOption};
|
||||
use crate::filetree::FileTree;
|
||||
|
||||
pub struct GridLgMode;
|
||||
|
||||
#[async_trait]
|
||||
impl DisplayMode for GridLgMode {
|
||||
fn name(&self) -> &'static str {
|
||||
"grid_lg"
|
||||
}
|
||||
|
||||
fn render(&self, tree: &FileTree) -> Value {
|
||||
let nodes: Vec<Value> = tree
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
json!({
|
||||
"node_id": n.node_id,
|
||||
"label": n.label,
|
||||
"aliases": n.aliases,
|
||||
"file_uuid": n.file_uuid,
|
||||
"node_type": n.node_type.as_str(),
|
||||
"icon": n.icon,
|
||||
"color": n.color,
|
||||
"bg_color": n.bg_color,
|
||||
"sort_order": n.sort_order,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
json!({
|
||||
"mode": "grid_lg",
|
||||
"user_id": tree.user_id,
|
||||
"cell_size": 192,
|
||||
"nodes": nodes,
|
||||
})
|
||||
}
|
||||
|
||||
fn sort_options(&self) -> Vec<SortOption> {
|
||||
vec![
|
||||
SortOption {
|
||||
key: "name_asc".into(),
|
||||
label: "Name A-Z".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "name_desc".into(),
|
||||
label: "Name Z-A".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "date_desc".into(),
|
||||
label: "Newest First".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "type".into(),
|
||||
label: "By Type".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn filter_options(&self) -> Vec<FilterOption> {
|
||||
vec![
|
||||
FilterOption {
|
||||
key: "all".into(),
|
||||
label: "All".into(),
|
||||
},
|
||||
FilterOption {
|
||||
key: "folder".into(),
|
||||
label: "Folders".into(),
|
||||
},
|
||||
FilterOption {
|
||||
key: "file".into(),
|
||||
label: "Files".into(),
|
||||
},
|
||||
FilterOption {
|
||||
key: "video".into(),
|
||||
label: "Video Files".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
72
filetree/src/modes/grid_sm.rs
Normal file
72
filetree/src/modes/grid_sm.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::filetree::mode::{DisplayMode, FilterOption, SortOption};
|
||||
use crate::filetree::FileTree;
|
||||
|
||||
pub struct GridSmMode;
|
||||
|
||||
#[async_trait]
|
||||
impl DisplayMode for GridSmMode {
|
||||
fn name(&self) -> &'static str {
|
||||
"grid_sm"
|
||||
}
|
||||
|
||||
fn render(&self, tree: &FileTree) -> Value {
|
||||
let nodes: Vec<Value> = tree
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
json!({
|
||||
"node_id": n.node_id,
|
||||
"label": n.label,
|
||||
"node_type": n.node_type.as_str(),
|
||||
"icon": n.icon,
|
||||
"color": n.color,
|
||||
"bg_color": n.bg_color,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
json!({
|
||||
"mode": "grid_sm",
|
||||
"user_id": tree.user_id,
|
||||
"cell_size": 96,
|
||||
"nodes": nodes,
|
||||
})
|
||||
}
|
||||
|
||||
fn sort_options(&self) -> Vec<SortOption> {
|
||||
vec![
|
||||
SortOption {
|
||||
key: "name_asc".into(),
|
||||
label: "Name A-Z".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "name_desc".into(),
|
||||
label: "Name Z-A".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "date_desc".into(),
|
||||
label: "Newest First".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn filter_options(&self) -> Vec<FilterOption> {
|
||||
vec![
|
||||
FilterOption {
|
||||
key: "all".into(),
|
||||
label: "All".into(),
|
||||
},
|
||||
FilterOption {
|
||||
key: "folder".into(),
|
||||
label: "Folders".into(),
|
||||
},
|
||||
FilterOption {
|
||||
key: "file".into(),
|
||||
label: "Files".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
87
filetree/src/modes/list.rs
Normal file
87
filetree/src/modes/list.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::filetree::mode::{DisplayMode, FilterOption, SortOption};
|
||||
use crate::filetree::FileTree;
|
||||
|
||||
pub struct ListMode;
|
||||
|
||||
#[async_trait]
|
||||
impl DisplayMode for ListMode {
|
||||
fn name(&self) -> &'static str {
|
||||
"list"
|
||||
}
|
||||
|
||||
fn render(&self, tree: &FileTree) -> Value {
|
||||
let nodes: Vec<Value> = tree
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
json!({
|
||||
"node_id": n.node_id,
|
||||
"label": n.label,
|
||||
"aliases": n.aliases,
|
||||
"file_uuid": n.file_uuid,
|
||||
"sha256": n.sha256,
|
||||
"parent_id": n.parent_id,
|
||||
"node_type": n.node_type.as_str(),
|
||||
"icon": n.icon,
|
||||
"color": n.color,
|
||||
"bg_color": n.bg_color,
|
||||
"file_size": n.file_size,
|
||||
"registered_at": n.registered_at,
|
||||
"sort_order": n.sort_order,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
json!({
|
||||
"mode": "list",
|
||||
"user_id": tree.user_id,
|
||||
"columns": ["icon", "label", "node_type", "updated_at"],
|
||||
"nodes": nodes,
|
||||
})
|
||||
}
|
||||
|
||||
fn sort_options(&self) -> Vec<SortOption> {
|
||||
vec![
|
||||
SortOption {
|
||||
key: "name_asc".into(),
|
||||
label: "Name A-Z".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "name_desc".into(),
|
||||
label: "Name Z-A".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "date_desc".into(),
|
||||
label: "Newest First".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "date_asc".into(),
|
||||
label: "Oldest First".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "type".into(),
|
||||
label: "By Type".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn filter_options(&self) -> Vec<FilterOption> {
|
||||
vec![
|
||||
FilterOption {
|
||||
key: "all".into(),
|
||||
label: "All".into(),
|
||||
},
|
||||
FilterOption {
|
||||
key: "folder".into(),
|
||||
label: "Folders".into(),
|
||||
},
|
||||
FilterOption {
|
||||
key: "file".into(),
|
||||
label: "Files".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
4
filetree/src/modes/mod.rs
Normal file
4
filetree/src/modes/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod grid_lg;
|
||||
pub mod grid_sm;
|
||||
pub mod list;
|
||||
pub mod tree;
|
||||
82
filetree/src/modes/tree.rs
Normal file
82
filetree/src/modes/tree.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::filetree::mode::{DisplayMode, FilterOption, SortOption};
|
||||
use crate::filetree::FileTree;
|
||||
|
||||
pub struct TreeMode;
|
||||
|
||||
#[async_trait]
|
||||
impl DisplayMode for TreeMode {
|
||||
fn name(&self) -> &'static str {
|
||||
"tree"
|
||||
}
|
||||
|
||||
fn render(&self, tree: &FileTree) -> Value {
|
||||
let nodes: Vec<Value> = tree
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
json!({
|
||||
"node_id": n.node_id,
|
||||
"label": n.label,
|
||||
"aliases": n.aliases,
|
||||
"file_uuid": n.file_uuid,
|
||||
"sha256": n.sha256,
|
||||
"parent_id": n.parent_id,
|
||||
"node_type": n.node_type.as_str(),
|
||||
"icon": n.icon,
|
||||
"color": n.color,
|
||||
"bg_color": n.bg_color,
|
||||
"file_size": n.file_size,
|
||||
"registered_at": n.registered_at,
|
||||
"children": n.children,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
json!({
|
||||
"mode": "tree",
|
||||
"user_id": tree.user_id,
|
||||
"nodes": nodes,
|
||||
})
|
||||
}
|
||||
|
||||
fn sort_options(&self) -> Vec<SortOption> {
|
||||
vec![
|
||||
SortOption {
|
||||
key: "name_asc".into(),
|
||||
label: "Name A-Z".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "name_desc".into(),
|
||||
label: "Name Z-A".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "date_desc".into(),
|
||||
label: "Newest First".into(),
|
||||
},
|
||||
SortOption {
|
||||
key: "date_asc".into(),
|
||||
label: "Oldest First".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn filter_options(&self) -> Vec<FilterOption> {
|
||||
vec![
|
||||
FilterOption {
|
||||
key: "all".into(),
|
||||
label: "All".into(),
|
||||
},
|
||||
FilterOption {
|
||||
key: "folder".into(),
|
||||
label: "Folders".into(),
|
||||
},
|
||||
FilterOption {
|
||||
key: "file".into(),
|
||||
label: "Files".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
89
filetree/src/node.rs
Normal file
89
filetree/src/node.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileNode {
|
||||
pub node_id: String,
|
||||
pub label: String,
|
||||
pub aliases: Aliases,
|
||||
pub file_uuid: Option<String>,
|
||||
pub sha256: Option<String>,
|
||||
pub parent_id: Option<String>,
|
||||
pub children: Vec<String>,
|
||||
pub node_type: NodeType,
|
||||
pub icon: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub bg_color: Option<String>,
|
||||
pub file_size: Option<i64>,
|
||||
pub registered_at: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub sort_order: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Aliases {
|
||||
#[serde(flatten)]
|
||||
pub map: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Aliases {
|
||||
pub fn empty() -> Self {
|
||||
Aliases {
|
||||
map: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> String {
|
||||
serde_json::to_string(&self.map).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
pub fn from_json(s: &str) -> Self {
|
||||
let map: HashMap<String, String> = serde_json::from_str(s).unwrap_or_default();
|
||||
Aliases { map }
|
||||
}
|
||||
|
||||
pub fn set(&mut self, lang: &str, value: &str) {
|
||||
self.map.insert(lang.to_string(), value.to_string());
|
||||
}
|
||||
|
||||
pub fn get(&self, lang: &str) -> Option<&String> {
|
||||
self.map.get(lang)
|
||||
}
|
||||
|
||||
pub fn first_value(&self) -> Option<&String> {
|
||||
self.map.values().next()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NodeType {
|
||||
Folder,
|
||||
File,
|
||||
DynamicLayer,
|
||||
}
|
||||
|
||||
impl NodeType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
NodeType::Folder => "folder",
|
||||
NodeType::File => "file",
|
||||
NodeType::DynamicLayer => "dynamic_layer",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for NodeType {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"folder" => Ok(NodeType::Folder),
|
||||
"file" => Ok(NodeType::File),
|
||||
"dynamic_layer" => Ok(NodeType::DynamicLayer),
|
||||
_ => Ok(NodeType::Folder), // Default to Folder for unknown types
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user