Test Gitea Runner functionality

This commit is contained in:
Warren
2026-05-30 14:08:55 +08:00
parent 596d8d5e27
commit b362e9b3f1
44 changed files with 1 additions and 0 deletions

553
filetree/src/mod.rs Normal file
View 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");
}
}