CAN Service: content-addressable storage with HTTP API, SQLite metadata, file-based blob storage, thumbnail generation, and integrity verification. can-sync v1: P2P sync sidecar using iroh-docs for encrypted peer-to-peer replication with library/filter-based selective sync. Fully builds but being superseded by v2 (simplified full-mirror approach). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
260 lines
8.2 KiB
Rust
260 lines
8.2 KiB
Rust
use std::collections::{HashMap, HashSet};
|
|
|
|
use crate::api::AssetMeta;
|
|
use crate::util;
|
|
use chrono::DateTime;
|
|
|
|
/// Unique identifier for a node in the virtual tree.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub struct NodeId(pub usize);
|
|
|
|
/// A node is either a directory or a file reference.
|
|
#[derive(Debug, Clone)]
|
|
pub enum NodeKind {
|
|
Directory,
|
|
/// Points to an index in the flat asset list.
|
|
File { asset_index: usize },
|
|
}
|
|
|
|
/// A node in the virtual directory tree.
|
|
#[derive(Debug, Clone)]
|
|
pub struct VNode {
|
|
pub name: String,
|
|
pub kind: NodeKind,
|
|
pub children: Vec<NodeId>,
|
|
#[allow(dead_code)]
|
|
pub parent: Option<NodeId>,
|
|
}
|
|
|
|
impl VNode {
|
|
pub fn is_directory(&self) -> bool {
|
|
matches!(self.kind, NodeKind::Directory)
|
|
}
|
|
}
|
|
|
|
/// The complete virtual directory tree built from a list of assets.
|
|
pub struct VirtualTree {
|
|
nodes: Vec<VNode>,
|
|
/// Normalized path -> NodeId lookup.
|
|
path_index: HashMap<String, NodeId>,
|
|
}
|
|
|
|
impl VirtualTree {
|
|
/// Build the virtual tree from a flat list of assets.
|
|
pub fn build(assets: &[AssetMeta]) -> Self {
|
|
let mut tree = TreeBuilder::new();
|
|
|
|
// Create top-level directories
|
|
let root = tree.root();
|
|
let can_dir = tree.add_dir("CAN", root);
|
|
let app_dir = tree.add_dir("APPLICATION", root);
|
|
let dates_dir = tree.add_dir("DATES", root);
|
|
let tags_dir = tree.add_dir("TAGS", root);
|
|
|
|
for (i, asset) in assets.iter().enumerate() {
|
|
let ext = util::mime_to_ext(&asset.mime_type);
|
|
let hash8 = &asset.hash[..asset.hash.len().min(8)];
|
|
|
|
// 1) CAN/ — always: {timestamp}_{hash8}.ext
|
|
let can_name = format!("{}_{}.{}", asset.timestamp, hash8, ext);
|
|
let can_name = util::sanitize_filename(&can_name);
|
|
tree.add_file(&can_name, can_dir, i);
|
|
|
|
// Display name for other folders: human_filename if available, else hash8.ext
|
|
let display_name = if let Some(ref hf) = asset.human_filename {
|
|
util::sanitize_filename(hf)
|
|
} else {
|
|
format!("{}.{}", hash8, ext)
|
|
};
|
|
|
|
// 2) APPLICATION/{app}/ — if application is set
|
|
if let Some(ref app) = asset.application {
|
|
let app_name = util::sanitize_filename(app);
|
|
let app_sub = tree.ensure_dir(&app_name, app_dir);
|
|
tree.add_file(&display_name, app_sub, i);
|
|
}
|
|
|
|
// 3) DATES/{year}/{month:02}/
|
|
if let Some(dt) = DateTime::from_timestamp_millis(asset.timestamp) {
|
|
let year_str = dt.format("%Y").to_string();
|
|
let month_str = dt.format("%m").to_string();
|
|
let year_dir = tree.ensure_dir(&year_str, dates_dir);
|
|
let month_dir = tree.ensure_dir(&month_str, year_dir);
|
|
tree.add_file(&display_name, month_dir, i);
|
|
}
|
|
|
|
// 4) TAGS/{tag}/
|
|
for tag in &asset.tags {
|
|
let tag_name = util::sanitize_filename(tag);
|
|
if tag_name.is_empty() {
|
|
continue;
|
|
}
|
|
let tag_sub = tree.ensure_dir(&tag_name, tags_dir);
|
|
tree.add_file(&display_name, tag_sub, i);
|
|
}
|
|
}
|
|
|
|
// Sort children and build path index
|
|
tree.finalize()
|
|
}
|
|
|
|
/// Look up a node by its normalized path (e.g., `\can\file.txt`).
|
|
pub fn lookup(&self, path: &str) -> Option<NodeId> {
|
|
let normalized = path.to_lowercase().replace('/', "\\");
|
|
let normalized = if normalized.len() > 1 && normalized.ends_with('\\') {
|
|
&normalized[..normalized.len() - 1]
|
|
} else {
|
|
&normalized
|
|
};
|
|
self.path_index.get(normalized).copied()
|
|
}
|
|
|
|
/// Get a node by ID.
|
|
pub fn get(&self, id: NodeId) -> &VNode {
|
|
&self.nodes[id.0]
|
|
}
|
|
|
|
/// Get the root node ID.
|
|
#[allow(dead_code)]
|
|
pub fn root(&self) -> NodeId {
|
|
NodeId(0)
|
|
}
|
|
}
|
|
|
|
/// Builder helper for constructing the virtual tree.
|
|
struct TreeBuilder {
|
|
nodes: Vec<VNode>,
|
|
/// Track names used per directory to resolve collisions.
|
|
dir_names: HashMap<usize, HashSet<String>>,
|
|
/// Cache for ensure_dir: (parent_id, name) -> NodeId
|
|
dir_cache: HashMap<(usize, String), NodeId>,
|
|
}
|
|
|
|
impl TreeBuilder {
|
|
fn new() -> Self {
|
|
let root = VNode {
|
|
name: String::new(),
|
|
kind: NodeKind::Directory,
|
|
children: Vec::new(),
|
|
parent: None,
|
|
};
|
|
let mut dir_names = HashMap::new();
|
|
dir_names.insert(0, HashSet::new());
|
|
Self {
|
|
nodes: vec![root],
|
|
dir_names,
|
|
dir_cache: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
fn root(&self) -> NodeId {
|
|
NodeId(0)
|
|
}
|
|
|
|
/// Add a directory as a child of `parent`. Returns its NodeId.
|
|
fn add_dir(&mut self, name: &str, parent: NodeId) -> NodeId {
|
|
let id = NodeId(self.nodes.len());
|
|
self.nodes.push(VNode {
|
|
name: name.to_string(),
|
|
kind: NodeKind::Directory,
|
|
children: Vec::new(),
|
|
parent: Some(parent),
|
|
});
|
|
self.nodes[parent.0].children.push(id);
|
|
self.dir_names.insert(id.0, HashSet::new());
|
|
self.dir_names
|
|
.entry(parent.0)
|
|
.or_default()
|
|
.insert(name.to_lowercase());
|
|
self.dir_cache
|
|
.insert((parent.0, name.to_lowercase()), id);
|
|
id
|
|
}
|
|
|
|
/// Get or create a subdirectory by name under `parent`.
|
|
fn ensure_dir(&mut self, name: &str, parent: NodeId) -> NodeId {
|
|
let key = (parent.0, name.to_lowercase());
|
|
if let Some(&id) = self.dir_cache.get(&key) {
|
|
return id;
|
|
}
|
|
self.add_dir(name, parent)
|
|
}
|
|
|
|
/// Add a file node as a child of `parent`, deduplicating names.
|
|
fn add_file(&mut self, name: &str, parent: NodeId, asset_index: usize) {
|
|
let used = self.dir_names.entry(parent.0).or_default();
|
|
let lower = name.to_lowercase();
|
|
|
|
let final_name = if !used.contains(&lower) {
|
|
used.insert(lower);
|
|
name.to_string()
|
|
} else {
|
|
// Deduplicate: try _2, _3, etc.
|
|
let (stem, ext) = if let Some(dot_pos) = name.rfind('.') {
|
|
(&name[..dot_pos], &name[dot_pos..])
|
|
} else {
|
|
(name, "")
|
|
};
|
|
let mut n = 2;
|
|
loop {
|
|
let candidate = format!("{}_{}{}", stem, n, ext);
|
|
let cand_lower = candidate.to_lowercase();
|
|
if !used.contains(&cand_lower) {
|
|
used.insert(cand_lower);
|
|
break candidate;
|
|
}
|
|
n += 1;
|
|
}
|
|
};
|
|
|
|
let id = NodeId(self.nodes.len());
|
|
self.nodes.push(VNode {
|
|
name: final_name,
|
|
kind: NodeKind::File { asset_index },
|
|
children: Vec::new(),
|
|
parent: Some(parent),
|
|
});
|
|
self.nodes[parent.0].children.push(id);
|
|
}
|
|
|
|
/// Sort children and build the path index.
|
|
fn finalize(mut self) -> VirtualTree {
|
|
// Sort children by name (case-insensitive)
|
|
let names: Vec<String> = self.nodes.iter().map(|n| n.name.to_lowercase()).collect();
|
|
for node in &mut self.nodes {
|
|
node.children.sort_by(|a, b| names[a.0].cmp(&names[b.0]));
|
|
}
|
|
|
|
// Build path index by walking the tree
|
|
let mut path_index = HashMap::new();
|
|
path_index.insert("\\".to_string(), NodeId(0));
|
|
|
|
fn walk(
|
|
nodes: &[VNode],
|
|
id: NodeId,
|
|
prefix: &str,
|
|
index: &mut HashMap<String, NodeId>,
|
|
) {
|
|
for &child_id in &nodes[id.0].children {
|
|
let child = &nodes[child_id.0];
|
|
let path = if prefix == "\\" {
|
|
format!("\\{}", child.name.to_lowercase())
|
|
} else {
|
|
format!("{}\\{}", prefix, child.name.to_lowercase())
|
|
};
|
|
index.insert(path.clone(), child_id);
|
|
if child.is_directory() {
|
|
walk(nodes, child_id, &path, index);
|
|
}
|
|
}
|
|
}
|
|
|
|
walk(&self.nodes, NodeId(0), "\\", &mut path_index);
|
|
|
|
VirtualTree {
|
|
nodes: self.nodes,
|
|
path_index,
|
|
}
|
|
}
|
|
}
|