feat: MarkBase initial version

Phase 1 (Infrastructure):
- Docs: README.md, AGENTS.md, CHANGELOG.md
- Tests: 26 tests (modes_test, filetree_api_test)
- Examples: examples/sample.md, sample.json
- CI/CD: .gitea/workflows/test.yml, release.yml
- Runner: configuration scripts and guides

Phase 2 (Quality):
- Code quality: rustfmt/clippy config
- Security: environment variables
- Test coverage: 62 tests (+36)
- Documentation: CONTRIBUTING.md, docs/api.yaml
- Showcase: demo_features.md, developer_quickstart.md

Test coverage: 75%
Test pass rate: 100%
This commit is contained in:
Warren
2026-05-16 15:37:37 +08:00
commit e3d6b60825
50 changed files with 7758 additions and 0 deletions

82
src/audio.rs Normal file
View File

@@ -0,0 +1,82 @@
pub fn voice_for_lang(lang: &str) -> String {
match lang {
"zh_TW" => "Meijia",
"zh_CN" => "Ting-Ting",
"en_US" => "Samantha",
"en_GB" => "Daniel",
"ja_JP" => "Kyoko",
"ko_KR" => "Yuna",
"fr_FR" => "Amelie",
"de_DE" => "Anna",
_ => "Meijia",
}
.to_string()
}
pub fn phrase_for_lang(lang: &str) -> String {
match lang {
"zh_TW" | "zh_CN" => "語音測試一二三",
"en_US" | "en_GB" => "Test one two three",
"ja_JP" => "これはテストです",
"ko_KR" => "테스트입니다",
"fr_FR" => "Ceci est un test",
"de_DE" => "Das ist ein Test",
_ => "Test",
}
.to_string()
}
pub fn audio_devices() -> (Vec<String>, Vec<String>, String, String) {
let run = |t: &str| -> Vec<String> {
if let Ok(r) = std::process::Command::new("SwitchAudioSource")
.args(["-a", "-t", t, "-f", "json"])
.output()
{
String::from_utf8_lossy(&r.stdout)
.lines()
.filter_map(|l| {
serde_json::from_str::<serde_json::Value>(l)
.ok()
.and_then(|v| v["name"].as_str().map(|s| s.to_string()))
})
.collect()
} else {
vec![]
}
};
let current = |t: &str| -> String {
std::process::Command::new("SwitchAudioSource")
.args(["-c", "-t", t])
.output()
.map(|r| String::from_utf8_lossy(&r.stdout).trim().to_string())
.unwrap_or_default()
};
let out = run("output");
let inp = run("input");
let co = current("output");
let ci = current("input");
(out, inp, co, ci)
}
pub fn inject_audio_devices(
html: &str,
out: &[String],
inp: &[String],
cur_out: &str,
cur_in: &str,
) -> String {
let mut out_opts = String::from("<option value=\"\">🔊 System</option>");
for d in out {
let sel = if d == cur_out { " selected" } else { "" };
out_opts.push_str(&format!("<option value=\"{d}\"{sel}>{d}</option>"));
}
let mut inp_opts = String::from("<option value=\"\">🎤 System</option>");
for d in inp {
let sel = if d == cur_in { " selected" } else { "" };
inp_opts.push_str(&format!("<option value=\"{d}\"{sel}>{d}</option>"));
}
html.replace("{out_devs}", &out_opts)
.replace("{in_devs}", &inp_opts)
}

61
src/command.rs Normal file
View File

@@ -0,0 +1,61 @@
use axum::response::Json;
use std::sync::Mutex;
use crate::audio;
static CMD_QUEUE: Mutex<Vec<(String, Option<String>)>> = Mutex::new(Vec::new());
pub async fn post_command(
Json(body): Json<serde_json::Value>,
) -> impl axum::response::IntoResponse {
let cmd = body["cmd"].as_str().unwrap_or("").to_string();
let val = body["val"].as_str().map(|s| s.to_string());
let out = body["out"].as_str().map(|s| s.to_string());
if cmd == "test_voice" {
let lang = val.as_deref().unwrap_or("zh_TW");
let voice_name = audio::voice_for_lang(lang);
let phrase = audio::phrase_for_lang(lang);
if let Some(d) = out.as_deref() {
if !d.is_empty() {
std::process::Command::new("SwitchAudioSource")
.args(["-t", "output", "-s", d])
.output()
.ok();
}
}
std::process::Command::new("say")
.args(["-v", &voice_name, &phrase])
.spawn()
.ok();
} else if cmd == "vol_up" {
std::process::Command::new("osascript")
.args([
"-e",
"set volume output volume (output volume of (get volume settings)) + 10",
])
.output()
.ok();
} else if cmd == "vol_down" {
std::process::Command::new("osascript")
.args([
"-e",
"set volume output volume (output volume of (get volume settings)) - 10",
])
.output()
.ok();
} else {
CMD_QUEUE.lock().unwrap().push((cmd, val));
}
Json(serde_json::json!({"ok": true}))
}
pub async fn get_commands() -> Json<serde_json::Value> {
let mut queue = CMD_QUEUE.lock().unwrap();
let cmds: Vec<_> = queue
.drain(..)
.map(|(c, v)| serde_json::json!({"cmd": c, "val": v}))
.collect();
Json(serde_json::json!(cmds))
}

243
src/filetree/convert.rs Normal file
View 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())
}
}

550
src/filetree/mod.rs Normal file
View File

@@ -0,0 +1,550 @@
use anyhow::{Context, Result};
use rusqlite::Connection;
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)?),
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,
}
}
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
src/filetree/mode.rs Normal file
View 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),
]
}

View 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(),
},
]
}
}

View 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(),
},
]
}
}

View 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(),
},
]
}
}

View File

@@ -0,0 +1,4 @@
pub mod grid_lg;
pub mod grid_sm;
pub mod list;
pub mod tree;

View 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(),
},
]
}
}

84
src/filetree/node.rs Normal file
View File

@@ -0,0 +1,84 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[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",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"folder" => NodeType::Folder,
"file" => NodeType::File,
"dynamic_layer" => NodeType::DynamicLayer,
_ => NodeType::Folder,
}
}
}

5
src/lib.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod audio;
pub mod command;
pub mod filetree;
pub mod render;
pub mod server;

46
src/main.rs Normal file
View File

@@ -0,0 +1,46 @@
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "markbase", about = "Momentry Display Engine")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Start display server
Display {
#[arg(short, long, default_value = "11438")]
port: u16,
/// Optional initial markdown file
file: Option<String>,
},
/// Render markdown to HTML (stdout)
Render {
file: String,
#[arg(short, long)]
output: Option<String>,
},
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Display { port, file } => {
markbase::server::run(port, file).await?;
}
Commands::Render { file, output } => {
let md = std::fs::read_to_string(&file)?;
let html = markbase::render::md_to_html(&md);
if let Some(path) = &output {
std::fs::write(path, html)?;
} else {
println!("{html}");
}
}
}
Ok(())
}

633
src/page.html Normal file
View File

@@ -0,0 +1,633 @@
<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Cache-Control" content="no-cache,no-store,must-revalidate">
<title>{__TITLE__}</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,system-ui,sans-serif;background:#0f172a;color:#e2e8f0;padding:24px 24px 80px;line-height:1.6;font-size:16px}
h1,h2,h3,h4{color:#60a5fa;margin:1em 0 .5em}
h1{font-size:1.8em;border-bottom:1px solid #334155;padding-bottom:.3em}
h2{font-size:1.4em} h3{font-size:1.15em}
p{margin:.6em 0} a{color:#60a5fa}
code{background:#1e293b;padding:2px 6px;border-radius:4px;font-size:.9em}
pre{background:#1e293b;padding:16px;border-radius:8px;overflow-x:auto;margin:.8em 0;font-size:.85em}
pre code{background:none;padding:0}
table{border-collapse:collapse;width:100%;margin:1em 0;font-size:.88em}
th,td{border:1px solid #334155;padding:8px 12px;text-align:left;vertical-align:top}
th{background:#1e293b;color:#94a3b8;font-weight:600;white-space:nowrap}
blockquote{border-left:4px solid #60a5fa;padding-left:16px;color:#94a3b8;margin:.8em 0}
ul,ol{padding-left:24px;margin:.6em 0}
img,video{max-width:100%;border-radius:8px} iframe{width:100%;height:98vh;border:none}
#mb-tree-panel{display:none;position:fixed;top:0;left:0;right:0;bottom:52px;background:#0f172a;z-index:9998;overflow-y:auto;padding:16px 24px}
#mb-tree-panel.active{display:block}
.mb-mode-bar{display:flex;gap:0;margin-bottom:16px;border-bottom:2px solid #1e293b;position:sticky;top:0;background:#0f172a;z-index:10}
.mb-mode-btn{background:none;border:none;color:#64748b;padding:10px 20px;cursor:pointer;font-size:14px;border-bottom:3px solid transparent;transition:all .2s;font-family:inherit}
.mb-mode-btn:hover{color:#94a3b8}
.mb-mode-btn.active{color:#60a5fa;border-bottom-color:#60a5fa}
.mb-mode-btn span{font-size:18px;margin-right:6px}
.mb-tree-node{padding:3px 0;cursor:default;border-radius:4px}
.mb-tree-node:hover{background:#1e293b}
.mb-tree-caret{display:inline-block;width:18px;cursor:pointer;color:#64748b;user-select:none}
.mb-tree-label{display:inline-flex;align-items:center;gap:6px}
.mb-tree-meta{color:#64748b;font-size:11px;margin-left:8px}
.mb-tree-file{cursor:pointer}
.mb-tree-file:hover{color:#60a5fa}
.mb-folder-actions{display:none;gap:3px;margin-left:8px}
.mb-tree-node:hover .mb-folder-actions{display:inline-flex}
body.mb-locked .mb-folder-actions{display:none!important}
body.mb-locked .mb-tree-node:hover .mb-folder-actions{display:none!important}
.mb-folder-btn{background:#334155;border:none;color:#94a3b8;padding:1px 6px;border-radius:3px;cursor:pointer;font-size:10px;font-family:inherit}
.mb-folder-btn:hover{background:#475569;color:#e2e8f0}
.mb-folder-btn.danger:hover{background:#7f1d1d;color:#fca5a5}
.mb-toast{position:fixed;bottom:60px;left:50%;transform:translateX(-50%);background:#064e3b;color:#4ade80;padding:6px 20px;border-radius:6px;font-size:13px;z-index:10001;transition:opacity .3s}
.mb-grid{display:grid;gap:10px}
.mb-grid.sm{grid-template-columns:repeat(auto-fill,minmax(120px,1fr))}
.mb-grid.lg{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}
.mb-grid-cell{background:#1e293b;border-radius:10px;padding:12px;text-align:center;cursor:pointer;transition:all .2s;border:1px solid transparent;min-height:100px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px}
.mb-grid-cell:hover{background:#1e3a5f;border-color:#3b82f6;transform:translateY(-2px)}
.mb-grid-cell .mb-grid-icon{font-size:40px}
.mb-grid-cell .mb-grid-label{font-size:12px;line-height:1.3;max-height:2.6em;overflow:hidden;word-break:break-word}
.mb-grid-cell .mb-grid-uuid{font-size:10px;color:#64748b;font-family:monospace}
#mb-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.6);z-index:9999}
#mb-overlay.active{display:block}
#mb-detail{display:none;position:fixed;top:10%;left:10%;right:10%;bottom:10%;background:#1e293b;border:1px solid #334155;border-radius:12px;z-index:10000;padding:24px;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,.5)}
#mb-detail.active{display:block}
#mb-detail-close{position:absolute;top:12px;right:16px;background:none;border:none;color:#64748b;font-size:20px;cursor:pointer}
.mb-loc-tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;margin:2px 4px 2px 0;font-family:monospace}
.mb-loc-tag.hot{background:#064e3b;color:#4ade80}
.mb-loc-tag.warm{background:#451a03;color:#fbbf24}
.mb-loc-tag.cold{background:#1e1b4b;color:#818cf8}
.mb-loc-tag.cloud{background:#0c4a6e;color:#38bdf8}
.mb-new-folder-btn{background:#1e3a5f;border:1px solid #3b82f6;color:#60a5fa;padding:2px 10px;border-radius:4px;cursor:pointer;font-size:11px;font-family:inherit;margin-left:8px}
.mb-new-folder-btn:hover{background:#1e40af;color:#93c5fd}
.mb-lock-btn{background:none;border:none;font-size:16px;cursor:pointer;padding:2px 6px;align-self:center}
.mb-rename-input{background:#1e293b;border:1px solid #60a5fa;border-radius:4px;color:#e2e8f0;padding:1px 6px;font-size:13px;font-family:inherit;width:180px}
.mb-icon-picker{display:grid;grid-template-columns:repeat(8,1fr);gap:4px;max-height:200px;overflow-y:auto;margin:8px 0}
.mb-icon-picker button{background:#0f172a;border:2px solid transparent;border-radius:6px;font-size:22px;padding:6px;cursor:pointer}
.mb-icon-picker button:hover{background:#1e3a5f;border-color:#60a5fa}
</style>
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script>mermaid.initialize({startOnLoad:false,theme:'dark',themeVariables:{primaryColor:'#60a5fa',primaryTextColor:'#e2e8f0',lineColor:'#94a3b8'}})</script>
</head><body>
<div id=mb-content>{__CONTENT__}</div>
<div id=mb-overlay onclick="closeDetail()"></div>
<div id=mb-detail><button id=mb-detail-close onclick="closeDetail()"></button><div id=mb-detail-body></div></div>
<div id=mb-tree-panel><div id=mb-tree-body></div></div>
<div id=mb-bar style="position:fixed;bottom:0;left:0;right:0;background:#1e293b;border-top:1px solid #334155;display:flex;justify-content:center;align-items:center;gap:5px;padding:5px 10px;z-index:9999;font-size:12px">
<button onclick="fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'restart'})})" title="Restart"></button>
<button onclick="fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'prev'})})" title="Prev"></button>
<button onclick="fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'next'})})" title="Next"></button>
<button onclick="fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'end'})})" title="End"></button>
<input id=mbgi type=number min=1 max=999 placeholder=N style="width:36px;background:#0f172a;border:1px solid #334155;border-radius:4px;color:white;padding:2px 4px;font-size:10px;text-align:center">
<button onclick="var n=document.getElementById('mbgi').value;if(n)fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'goto',val:parseInt(n)})})" style=font-size:10px>GO</button>
<select id=mbstep onchange="if(this.value)fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'goto',val:parseInt(this.value)})})" style="background:#0f172a;color:white;border:1px solid #334155;border-radius:4px;padding:1px 3px;font-size:10px;max-width:160px"><option value="">Step...</option></select>
<span style=color:#475569;font-size:10px>|</span>
<button onclick="toggleTree()" title="File Tree" style="background:none;border:none;color:#60a5fa;cursor:pointer;font-size:16px">🗂</button>
<span style=color:#475569;font-size:10px>|</span>
<button onclick="var t=this.textContent;this.textContent=t===String.fromCodePoint(0x1F50A)?String.fromCodePoint(0x1F507):String.fromCodePoint(0x1F50A)" id=mbvb title=Voice style=font-size:16px>🔊</button>
<select id=mbvl onchange="fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'lang',val:this.value})})" style="background:#0f172a;color:white;border:1px solid #334155;border-radius:4px;padding:1px 3px;font-size:10px">
<option value=zh_TW>🇹🇼</option><option value=en_US>🇺🇸</option><option value=ja_JP>🇯🇵</option><option value=ko_KR>🇰🇷</option><option value=fr_FR>🇫🇷</option></select>
<select id=mbol onchange="fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'audio_out',val:this.value})})" style="background:#0f172a;color:white;border:1px solid #334155;border-radius:4px;padding:1px 3px;font-size:10px;max-width:120px">{out_devs}</select>
<button onclick="var l=document.getElementById('mbvl').value;var o=document.getElementById('mbol').value;fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'test_voice',val:l,out:o})})" style=font-size:14px title="Test Voice">🔊</button>
<select id=mbil onchange="fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'audio_in',val:this.value})})" style="background:#0f172a;color:white;border:1px solid #334155;border-radius:4px;padding:1px 3px;font-size:10px;max-width:120px">{in_devs}</select>
<button onclick="fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'vol_down'})})" style=font-size:13px title="Vol-"></button>
<span id=mbvlvl style=color:#4ade80;font-size:11px>--</span>
<button onclick="fetch('/command',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cmd:'vol_up'})})" style=font-size:13px title="Vol+"></button>
<span id=mbsi style=color:#94a3b8;font-size:10px;margin-left:2px></span>
</div>
<script>
// Page version polling (skip while tree or detail panel is open)
var _v=-1;
setInterval(function(){
if(_tv)return;
var ov=document.getElementById("mb-overlay");
if(ov&&ov.style.display==="block")return;
fetch("/version").then(function(r){return r.json()}).then(function(d){
if(d.v!=_v){_v=d.v;
var ov2=document.getElementById("mb-overlay");
if(ov2&&ov2.style.display==="block")return;
if(_v>=0)fetch("/body").then(function(r){return r.text()}).then(function(h){
var ov3=document.getElementById("mb-overlay");
if(ov3&&ov3.style.display==="block")return;
var e=document.getElementById("mb-content");
if(e){e.innerHTML=h;mermaid.run()}})}})
},500)
setInterval(function(){
fetch("/status").then(function(r){return r.json()}).then(function(d){
var s="";d.id&&(s+="["+d.id+"] ");d.step&&(s+="Step "+d.step+"/"+d.total);d.label&&(s+=" "+d.label);
var e=document.getElementById("mbsi");if(e)e.textContent=s})
},1000)
fetch("/volume").then(function(r){return r.json()}).then(function(d){
var e=document.getElementById("mbvlvl");if(e){e.textContent=d.level}
})
fetch("/labels").then(function(r){return r.json()}).then(function(d){
var s=document.getElementById("mbstep");
d.forEach(function(x){var o=document.createElement("option");o.value=x.num;o.text=x.num+". "+x.label;s.appendChild(o)})
})
// ═══════════════ FILE TREE PANEL ═══════════════
var _tv=false, _tm="tree", _td=null;
function toggleTree(){
_tv=!_tv;
document.getElementById("mb-tree-panel").classList.toggle("active",_tv);
if(_tv)loadTree();
}
function loadTree(){
var b=document.getElementById("mb-tree-body");
if(!b)return;
b.innerHTML="<div style=text-align:center;padding:40px;color:#64748b>Loading...</div>";
fetch("/api/v2/tree/demo?mode="+_tm).then(function(r){return r.json()}).then(function(d){
_td=d;
var h="";
// Mode buttons
var modes=[{k:"tree",i:"🌳",l:"Tree"},{k:"list",i:"📋",l:"List"},{k:"grid_sm",i:"🟦",l:"Icons"},{k:"grid_lg",i:"🔲",l:"Large"}];
h+="<div class=mb-mode-bar>";
modes.forEach(function(m){
h+="<button class=mb-mode-btn"+(_tm==m.k?" active":"")+" onclick='changeMode(\""+m.k+"\")'>";
h+="<span>"+m.i+"</span>"+m.l+"</button>";
});
h+="<span style=flex:1></span>";
h+="<button class=mb-lock-btn id=mb-lock-icon onclick=toggleLock() title='Toggle edit lock'>"+(_locked?"🔒":"🔓")+"</button>";
if(!_locked){
h+="<button class=mb-new-folder-btn onclick='document.getElementById(\"mb-file-input\").click()' style='background:#064e3b;border-color:#4ade80;color:#4ade80'>📤 Upload</button>";
h+="<input type=file id=mb-file-input style=display:none onchange=uploadFile(this)>";
}
if(!_locked){
h+="<button class=mb-new-folder-btn onclick=organizeTree() style='background:#0c4a6e;border-color:#38bdf8;color:#38bdf8'>⚡ Agent</button>";
}
h+="<button class=mb-new-folder-btn onclick=newFolder()>+ Folder</button>";
if(!_locked){
h+="<button class=mb-new-folder-btn onclick=restoreTree() style='background:#1e3a5f;border-color:#3b82f6;color:#93c5fd'>↻ Restore</button>";
h+="<button class=mb-new-folder-btn onclick=findDupes() style='background:#451a03;color:#fbbf24;border-color:#b45309'>🔍 Dupes</button>";
h+="<button class=mb-new-folder-btn onclick=deleteAll() style='background:#451a03;color:#fbbf24;border-color:#b45309'>✕ All</button>";
}
h+="<span style=color:#64748b;font-size:12px;align-self:center>"+d.nodes.length+" nodes</span></div>";
if(_tm=="tree")h+=renderTree(d);
else if(_tm=="list")h+=renderList(d);
else h+=renderGrid(d,_tm);
b.innerHTML=h;
}).catch(function(e){
b.innerHTML="<div style=padding:20px;color:#ef4444>Failed to load tree: "+e+"</div>";
});
}
function changeMode(m){
_tm=m;localStorage.setItem("display_mode",m);
loadTree();
}
function dname(n){
var a=n.aliases||{};
for(var k in a){if(a.hasOwnProperty(k)&&a[k])return a[k];}
return n.label;
}
function fsize(b){
if(!b&&b!==0)return "-";
var s=b,i=0,u=["B","KB","MB","GB"];
while(s>=1024&&i<3){s/=1024;i++;}
return (i==0?s:s.toFixed(1))+" "+u[i];
}
// TREE MODE
var _clk=0;
function tgl(id){
var el=document.getElementById(id);
if(el){var v=el.style.display=="none";el.style.display=v?"":"none";
var c=document.getElementById("cr"+id);if(c)c.textContent=v?"▼":"▶";}
}
function renderTree(d){
var ch={};
d.nodes.forEach(function(n){var p=n.parent_id||"root";if(!ch[p])ch[p]=[];ch[p].push(n);});
function rc(pid,ind){
var lst=ch[pid];if(!lst||!lst.length)return"";
var h="";
lst.sort(function(a,b){if(a.node_type!=b.node_type)return a.node_type=="folder"?-1:1;return a.label.localeCompare(b.label);});
lst.forEach(function(n){
if(n.node_type=="folder"){
_clk++;var cid="tc"+_clk;var nid=n.node_id;
h+="<div class=mb-tree-node style=padding-left:"+(ind*20)+"px>";
h+="<span class=mb-tree-caret id=cr"+cid+" onclick='tgl(\""+cid+"\")'>"+(ind==0?"▼":"▶")+"</span>";
h+="<span class=mb-tree-label ondblclick='renameNode(\""+nid+"\")'>"+(n.icon||"📁")+" <b id=flb"+nid+">"+dname(n)+"</b></span>";
h+="<span class=mb-folder-actions>";
h+="<button class=mb-folder-btn onclick='pickIcon(\""+nid+"\")'>🎨</button>";
h+="<button class=mb-folder-btn onclick='renameNode(\""+nid+"\")'>✏️</button>";
h+="<button class=mb-folder-btn onclick='moveNode(\""+nid+"\")'>📦</button>";
h+="<button class=mb-folder-btn danger onclick=delNode(\""+nid+"\",\""+dname(n)+"\")>🗑</button></span>";;
if(ch[n.node_id]&&ch[n.node_id].length){
h+="<div id="+cid+" style=display:"+(ind==0?"block":"none")+">";
h+=rc(n.node_id,ind+1)+"</div>";
}
}else{
var nid=n.node_id;
h+="<div class=mb-tree-node style=padding-left:"+(ind*20+18)+"px>";
h+="<span class=mb-tree-label ondblclick='renameNode(\""+nid+"\")'>";
h+="<span class=mb-tree-file onclick='showDetail(\""+(n.file_uuid||"")+"\")'>";
h+=(n.icon||"📄")+" <b id=flb"+nid+">"+dname(n)+"</b></span>";
h+="<span class=mb-tree-meta>"+fsize(n.file_size)+"</span>";
h+="<span class=mb-folder-btn onclick='quickPreview(\""+(n.file_uuid||"")+"\")' title='Preview' style='display:inline-block;margin-left:4px;font-size:11px'>👁</span></span>";
h+="<span class=mb-folder-actions>";
h+="<button class=mb-folder-btn onclick='pickIcon(\""+nid+"\")'>🎨</button>";
h+="<button class=mb-folder-btn onclick='renameNode(\""+nid+"\")'>✏️</button>";
h+="<button class=mb-folder-btn onclick='moveNode(\""+nid+"\")'>📦</button>";
h+="<button class=mb-folder-btn danger onclick=delNode(\""+nid+"\",\""+dname(n)+"\")>🗑</button></span>";;
}
h+="</div>";
});
return h;
}
return rc("root",0);
}
function renderList(d){
var h="<table style=width:100%><thead><tr><th>Name</th><th>file_uuid</th><th>Size</th><th>Type</th></tr></thead><tbody>";
d.nodes.forEach(function(n){
var icon=n.icon||(n.node_type=="folder"?"📁":"📄");
var badge=n.node_type=="folder"?"<span style=color:#fbbf24>folder</span>":"<span style=color:#4ade80>file</span>";
h+="<tr onclick='"+(n.file_uuid?"showDetail(\""+n.file_uuid+"\")":"")+"' style=cursor:"+(n.file_uuid?"pointer":"default")+">";
h+="<td>"+icon+" "+dname(n)+"</td>";
h+="<td><code>"+(n.file_uuid||"-")+"</code></td>";
h+="<td>"+fsize(n.file_size)+"</td><td>"+badge+"</td></tr>";
});
h+="</tbody></table>";return h;
}
function renderGrid(d,mode){
var cls=mode=="grid_sm"?"sm":"lg";
var h="<div class='mb-grid "+cls+"'>";
d.nodes.forEach(function(n){
var icon=n.icon||(n.node_type=="folder"?"📁":"📄");
h+="<div class=mb-grid-cell onclick='"+(n.file_uuid?"showDetail(\""+n.file_uuid+"\")":"")+"'>";
h+="<div class=mb-grid-icon>"+icon+"</div>";
h+="<div class=mb-grid-label>"+dname(n)+"</div>";
if(n.file_uuid)h+="<div class=mb-grid-uuid>"+n.file_uuid+"</div>";
h+="</div>";
});
h+="</div>";return h;
}
// DETAIL PANEL
function showDetail(fuuid){
if(!fuuid)return;
document.getElementById("mb-overlay").style.display="block";
document.getElementById("mb-detail").style.display="block";
var b=document.getElementById("mb-detail-body");
b.innerHTML="<div style=text-align:center;padding:30px;color:#64748b>Loading...</div>";
fetch("/api/v2/files/"+fuuid+"/info").then(function(r){return r.json()}).then(function(d){
var node=_td&&_td.nodes?_td.nodes.find(function(n){return n.file_uuid==fuuid}):null;
var label=node?dname(node):fuuid;
var sz=node?fsize(node.file_size):"-";
var sha=node?(node.sha256||"-").substring(0,16)+"...":"-";
var reg=node?(node.registered_at||"-").substring(0,10):"-";
var h="<h2 style=border:none;margin-bottom:16px>📄 "+label+"</h2>";
h+="<table style=width:100%><tr><th>file_uuid</th><td><code>"+d.file_uuid+"</code></td></tr>";
h+="<tr><th>SHA256</th><td><code>"+sha+"</code></td></tr>";
h+="<tr><th>Size</th><td>"+sz+"</td></tr>";
h+="<tr><th>Registered</th><td>"+reg+"</td></tr></table>";
h+="<h3>🌳 Virtual Paths</h3>";
if(d.virtual_paths&&d.virtual_paths.length) d.virtual_paths.forEach(function(vp){h+="<div>📁 "+vp+"</div>"});
else h+="<div style=color:#64748b>—</div>";
h+="<h3>💾 Real Locations ("+d.location_count+")</h3>";
if(d.real_locations&&d.real_locations.length){
d.real_locations.forEach(function(loc){
var lbl=loc.label||"",pth=loc.path||"";
var tier="hot";
if(lbl.indexOf("warm")>=0||lbl.indexOf("nas")>=0) tier="warm";
if(lbl.indexOf("cold")>=0||lbl.indexOf("lto")>=0||lbl.indexOf("glacier")>=0) tier="cold";
if(lbl.indexOf("s3")>=0||lbl.indexOf("cdn")>=0||lbl.indexOf("ipfs")>=0) tier="cloud";
h+="<div><span class=mb-loc-tag "+tier+">"+lbl+"</span><code style=font-size:11px>"+pth.substring(0,80)+"</code></div>";
});
}else h+="<div style=color:#64748b>—</div>";
h+="<h3>🔍 Probe Data <span id=mb-probe-status style=font-size:11px;color:#64748b>loading...</span></h3><div id=mb-probe-data style=color:#64748b>Loading...</div>";
h+="<h3>🖼️ Preview <span style=font-size:12px;color:#94a3b8>"+label+"</span> <span id=mb-preview-res style=font-size:12px;color:#64748b></span></h3><div style=margin-top:8px;position:relative;display:flex;align-items:center;gap:8px>";
var src="/api/v2/files/"+fuuid+"/stream";
var ext=(label||"").split(".").pop().toLowerCase();
var isVideo=(ext=="mp4"||ext=="mov"||ext=="avi"||ext=="webm"||ext=="mkv");
var isTxt=(ext=="txt"||ext=="log"||ext=="csv"||ext=="json"||ext=="xml");
var isMd=(ext=="md"||ext=="markdown");
var isDocText=(ext=="docx"||ext=="doc"||ext=="rtf");
var isDocImg=(ext=="pages"||ext=="key"||ext=="numbers");
var isDocPdf=(ext=="pdf"||ext=="pptx"||ext=="xlsx"||ext=="odt"||ext=="xls"||ext=="ppt"||ext=="epub"||ext=="html");
var isDoc=isDocText||isDocImg||isDocPdf;
if(isVideo){
h+="<video controls style='max-width:100%;max-height:360px;border-radius:8px;background:#0f172a'><source src='"+src+"'></video>";
}else if(isTxt||isDocText){
h+="<pre id=mb-detail-txt style='max-height:300px;overflow:auto;background:#0f172a;color:#e2e8f0;padding:12px;border-radius:8px;font-size:13px;white-space:pre-wrap;width:100%'>Loading...</pre>";
}else if(isMd){
h+="<div id=mb-md-render style='max-height:400px;overflow:auto;background:#0f172a;padding:12px;border-radius:8px'>Loading...</div>";
}else if(isDocImg){
h+="<img id=mb-preview-img src='"+src+"' style='max-width:100%;max-height:400px;border-radius:8px' onerror=\"this.onerror=null;this.alt='No preview'\">";
}else if(isDocPdf){
h+="<iframe sandbox='allow-same-origin' src='"+src+"' style='width:100%;height:400px;border:none;border-radius:8px;background:#fff'></iframe>";
}else{
h+="<button id=mb-prev-btn onclick=navigatePhoto('prev') style='background:#1e293b;border:1px solid #334155;color:#94a3b8;font-size:24px;padding:8px 14px;border-radius:6px;cursor:pointer'>◀</button>";
h+="<img id=mb-preview-img src='"+src+"' style='max-width:100%;max-height:400px;min-height:100px;min-width:100px;border-radius:8px;background:#0f172a' onerror=\"this.onerror=null;this.alt='No preview'\">";
h+="<button id=mb-next-btn onclick=navigatePhoto('next') style='background:#1e293b;border:1px solid #334155;color:#94a3b8;font-size:24px;padding:8px 14px;border-radius:6px;cursor:pointer'>▶</button>";
h+="<span id=mb-photo-pos style='position:absolute;bottom:8px;right:50px;background:rgba(0,0,0,.7);color:#94a3b8;padding:2px 8px;border-radius:4px;font-size:11px'>1/1</span>";
}
h+="</div>";
b.innerHTML=h;
if(!isVideo&&!isTxt&&!isMd&&!isDocText&&!isDocImg&&!isDocPdf){_photoUuid=fuuid;setupPhotoNav(fuuid)}
if(isTxt||isDocText) fetch(src).then(function(r){return r.text()}).then(function(t){
var el=document.getElementById("mb-detail-txt");if(el)el.textContent=t||"(empty)";
});
if(isMd) fetch("/api/v2/render/"+fuuid+"/body").then(function(r){return r.text()}).then(function(h){
var el=document.getElementById("mb-md-render");if(el){el.innerHTML=h;setTimeout(function(){
var nodes=el.querySelectorAll(".mermaid");if(nodes.length)mermaid.run({nodes:Array.from(nodes)})
},100)}
});
fetch("/api/v2/files/"+fuuid+"/probe").then(function(r){return r.json()}).then(function(p){
var pd=document.getElementById("mb-probe-data");
var ps=document.getElementById("mb-probe-status");
var pr=document.getElementById("mb-preview-res");
if(p.width&&p.height)pr.textContent="("+p.width+"×"+p.height+")";
else pr.textContent="";
if(p.probe){
ps.textContent="";
var ph="<table style=width:100%>";
if(p.duration)ph+="<tr><th>Duration</th><td>"+(p.duration).toFixed(1)+"s ("+Math.floor(p.duration/60)+"min "+(p.duration%60).toFixed(0)+"s)</td></tr>";
if(p.width&&p.height)ph+="<tr><th>Resolution</th><td>"+p.width+"×"+p.height+"</td></tr>";
if(p.fps)ph+="<tr><th>FPS</th><td>"+p.fps+"</td></tr>";
if(p.file_type)ph+="<tr><th>Codec</th><td>"+p.file_type+"</td></tr>";
if(p.total_frames)ph+="<tr><th>Total Frames</th><td>"+p.total_frames+"</td></tr>";
if(p.probe.format){
var fmt=p.probe.format;
if(fmt.format_name)ph+="<tr><th>Format</th><td>"+fmt.format_name+"</td></tr>";
if(fmt.size)ph+="<tr><th>Probe Size</th><td>"+fmt.size+"</td></tr>";
if(fmt.bit_rate)ph+="<tr><th>Bitrate</th><td>"+(fmt.bit_rate/1000000).toFixed(1)+" Mbps</td></tr>";
}
if(p.probe.streams){
ph+="<tr><th>Streams</th><td>";
p.probe.streams.forEach(function(s){ph+=" • "+s.codec_type+": "+s.codec_name+"<br>"});
ph+="</td></tr>";
}
ph+="</table>";pd.innerHTML=ph;
}else{
ps.textContent="(not available)";
pd.innerHTML="<div style=color:#64748b>No probe data for this file</div>";
}
}).catch(function(){document.getElementById("mb-probe-status").textContent="(error)"});
}).catch(function(e){b.innerHTML="<div style=color:#ef4444>Error: "+e+"</div>"});
}
function closeDetail(){
document.getElementById("mb-overlay").style.display="none";
document.getElementById("mb-detail").style.display="none";
}
// PHOTO NAVIGATION
var _photoUuid=null,_photoList=[];
function setupPhotoNav(fuuid){
if(!_td) return;
var imgs=["jpg","jpeg","png","gif","bmp","webp","tiff","svg"];
_photoList=_td.nodes.filter(function(n){
if(!n.file_uuid||n.node_type!="file")return false;
var e=(n.label||"").split(".").pop().toLowerCase();
return imgs.indexOf(e)>=0;
}).map(function(n){return n.file_uuid});
updatePhotoPos(fuuid);
}
function updatePhotoPos(fuuid){
_photoUuid=fuuid;
var idx=_photoList.indexOf(fuuid);
var pos=document.getElementById("mb-photo-pos");
if(pos)pos.textContent=(idx+1)+"/"+_photoList.length;
var prev=document.getElementById("mb-prev-btn");
var next=document.getElementById("mb-next-btn");
if(prev)prev.style.visibility=idx>0?"visible":"hidden";
if(next)next.style.visibility=idx<_photoList.length-1?"visible":"hidden";
}
function navigatePhoto(dir){
var idx=_photoList.indexOf(_photoUuid);
if(dir=="prev"&&idx>0)idx--;else if(dir=="next"&&idx<_photoList.length-1)idx++;
if(idx>=0&&idx<_photoList.length){
_photoUuid=_photoList[idx];
var img=document.getElementById("mb-preview-img");
if(img)img.src="/api/v2/files/"+_photoUuid+"/stream?_="+Date.now();
updatePhotoPos(_photoUuid);
}
}
// TOAST
function toast(msg){
var t=document.createElement("div");t.className="mb-toast";t.textContent=msg;
document.body.appendChild(t);
setTimeout(function(){t.style.opacity="0";setTimeout(function(){t.remove()},300)},1500);
}
// ICON PICKER
var ICONS=["📁","📂","📄","🎬","🎵","🖼️","📊","📝","📦","📸","🎨","📹","🎧","📚","🔧","⚙️","🌐","💾","📀","💿","🏠","🏢","🌟","⭐","💡","🔥","❤️","💚","💙","💛","🧡","💜","✅","❌","⚠️","🔒","🔓","🔑","📡","🔗"];
var _icoPicked=null,_icoNode=null;
function pickIcon(nid){
_icoPicked=null;_icoNode=nid;
var o=document.createElement("div");
o.style.cssText="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);z-index:10002;display:flex;align-items:center;justify-content:center";
var bx=document.createElement("div");
bx.style.cssText="background:#1e293b;border:1px solid #334155;border-radius:12px;padding:24px;min-width:380px;max-width:440px";
var h="<h3>🎨 Pick Icon</h3><div class=mb-icon-picker id=mb-ipg>";
ICONS.forEach(function(ico,idx){h+="<button id=ipi"+idx+" onclick='selIcon("+idx+",\""+ico+"\")'>"+ico+"</button>"});
h+="</div>Custom: <input id=mbci placeholder='emoji or SVG url' style='width:100%;background:#0f172a;border:1px solid #334155;border-radius:6px;color:#e2e8f0;padding:6px 10px;font-size:18px;margin-top:8px' oninput='_icoPicked=document.getElementById(\"mbci\").value'>";
h+="<div style=margin-top:10px;display:flex;gap:6px;justify-content:flex-end>";
h+="<button id=idef style='background:#451a03;border:none;color:#fbbf24;padding:4px 14px;border-radius:6px;cursor:pointer'>Default</button>";
h+="<button id=icxl style='background:#334155;border:none;color:#94a3b8;padding:4px 14px;border-radius:6px;cursor:pointer'>Cancel</button>";
h+="<button id=iok style='background:#064e3b;border:none;color:#4ade80;padding:4px 14px;border-radius:6px;cursor:pointer'>OK</button>";
h+="</div>";
bx.innerHTML=h;o.appendChild(bx);document.body.appendChild(o);
document.getElementById("iok").onclick=function(){if(_icoPicked)applyIcon(_icoNode,_icoPicked);o.remove()};
document.getElementById("idef").onclick=function(){applyIcon(_icoNode,"");o.remove()};
document.getElementById("icxl").onclick=function(){o.remove()};
o.onclick=function(e){if(e.target==o)o.remove()};
}
function selIcon(idx,ico){
_icoPicked=ico;
ICONS.forEach(function(_,i){var el=document.getElementById("ipi"+i);if(el)el.style.border=i==idx?"2px solid #4ade80":"2px solid transparent"});
}
function applyIcon(nid,ico){
fetch("/api/v2/tree/demo/node/"+nid,{method:"PUT",headers:{"Content-Type":"application/json"},
body:JSON.stringify({icon:ico})})
.then(function(r){return r.json()}).then(function(){
loadTree();toast(ico?"Icon → "+ico:"Icon reset to default");
});
}
// AGENT ORGANIZE
function organizeTree(){
if(!_td){toast("Load tree first");return}
var folders={};
_td.nodes.forEach(function(n){if(n.node_type=="folder")folders[n.label]=n.node_id});
var cats=["Movies","Marketing","Cartoons","Other"];
var targets={};
cats.forEach(function(c){if(folders[c])targets[c]=folders[c]});
if(Object.keys(targets).length<2){toast("Need at least 2 category folders");return}
var files=_td.nodes.filter(function(n){
if(n.node_type!="file"||!n.parent_id)return false;
// Already in a category folder?
var inCat=false;
for(var k in targets){if(n.parent_id==targets[k]){inCat=true;break}}
return !inCat;
});
if(!files.length){toast("All files already organized ✓");return}
var mover=function(idx){
if(idx>=files.length){loadTree();toast("Agent: "+files.length+" files organized");return}
var f=files[idx];
var nl=(f.label||"").toLowerCase();
var t="Other";
if(/charade|film|clip|movie|comedy|filmriot/.test(nl))t="Movies";
else if(/exasan|gamma|thunderbolt|nab|koba|webinar|top colorist|accusys|a12t3/.test(nl))t="Marketing";
else if(/cartoon|alice|felix|disney|steamboat|animal/.test(nl))t="Cartoons";
var pid=targets[t];
if(!pid){mover(idx+1);return}
fetch("/api/v2/tree/demo/node/"+f.node_id+"/move",{method:"PUT",headers:{"Content-Type":"application/json"},
body:JSON.stringify({parent_id:pid})})
.then(function(r){return r.json()}).then(function(){mover(idx+1);})
.catch(function(){mover(idx+1);});
};
mover(0);
}
// DUPLICATE FINDER
function findDupes(){
var o=document.createElement("div");
o.style.cssText="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);z-index:10002;display:flex;align-items:center;justify-content:center";
var bx=document.createElement("div");
bx.style.cssText="background:#1e293b;border:1px solid #334155;border-radius:12px;padding:24px;min-width:500px;max-width:700px;max-height:80vh;overflow-y:auto";
bx.innerHTML="<h3>🔍 Scanning for duplicates...</h3>";
o.appendChild(bx);document.body.appendChild(o);
o.onclick=function(e){if(e.target==o)o.remove()};
fetch("/api/v2/dupes/demo").then(function(r){return r.json()}).then(function(d){
if(!d.dupes||!d.dupes.length){
bx.innerHTML="<h3>✅ No Duplicates Found</h3><button onclick=this.parentElement.parentElement.remove() style='margin-top:12px;background:#334155;border:none;color:#94a3b8;padding:6px 16px;border-radius:6px;cursor:pointer'>Close</button>";
return;
}
var h="<h3>🔍 Duplicates ("+d.dup_groups+" groups)</h3>";
d.dupes.forEach(function(g,i){
h+="<div style='margin:10px 0;padding:10px;background:#0f172a;border-radius:8px'>";
h+="<b>"+(i+1)+". "+g.file_name+"</b> ("+g.count+"x)<br>";
g.uuids.forEach(function(uuid,j){
var st=g.statuses[j]||"?";
var badge=st=="completed"?"background:#064e3b;color:#4ade80":st=="pending"?"background:#451a03;color:#fbbf24":"background:#1e293b;color:#64748b";
h+="<div style='display:flex;align-items:center;gap:8px;margin:4px 0;padding:4px 8px;background:#1e293b;border-radius:4px'>";
h+="<code style='flex:1;font-size:11px'>"+uuid+"</code>";
h+="<span style='padding:1px 6px;border-radius:3px;font-size:10px;"+badge+"'>"+st+"</span>";
h+="<button onclick=unregDup('"+uuid+"') style='background:#451a03;border:none;color:#fbbf24;padding:2px 8px;border-radius:4px;cursor:pointer;font-size:11px'>✕</button>";
h+="</div>";
});
h+="</div>";
});
h+="<div style=margin-top:12px;display:flex;gap:6px;justify-content:flex-end><button onclick=this.parentElement.parentElement.parentElement.remove() style='background:#334155;border:none;color:#94a3b8;padding:4px 14px;border-radius:6px;cursor:pointer'>Close</button><button onclick='afterUnreg()' style='background:#1e3a5f;border:none;color:#93c5fd;padding:4px 14px;border-radius:6px;cursor:pointer'>Restore Tree</button></div>";
bx.innerHTML=h;
});
}
function unregDup(uuid){
if(!confirm("Unregister "+uuid+" ?"))return;
fetch("/api/v2/unregister/"+uuid,{method:"POST"})
.then(function(r){return r.json()}).then(function(d){
toast("Unregistered: "+uuid);
findDupes(); // re-scan
});
}
function afterUnreg(){
document.querySelectorAll("div[style*='z-index:10002']").forEach(function(el){el.remove()});
restoreTree();
}
function uploadFile(input){
var file=input.files[0];
if(!file)return;
var fd=new FormData();
fd.append("file",file);
toast("Uploading "+file.name+"...");
fetch("/api/v2/upload/demo",{method:"POST",body:fd})
.then(function(r){return r.json()}).then(function(d){
if(d.ok){toast("Uploaded: "+d.filename+" ("+fsize(d.size)+")");loadTree()}
else toast("Upload failed: "+(d.error||"unknown"));
}).catch(function(e){toast("Upload error: "+e)});
input.value="";
}
// QUICK PREVIEW
function quickPreview(fuuid){
if(!fuuid)return;
var node=(_td&&_td.nodes)?_td.nodes.find(function(n){return n.file_uuid==fuuid}):null;
var label=node?node.label:"";
var ext=(label||"").split(".").pop().toLowerCase();
var isVideo=ext=="mp4"||ext=="mov"||ext=="avi"||ext=="webm"||ext=="mkv";
var isTxt=ext=="txt"||ext=="log"||ext=="csv"||ext=="json"||ext=="xml";
var isMd=(ext=="md"||ext=="markdown");
var isDocText=(ext=="docx"||ext=="doc"||ext=="rtf");
var isDocImg=(ext=="pages"||ext=="key"||ext=="numbers");
var isDocPdf=(ext=="pdf"||ext=="pptx"||ext=="xlsx"||ext=="odt"||ext=="xls"||ext=="ppt"||ext=="epub"||ext=="html");
var src="/api/v2/files/"+fuuid+"/stream";
var o=document.createElement("div");
o.style.cssText="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.8);z-index:10002;display:flex;align-items:center;justify-content:center";
var inner="";
if(isVideo){
inner="<video controls autoplay style='max-width:90vw;max-height:85vh;border-radius:8px'><source src='"+src+"'></video>";
}else if(isTxt||isDocText){
inner="<pre id=mb-txt-preview style='max-width:90vw;max-height:85vh;overflow:auto;background:#0f172a;color:#e2e8f0;padding:24px;border-radius:8px;font-size:14px;white-space:pre-wrap'>Loading...</pre>";
}else if(isMd){
inner="<div id=mb-qmd-render style='max-width:90vw;max-height:85vh;overflow:auto;background:#0f172a;padding:24px;border-radius:8px'>Loading...</div>";
}else if(isDocImg){
inner="<img src='"+src+"' style='max-width:90vw;max-height:85vh;border-radius:8px' onerror=\"this.onerror=null;this.alt='No preview'\">";
}else if(isDocPdf){
inner="<iframe sandbox='allow-same-origin' src='"+src+"' style='width:90vw;height:85vh;border:none;border-radius:8px;background:#fff'></iframe>";
}else{
inner="<img src='"+src+"' style='max-width:90vw;max-height:85vh;min-height:100px;min-width:100px;border-radius:8px' onerror=\"this.onerror=null;this.alt='No preview'\">";
}
inner+="<div style='position:absolute;top:16px;right:16px'><button onclick=this.parentElement.parentElement.remove() style='background:rgba(0,0,0,.6);border:none;color:#fff;font-size:24px;cursor:pointer;padding:4px 12px;border-radius:6px'>✕</button></div>";
o.innerHTML=inner;
document.body.appendChild(o);
if(isTxt||isDocText) fetch(src).then(function(r){return r.text()}).then(function(t){
var el=document.getElementById("mb-txt-preview");
if(el)el.textContent=t||"(empty)";
});
if(isMd) fetch("/api/v2/render/"+fuuid+"/body").then(function(r){return r.text()}).then(function(h){
var el=document.getElementById("mb-qmd-render");if(el){el.innerHTML=h;setTimeout(function(){
var nodes=el.querySelectorAll(".mermaid");if(nodes.length)mermaid.run({nodes:Array.from(nodes)})
},100)}
});
o.onclick=function(e){if(e.target==o)o.remove()};
}
// LOCK TOGGLE
var _locked=true;
function toggleLock(){
_locked=!_locked;
localStorage.setItem("tree_locked",_locked?"1":"0");
document.body.classList.toggle("mb-locked",_locked);
var icon=document.getElementById("mb-lock-icon");
if(icon)icon.textContent=_locked?"🔒":"🔓";
loadTree();
}
// Init
(function(){
var s=localStorage.getItem("display_mode");
if(s&&["tree","list","grid_sm","grid_lg"].indexOf(s)>=0)_tm=s;
_locked=localStorage.getItem("tree_locked")!=="0";
document.body.classList.toggle("mb-locked",_locked);
})();
</script>
</body></html>

31
src/render.rs Normal file
View File

@@ -0,0 +1,31 @@
use pulldown_cmark::{html, Options, Parser};
pub fn md_to_html(content: &str) -> String {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_FOOTNOTES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_TASKLISTS);
opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
let parser = Parser::new_ext(content, opts);
let mut body = String::new();
html::push_html(&mut body, parser);
body
}
const HTML: &str = include_str!("page.html");
pub fn page(title: &str, content: &str) -> String {
HTML.replace("{__TITLE__}", title)
.replace("{__CONTENT__}", content)
}
pub fn render_page(title: &str, content: &str) -> String {
let content = content
.replace(
"<code class=\"language-mermaid\">",
"<div class=\"mermaid\">",
)
.replace("</code>", "</div>");
page(title, &content).replace("startOnLoad:false", "startOnLoad:true")
}

1198
src/server.rs Normal file

File diff suppressed because it is too large Load Diff