wowaweewa

This commit is contained in:
Danilo Reyes
2026-02-07 06:15:34 -06:00
parent 070a3633d8
commit 4337a8847c
29 changed files with 1699 additions and 38 deletions

19
.gitignore vendored
View File

@@ -10,3 +10,22 @@
.DS_Store
Thumbs.db
*~
.vscode/
.idea/
*.tmp
*.swp
# Rust build/output
target/
debug/
release/
*.rs.bk
*.rlib
*.prof*
# Node/Svelte build/output
node_modules/
dist/
build/
*.log
.env*

17
README.md Normal file
View File

@@ -0,0 +1,17 @@
# Archive Curator
## Local Run Notes
- Backend: Rust (Axum) service in `backend/`
- Frontend: Svelte-based UI in `frontend/`
### Planned Commands
- Backend tests: `cargo test`
- Backend lint: `cargo clippy`
- Frontend scripts: `npm run dev` / `npm run build`
### Safety Defaults
This project is designed for local-only operation with strict safety gates:
read-only mode, preview/confirm workflows, and append-only audit logging.

18
backend/Cargo.toml Normal file
View File

@@ -0,0 +1,18 @@
[package]
name = "archive-curator-backend"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7"
chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "macros"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
thiserror = "1"
anyhow = "1"
uuid = { version = "1", features = ["v4", "serde"] }
rand = "0.8"

1
backend/rustfmt.toml Normal file
View File

@@ -0,0 +1 @@
edition = "2021"

2
backend/src/api/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod untagged;
pub mod untagged_delete;

145
backend/src/api/untagged.rs Normal file
View File

@@ -0,0 +1,145 @@
use std::fs;
use std::path::Path;
use axum::{
extract::{Path as AxumPath, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use crate::services::collage_sampler::MediaItem;
use crate::state::AppState;
#[derive(Debug, Serialize, Deserialize)]
pub struct UntaggedCollage {
pub directory_id: String,
pub directory_name: String,
pub total_size_bytes: u64,
pub file_count: u64,
pub samples: Vec<MediaItem>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DecisionResult {
pub outcome: String,
pub audit_entry_id: String,
}
pub fn router(state: AppState) -> Router {
Router::new()
.route("/directories/untagged/next", get(next_untagged))
.route(
"/directories/untagged/:directory_id/resample",
post(resample_collage),
)
.route(
"/directories/untagged/:directory_id/keep",
post(keep_directory),
)
.with_state(state)
}
async fn next_untagged(State(state): State<AppState>) -> Result<Json<UntaggedCollage>, StatusCode> {
let directory = state
.untagged_queue
.next_directory()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
let samples = state
.collage_sampler
.sample(&directory.id, &directory.absolute_path, 12)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(UntaggedCollage {
directory_id: directory.id,
directory_name: directory.name,
total_size_bytes: directory.total_size_bytes,
file_count: directory.file_count,
samples,
}))
}
async fn resample_collage(
State(state): State<AppState>,
AxumPath(directory_id): AxumPath<String>,
) -> Result<Json<UntaggedCollage>, StatusCode> {
let directory_path = state
.untagged_queue
.resolve_directory(&directory_id)
.map_err(|_| StatusCode::BAD_REQUEST)?;
let directory_name = directory_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
let (total_size_bytes, file_count) = dir_stats(&directory_path)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let samples = state
.collage_sampler
.sample(&directory_id, &directory_path, 12)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(UntaggedCollage {
directory_id,
directory_name,
total_size_bytes,
file_count,
samples,
}))
}
async fn keep_directory(
State(state): State<AppState>,
AxumPath(directory_id): AxumPath<String>,
) -> Result<impl IntoResponse, StatusCode> {
state
.read_only
.ensure_writable()
.map_err(|_| StatusCode::CONFLICT)?;
let directory_path = state
.untagged_queue
.resolve_directory(&directory_id)
.map_err(|_| StatusCode::BAD_REQUEST)?;
let destination = state
.ops
.keep_directory(&directory_path)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let entry = state
.audit_log
.append_mutation(
"keep_directory",
vec![directory_path.display().to_string(), destination.display().to_string()],
Vec::new(),
"ok",
None,
)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(DecisionResult {
outcome: "kept".to_string(),
audit_entry_id: entry.id.to_string(),
}))
}
fn dir_stats(path: &Path) -> std::io::Result<(u64, u64)> {
let mut total_size = 0u64;
let mut file_count = 0u64;
for entry in fs::read_dir(path)? {
let entry = entry?;
let meta = entry.metadata()?;
if meta.is_dir() {
let (size, count) = dir_stats(&entry.path())?;
total_size += size;
file_count += count;
} else if meta.is_file() {
total_size += meta.len();
file_count += 1;
}
}
Ok((total_size, file_count))
}

View File

@@ -0,0 +1,153 @@
use axum::{
extract::{Path as AxumPath, State},
http::StatusCode,
routing::post,
Json, Router,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::services::list_file::{apply_removals_atomic, load_entries, match_entries, preview_removals};
use crate::services::preview_action::{PreviewAction, PreviewActionType};
use crate::state::AppState;
#[derive(Debug, Serialize, Deserialize)]
pub struct DeletePreview {
pub preview_id: String,
pub target_paths: Vec<String>,
pub list_file_changes_preview: Vec<String>,
pub can_proceed: bool,
pub read_only_mode: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DeleteConfirm {
pub preview_id: String,
pub remove_from_list_file: bool,
#[serde(default)]
pub selected_matches: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DecisionResult {
pub outcome: String,
pub audit_entry_id: String,
}
pub fn router(state: AppState) -> Router {
Router::new()
.route(
"/directories/untagged/:directory_id/preview-delete",
post(preview_delete),
)
.route(
"/directories/untagged/:directory_id/confirm-delete",
post(confirm_delete),
)
.with_state(state)
}
async fn preview_delete(
State(state): State<AppState>,
AxumPath(directory_id): AxumPath<String>,
) -> Result<Json<DeletePreview>, StatusCode> {
state
.read_only
.ensure_writable()
.map_err(|_| StatusCode::CONFLICT)?;
let directory_path = state
.untagged_queue
.resolve_directory(&directory_id)
.map_err(|_| StatusCode::BAD_REQUEST)?;
let directory_name = directory_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
let mut entries = load_entries(&state.config.download_list_path)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let _ = match_entries(&mut entries, &[directory_name]);
let (_remaining, removed) = preview_removals(&entries);
let action = PreviewAction::new(
PreviewActionType::DirectoryDelete,
vec![directory_path.display().to_string()],
removed.clone(),
);
let action = state.preview_store.create(action);
Ok(Json(DeletePreview {
preview_id: action.id.to_string(),
target_paths: action.target_paths,
list_file_changes_preview: action.list_file_changes_preview,
can_proceed: true,
read_only_mode: false,
}))
}
async fn confirm_delete(
State(state): State<AppState>,
AxumPath(directory_id): AxumPath<String>,
Json(payload): Json<DeleteConfirm>,
) -> Result<Json<DecisionResult>, StatusCode> {
state
.read_only
.ensure_writable()
.map_err(|_| StatusCode::CONFLICT)?;
let preview_id = Uuid::parse_str(&payload.preview_id)
.map_err(|_| StatusCode::BAD_REQUEST)?;
let _preview = state
.preview_store
.confirm(preview_id)
.map_err(|_| StatusCode::BAD_REQUEST)?;
let directory_path = state
.untagged_queue
.resolve_directory(&directory_id)
.map_err(|_| StatusCode::BAD_REQUEST)?;
let mut list_file_changes = Vec::new();
if payload.remove_from_list_file {
let selected = payload
.selected_matches
.unwrap_or_else(|| {
directory_path
.file_name()
.and_then(|n| n.to_str())
.map(|s| vec![s.to_string()])
.unwrap_or_default()
});
let mut entries = load_entries(&state.config.download_list_path)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let _ = match_entries(&mut entries, &selected);
let (remaining, removed) = preview_removals(&entries);
apply_removals_atomic(&state.config.download_list_path, &remaining)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
list_file_changes = removed;
}
let staged = state
.ops
.confirm_delete_directory(&directory_path, false, true)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let entry = state
.audit_log
.append_mutation(
"delete_directory",
vec![directory_path.display().to_string()],
list_file_changes.clone(),
"ok",
Some(preview_id),
)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let outcome = if staged.is_some() { "staged" } else { "deleted" };
Ok(Json(DecisionResult {
outcome: outcome.to_string(),
audit_entry_id: entry.id.to_string(),
}))
}

126
backend/src/config.rs Normal file
View File

@@ -0,0 +1,126 @@
use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{AppError, AppResult};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub untagged_root: PathBuf,
pub whitelisted_root: PathBuf,
pub kept_root: PathBuf,
pub trash_root: PathBuf,
pub download_list_path: PathBuf,
pub audit_log_path: PathBuf,
pub state_db_path: PathBuf,
pub read_only_mode: bool,
pub hard_delete_enabled: bool,
pub excluded_patterns: Vec<String>,
}
impl Config {
pub fn from_env() -> AppResult<Self> {
let untagged_root = env_path("UNTAGGED_ROOT")?;
let whitelisted_root = env_path("WHITELISTED_ROOT")?;
let kept_root = env_path("KEPT_ROOT")?;
let trash_root = env_path("TRASH_ROOT")?;
let download_list_path = env_path("DOWNLOAD_LIST_PATH")?;
let audit_log_path = env_path("AUDIT_LOG_PATH")?;
let state_db_path = env_path("STATE_DB_PATH")?;
let read_only_mode = env_bool("READ_ONLY_MODE")?;
let hard_delete_enabled = env_bool("HARD_DELETE_ENABLED")?;
let excluded_patterns = std::env::var("EXCLUDED_PATTERNS")
.ok()
.map(|v| {
v.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default();
let config = Self {
untagged_root,
whitelisted_root,
kept_root,
trash_root,
download_list_path,
audit_log_path,
state_db_path,
read_only_mode,
hard_delete_enabled,
excluded_patterns,
};
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> AppResult<()> {
let roots = [
("untagged_root", &self.untagged_root),
("whitelisted_root", &self.whitelisted_root),
("kept_root", &self.kept_root),
("trash_root", &self.trash_root),
];
for (name, root) in roots.iter() {
if !root.is_absolute() {
return Err(AppError::InvalidConfig(format!(
"{name} must be an absolute path"
)));
}
}
validate_non_overlapping_roots(&roots)?;
Ok(())
}
}
pub fn validate_non_overlapping_roots(roots: &[(&str, &PathBuf)]) -> AppResult<()> {
let mut normalized = Vec::with_capacity(roots.len());
for (name, root) in roots.iter() {
let cleaned = normalize_path(root);
normalized.push(((*name).to_string(), cleaned));
}
for i in 0..normalized.len() {
for j in (i + 1)..normalized.len() {
let (name_a, path_a) = &normalized[i];
let (name_b, path_b) = &normalized[j];
if path_a == path_b {
return Err(AppError::InvalidConfig(format!(
"{name_a} and {name_b} must be different"
)));
}
if path_a.starts_with(path_b) || path_b.starts_with(path_a) {
return Err(AppError::InvalidConfig(format!(
"{name_a} and {name_b} must not overlap"
)));
}
}
}
Ok(())
}
fn normalize_path(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
Component::RootDir | Component::Prefix(_) => out.push(component.as_os_str()),
Component::Normal(_) => out.push(component.as_os_str()),
}
}
out
}
fn env_path(key: &str) -> AppResult<PathBuf> {
let value = std::env::var(key)
.map_err(|_| AppError::InvalidConfig(format!("{key} is required")))?;
Ok(PathBuf::from(value))
}
fn env_bool(key: &str) -> AppResult<bool> {
let value = std::env::var(key)
.map_err(|_| AppError::InvalidConfig(format!("{key} is required")))?;
Ok(matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
}

21
backend/src/error.rs Normal file
View File

@@ -0,0 +1,21 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("invalid configuration: {0}")]
InvalidConfig(String),
#[error("read-only mode enabled")]
ReadOnly,
#[error("path outside configured roots: {0}")]
PathViolation(String),
#[error("whitelisted directory protected: {0}")]
WhitelistProtected(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("serde json error: {0}")]
SerdeJson(#[from] serde_json::Error),
#[error("sqlx error: {0}")]
Sqlx(#[from] sqlx::Error),
}
pub type AppResult<T> = Result<T, AppError>;

50
backend/src/main.rs Normal file
View File

@@ -0,0 +1,50 @@
mod api;
mod config;
mod error;
mod services;
mod state;
use std::net::{IpAddr, SocketAddr};
use axum::{routing::get, Router};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::config::Config;
use crate::state::AppState;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::from_default_env())
.with(tracing_subscriber::fmt::layer())
.init();
let bind_addr = std::env::var("BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:8080".to_string());
let socket_addr: SocketAddr = bind_addr.parse()?;
if !is_local_network(socket_addr.ip()) {
return Err("bind address must be loopback or private network".into());
}
let config = Config::from_env()?;
let state = AppState::new(config)?;
let app = Router::new()
.route("/health", get(|| async { "OK" }))
.merge(api::untagged::router(state.clone()))
.merge(api::untagged_delete::router(state.clone()));
tracing::info!("listening on {}", socket_addr);
let listener = tokio::net::TcpListener::bind(socket_addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
fn is_local_network(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_shared(),
IpAddr::V6(v6) => v6.is_loopback() || v6.is_unique_local() || v6.is_unicast_link_local(),
}
}

View File

@@ -0,0 +1,62 @@
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::AppResult;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub id: Uuid,
pub timestamp: DateTime<Utc>,
pub action_type: String,
pub affected_paths: Vec<String>,
pub list_file_changes: Vec<String>,
pub outcome: String,
pub preview_id: Option<Uuid>,
}
#[derive(Clone)]
pub struct AuditLog {
path: PathBuf,
}
impl AuditLog {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
pub fn append(&self, entry: &AuditEntry) -> AppResult<()> {
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
let line = serde_json::to_string(entry)?;
writeln!(file, "{line}")?;
Ok(())
}
pub fn append_mutation(
&self,
action_type: &str,
affected_paths: Vec<String>,
list_file_changes: Vec<String>,
outcome: &str,
preview_id: Option<Uuid>,
) -> AppResult<AuditEntry> {
let entry = AuditEntry {
id: Uuid::new_v4(),
timestamp: Utc::now(),
action_type: action_type.to_string(),
affected_paths,
list_file_changes,
outcome: outcome.to_string(),
preview_id,
};
self.append(&entry)?;
Ok(entry)
}
}

View File

@@ -0,0 +1,83 @@
use std::fs;
use std::path::{Path, PathBuf};
use rand::seq::SliceRandom;
use rand::thread_rng;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::AppResult;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaItem {
pub id: String,
pub user_directory_id: String,
pub relative_path: String,
pub size_bytes: u64,
pub media_type: String,
}
#[derive(Clone, Default)]
pub struct CollageSampler;
impl CollageSampler {
pub fn sample(&self, directory_id: &str, directory: &Path, count: usize) -> AppResult<Vec<MediaItem>> {
let mut files = Vec::new();
collect_media_files(directory, &mut files)?;
let mut rng = thread_rng();
files.shuffle(&mut rng);
let samples = files.into_iter().take(count).map(|path| {
let relative_path = path
.strip_prefix(directory)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
let size_bytes = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
let media_type = media_type_for(&path);
MediaItem {
id: Uuid::new_v4().to_string(),
user_directory_id: directory_id.to_string(),
relative_path,
size_bytes,
media_type,
}
});
Ok(samples.collect())
}
}
fn collect_media_files(dir: &Path, out: &mut Vec<PathBuf>) -> AppResult<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let meta = entry.metadata()?;
if meta.is_dir() {
collect_media_files(&path, out)?;
} else if meta.is_file() && is_media_file(&path) {
out.push(path);
}
}
Ok(())
}
fn is_media_file(path: &Path) -> bool {
match path.extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase()) {
Some(ext) => matches!(
ext.as_str(),
"jpg" | "jpeg" | "png" | "gif" | "webp" | "bmp" | "mp4" | "webm" | "mkv" | "mov" | "avi"
),
None => false,
}
}
fn media_type_for(path: &Path) -> String {
match path.extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase()) {
Some(ext) if matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "gif" | "webp" | "bmp") => {
"image".to_string()
}
Some(ext) if matches!(ext.as_str(), "mp4" | "webm" | "mkv" | "mov" | "avi") => {
"video".to_string()
}
_ => "other".to_string(),
}
}

View File

@@ -0,0 +1,85 @@
use std::fs::{self, File};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{AppError, AppResult};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadListEntry {
pub raw_line: String,
pub normalized_value: String,
pub matched: bool,
}
pub fn load_entries(path: &Path) -> AppResult<Vec<DownloadListEntry>> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut entries = Vec::new();
for line in reader.lines() {
let raw = line?;
let normalized = normalize_value(&raw);
if normalized.is_empty() {
continue;
}
entries.push(DownloadListEntry {
raw_line: raw,
normalized_value: normalized,
matched: false,
});
}
Ok(entries)
}
pub fn match_entries(entries: &mut [DownloadListEntry], targets: &[String]) -> Vec<DownloadListEntry> {
let normalized_targets: Vec<String> = targets.iter().map(|t| normalize_value(t)).collect();
let mut matched = Vec::new();
for entry in entries.iter_mut() {
if normalized_targets.iter().any(|t| t == &entry.normalized_value) {
entry.matched = true;
matched.push(entry.clone());
}
}
matched
}
pub fn preview_removals(entries: &[DownloadListEntry]) -> (Vec<String>, Vec<String>) {
let mut remaining = Vec::new();
let mut removed = Vec::new();
for entry in entries {
if entry.matched {
removed.push(entry.raw_line.clone());
} else {
remaining.push(entry.raw_line.clone());
}
}
(remaining, removed)
}
pub fn apply_removals_atomic(path: &Path, remaining_lines: &[String]) -> AppResult<()> {
let temp_path = temp_path_for(path)?;
{
let mut file = File::create(&temp_path)?;
for line in remaining_lines {
writeln!(file, "{line}")?;
}
}
fs::rename(temp_path, path)?;
Ok(())
}
pub fn normalize_value(value: &str) -> String {
value.trim().to_lowercase()
}
fn temp_path_for(path: &Path) -> AppResult<PathBuf> {
let parent = path
.parent()
.ok_or_else(|| AppError::InvalidConfig("list file has no parent".to_string()))?;
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| AppError::InvalidConfig("list file has invalid name".to_string()))?;
Ok(parent.join(format!("{file_name}.tmp")))
}

View File

@@ -0,0 +1,10 @@
pub mod audit_log;
pub mod collage_sampler;
pub mod list_file;
pub mod ops;
pub mod ops_lock;
pub mod path_guard;
pub mod preview_action;
pub mod read_only;
pub mod state_store;
pub mod untagged_queue;

130
backend/src/services/ops.rs Normal file
View File

@@ -0,0 +1,130 @@
use std::fs;
use std::path::{Path, PathBuf};
use uuid::Uuid;
use crate::config::Config;
use crate::error::{AppError, AppResult};
use crate::services::path_guard::PathGuard;
#[derive(Clone)]
pub struct Ops {
path_guard: PathGuard,
whitelisted_root: PathBuf,
kept_root: PathBuf,
trash_root: PathBuf,
hard_delete_enabled: bool,
}
impl Ops {
pub fn from_config(config: &Config, path_guard: PathGuard) -> AppResult<Self> {
config.validate()?;
Ok(Self {
path_guard,
whitelisted_root: config.whitelisted_root.clone(),
kept_root: config.kept_root.clone(),
trash_root: config.trash_root.clone(),
hard_delete_enabled: config.hard_delete_enabled,
})
}
pub fn move_dir(&self, from: &Path, to: &Path) -> AppResult<()> {
self.path_guard.ensure_within_roots(from)?;
self.path_guard.ensure_within_roots(to)?;
self.ensure_not_symlink(from)?;
fs::rename(from, to)?;
Ok(())
}
pub fn keep_directory(&self, path: &Path) -> AppResult<PathBuf> {
self.path_guard.ensure_within_roots(path)?;
self.ensure_not_symlink(path)?;
let name = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| AppError::InvalidConfig("invalid path".to_string()))?;
let destination = self.kept_root.join(name);
self.move_dir(path, &destination)?;
Ok(destination)
}
pub fn stage_delete_dir(&self, path: &Path) -> AppResult<PathBuf> {
self.path_guard.ensure_within_roots(path)?;
self.ensure_not_whitelisted(path)?;
self.ensure_not_symlink(path)?;
let staged_path = self.staged_path(path)?;
fs::rename(path, &staged_path)?;
Ok(staged_path)
}
pub fn stage_delete_file(&self, path: &Path) -> AppResult<PathBuf> {
self.path_guard.ensure_within_roots(path)?;
self.ensure_not_symlink(path)?;
let staged_path = self.staged_path(path)?;
fs::rename(path, &staged_path)?;
Ok(staged_path)
}
pub fn hard_delete_dir(&self, path: &Path, confirmed: bool) -> AppResult<()> {
self.path_guard.ensure_within_roots(path)?;
self.ensure_not_whitelisted(path)?;
self.ensure_not_symlink(path)?;
self.ensure_hard_delete_allowed(confirmed)?;
fs::remove_dir_all(path)?;
Ok(())
}
pub fn hard_delete_file(&self, path: &Path, confirmed: bool) -> AppResult<()> {
self.path_guard.ensure_within_roots(path)?;
self.ensure_not_symlink(path)?;
self.ensure_hard_delete_allowed(confirmed)?;
fs::remove_file(path)?;
Ok(())
}
pub fn confirm_delete_directory(&self, path: &Path, hard_delete: bool, confirmed: bool) -> AppResult<Option<PathBuf>> {
if hard_delete {
self.hard_delete_dir(path, confirmed)?;
Ok(None)
} else {
let staged = self.stage_delete_dir(path)?;
Ok(Some(staged))
}
}
fn ensure_not_whitelisted(&self, path: &Path) -> AppResult<()> {
if path.starts_with(&self.whitelisted_root) {
return Err(AppError::WhitelistProtected(path.display().to_string()));
}
Ok(())
}
fn ensure_hard_delete_allowed(&self, confirmed: bool) -> AppResult<()> {
if !self.hard_delete_enabled || !confirmed {
return Err(AppError::InvalidConfig(
"hard delete disabled or unconfirmed".to_string(),
));
}
Ok(())
}
fn ensure_not_symlink(&self, path: &Path) -> AppResult<()> {
let metadata = fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() {
return Err(AppError::PathViolation(format!(
"symlink not allowed: {}",
path.display()
)));
}
Ok(())
}
fn staged_path(&self, path: &Path) -> AppResult<PathBuf> {
let name = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| AppError::InvalidConfig("invalid path".to_string()))?;
let suffix = Uuid::new_v4();
Ok(self.trash_root.join(format!("{name}.{suffix}.staged")))
}
}

View File

@@ -0,0 +1,18 @@
use std::sync::Arc;
use tokio::sync::{Mutex, MutexGuard};
#[derive(Clone, Default)]
pub struct OpsLock {
inner: Arc<Mutex<()>>,
}
impl OpsLock {
pub fn new() -> Self {
Self::default()
}
pub async fn acquire(&self) -> MutexGuard<'_, ()> {
self.inner.lock().await
}
}

View File

@@ -0,0 +1,48 @@
use std::path::{Path, PathBuf};
use crate::config::Config;
use crate::error::{AppError, AppResult};
#[derive(Debug, Clone)]
pub struct PathGuard {
roots: Vec<PathBuf>,
}
impl PathGuard {
pub fn from_config(config: &Config) -> AppResult<Self> {
config.validate()?;
Ok(Self {
roots: vec![
config.untagged_root.clone(),
config.whitelisted_root.clone(),
config.kept_root.clone(),
config.trash_root.clone(),
],
})
}
pub fn ensure_within_roots(&self, path: &Path) -> AppResult<()> {
let normalized = normalize(path);
for root in &self.roots {
let root_norm = normalize(root);
if normalized.starts_with(&root_norm) {
return Ok(());
}
}
Err(AppError::PathViolation(path.display().to_string()))
}
}
fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
_ => out.push(component.as_os_str()),
}
}
out
}

View File

@@ -0,0 +1,83 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{AppError, AppResult};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PreviewActionType {
DirectoryDelete,
FileDelete,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PreviewAction {
pub id: Uuid,
pub action_type: PreviewActionType,
pub target_paths: Vec<String>,
pub list_file_changes_preview: Vec<String>,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
impl PreviewAction {
pub fn new(action_type: PreviewActionType, target_paths: Vec<String>, list_file_changes: Vec<String>) -> Self {
let created_at = Utc::now();
let expires_at = created_at + Duration::minutes(15);
Self {
id: Uuid::new_v4(),
action_type,
target_paths,
list_file_changes_preview: list_file_changes,
created_at,
expires_at,
}
}
pub fn is_expired(&self) -> bool {
Utc::now() > self.expires_at
}
}
#[derive(Clone, Default)]
pub struct PreviewActionStore {
inner: Arc<Mutex<HashMap<Uuid, PreviewAction>>>,
}
impl PreviewActionStore {
pub fn new() -> Self {
Self::default()
}
pub fn create(&self, action: PreviewAction) -> PreviewAction {
let mut guard = self.inner.lock().expect("preview store lock");
guard.insert(action.id, action.clone());
action
}
pub fn get(&self, id: Uuid) -> AppResult<PreviewAction> {
let guard = self.inner.lock().expect("preview store lock");
let action = guard
.get(&id)
.cloned()
.ok_or_else(|| AppError::InvalidConfig("preview action not found".to_string()))?;
if action.is_expired() {
return Err(AppError::InvalidConfig("preview action expired".to_string()));
}
Ok(action)
}
pub fn confirm(&self, id: Uuid) -> AppResult<PreviewAction> {
let mut guard = self.inner.lock().expect("preview store lock");
let action = guard
.remove(&id)
.ok_or_else(|| AppError::InvalidConfig("preview action not found".to_string()))?;
if action.is_expired() {
return Err(AppError::InvalidConfig("preview action expired".to_string()));
}
Ok(action)
}
}

View File

@@ -0,0 +1,27 @@
use crate::config::Config;
use crate::error::{AppError, AppResult};
#[derive(Debug, Clone)]
pub struct ReadOnlyGuard {
read_only: bool,
}
impl ReadOnlyGuard {
pub fn new(config: &Config) -> Self {
Self {
read_only: config.read_only_mode,
}
}
pub fn ensure_writable(&self) -> AppResult<()> {
if self.read_only {
Err(AppError::ReadOnly)
} else {
Ok(())
}
}
pub fn ensure_writable_for_operation(&self, _operation: &str) -> AppResult<()> {
self.ensure_writable()
}
}

View File

@@ -0,0 +1,23 @@
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool};
use std::str::FromStr;
use crate::config::Config;
use crate::error::AppResult;
#[derive(Clone)]
pub struct StateStore {
pool: SqlitePool,
}
impl StateStore {
pub async fn connect(config: &Config) -> AppResult<Self> {
let options = SqliteConnectOptions::from_str(&config.state_db_path.to_string_lossy())?
.create_if_missing(true);
let pool = SqlitePool::connect_with(options).await?;
Ok(Self { pool })
}
pub fn pool(&self) -> &SqlitePool {
&self.pool
}
}

View File

@@ -0,0 +1,97 @@
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::error::{AppError, AppResult};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UntaggedDirectory {
pub id: String,
pub name: String,
pub absolute_path: PathBuf,
pub total_size_bytes: u64,
pub file_count: u64,
}
#[derive(Clone)]
pub struct UntaggedQueue {
root: PathBuf,
}
impl UntaggedQueue {
pub fn new(config: &Config) -> AppResult<Self> {
config.validate()?;
Ok(Self {
root: config.untagged_root.clone(),
})
}
pub fn next_directory(&self) -> AppResult<Option<UntaggedDirectory>> {
let mut dirs: Vec<PathBuf> = fs::read_dir(&self.root)?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect();
dirs.sort();
let path = match dirs.first() {
Some(path) => path.to_path_buf(),
None => return Ok(None),
};
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
let id = relative_id(&self.root, &path)?;
let (total_size_bytes, file_count) = dir_stats(&path)?;
Ok(Some(UntaggedDirectory {
id,
name,
absolute_path: path,
total_size_bytes,
file_count,
}))
}
pub fn resolve_directory(&self, id: &str) -> AppResult<PathBuf> {
ensure_safe_id(id)?;
Ok(self.root.join(id))
}
}
fn dir_stats(path: &Path) -> AppResult<(u64, u64)> {
let mut total_size = 0u64;
let mut file_count = 0u64;
for entry in fs::read_dir(path)? {
let entry = entry?;
let meta = entry.metadata()?;
if meta.is_dir() {
let (size, count) = dir_stats(&entry.path())?;
total_size += size;
file_count += count;
} else if meta.is_file() {
total_size += meta.len();
file_count += 1;
}
}
Ok((total_size, file_count))
}
fn relative_id(root: &Path, path: &Path) -> AppResult<String> {
let rel = path
.strip_prefix(root)
.map_err(|_| AppError::InvalidConfig("path outside untagged root".to_string()))?;
Ok(rel.to_string_lossy().to_string())
}
fn ensure_safe_id(id: &str) -> AppResult<()> {
let path = Path::new(id);
for component in path.components() {
if matches!(component, std::path::Component::ParentDir) {
return Err(AppError::InvalidConfig("invalid directory id".to_string()));
}
}
Ok(())
}

42
backend/src/state.rs Normal file
View File

@@ -0,0 +1,42 @@
use std::sync::Arc;
use crate::config::Config;
use crate::error::AppResult;
use crate::services::{
audit_log::AuditLog, collage_sampler::CollageSampler, ops::Ops, path_guard::PathGuard,
preview_action::PreviewActionStore, read_only::ReadOnlyGuard, untagged_queue::UntaggedQueue,
};
#[derive(Clone)]
pub struct AppState {
pub config: Arc<Config>,
pub path_guard: PathGuard,
pub ops: Ops,
pub read_only: ReadOnlyGuard,
pub audit_log: AuditLog,
pub preview_store: PreviewActionStore,
pub untagged_queue: UntaggedQueue,
pub collage_sampler: CollageSampler,
}
impl AppState {
pub fn new(config: Config) -> AppResult<Self> {
let path_guard = PathGuard::from_config(&config)?;
let ops = Ops::from_config(&config, path_guard.clone())?;
let read_only = ReadOnlyGuard::new(&config);
let audit_log = AuditLog::new(config.audit_log_path.clone());
let preview_store = PreviewActionStore::new();
let untagged_queue = UntaggedQueue::new(&config)?;
let collage_sampler = CollageSampler::default();
Ok(Self {
config: Arc::new(config),
path_guard,
ops,
read_only,
audit_log,
preview_store,
untagged_queue,
collage_sampler,
})
}
}

4
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"semi": true
}

21
frontend/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "archive-curator-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"eslint": "^9.0.0",
"prettier": "^3.0.0",
"svelte": "^5.0.0",
"vite": "^5.0.0"
}
}

View File

@@ -0,0 +1,55 @@
<script lang="ts">
export let matches: string[] = [];
export let selected: string[] = [];
function toggle(match: string) {
if (selected.includes(match)) {
selected = selected.filter((value) => value !== match);
} else {
selected = [...selected, match];
}
}
</script>
<div class="matches">
<h3>List-file matches</h3>
{#if matches.length === 0}
<p>No matches detected.</p>
{:else}
<ul>
{#each matches as match}
<li>
<label>
<input
type="checkbox"
checked={selected.includes(match)}
on:change={() => toggle(match)}
/>
<span>{match}</span>
</label>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.matches {
border: 1px solid #c2b8a3;
padding: 1rem;
border-radius: 12px;
background: #f7f2e9;
}
ul {
list-style: none;
padding: 0;
margin: 0.5rem 0 0;
display: grid;
gap: 0.5rem;
}
label {
display: flex;
gap: 0.5rem;
align-items: center;
}
</style>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
export let onResample: () => void;
export let onKeep: () => void;
export let onPreviewDelete: () => void;
export let onConfirmDelete: () => void;
export let confirmEnabled = false;
export let busy = false;
</script>
<div class="controls">
<button on:click={onResample} disabled={busy}>Resample</button>
<button on:click={onKeep} disabled={busy}>Keep</button>
<button on:click={onPreviewDelete} disabled={busy}>Preview delete</button>
<button class="danger" on:click={onConfirmDelete} disabled={!confirmEnabled || busy}>
Confirm delete
</button>
</div>
<style>
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
button {
padding: 0.6rem 1rem;
border-radius: 999px;
border: 1px solid #1e1e1e;
background: #f1d6b8;
font-weight: 600;
}
button.danger {
background: #d96c4f;
color: #fff;
border-color: #7f3422;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,210 @@
<script lang="ts">
import { onMount } from 'svelte';
import ListFileMatches from '../components/list-file-matches.svelte';
import UntaggedControls from '../components/untagged-controls.svelte';
import {
confirmDelete,
fetchNextUntagged,
previewDelete,
resampleCollage,
keepDirectory,
type DeletePreview,
type UntaggedCollage,
} from '../services/untagged_api';
let collage: UntaggedCollage | null = null;
let preview: DeletePreview | null = null;
let selectedMatches: string[] = [];
let statusMessage = '';
let busy = false;
async function loadNext() {
busy = true;
statusMessage = '';
preview = null;
selectedMatches = [];
try {
collage = await fetchNextUntagged();
} catch (err) {
statusMessage = 'No untagged directories available.';
} finally {
busy = false;
}
}
async function handleResample() {
if (!collage) return;
busy = true;
try {
collage = await resampleCollage(collage.directory_id);
} finally {
busy = false;
}
}
async function handleKeep() {
if (!collage) return;
busy = true;
statusMessage = '';
try {
await keepDirectory(collage.directory_id);
statusMessage = 'Directory moved to kept.';
await loadNext();
} catch (err) {
statusMessage = 'Keep failed.';
} finally {
busy = false;
}
}
async function handlePreviewDelete() {
if (!collage) return;
busy = true;
statusMessage = '';
try {
preview = await previewDelete(collage.directory_id);
selectedMatches = preview.list_file_changes_preview;
} catch (err) {
statusMessage = 'Preview failed.';
} finally {
busy = false;
}
}
async function handleConfirmDelete() {
if (!collage || !preview) return;
busy = true;
statusMessage = '';
try {
await confirmDelete(collage.directory_id, {
preview_id: preview.preview_id,
remove_from_list_file: selectedMatches.length > 0,
selected_matches: selectedMatches,
});
statusMessage = 'Delete staged.';
await loadNext();
} catch (err) {
statusMessage = 'Delete failed.';
} finally {
busy = false;
}
}
onMount(loadNext);
</script>
<section class="page">
<header>
<h1>Untagged Collage Review</h1>
<p>Curate directories quickly, with staged deletes and list-file previews.</p>
</header>
{#if collage}
<div class="summary">
<h2>{collage.directory_name}</h2>
<div class="meta">
<span>{collage.file_count} files</span>
<span>{Math.round(collage.total_size_bytes / (1024 * 1024))} MB</span>
</div>
</div>
<div class="grid">
{#each collage.samples as item}
<div class="tile">
<div class="badge">{item.media_type}</div>
<div class="path">{item.relative_path}</div>
</div>
{/each}
</div>
<UntaggedControls
{busy}
onResample={handleResample}
onKeep={handleKeep}
onPreviewDelete={handlePreviewDelete}
onConfirmDelete={handleConfirmDelete}
confirmEnabled={Boolean(preview)}
/>
{#if preview}
<ListFileMatches
matches={preview.list_file_changes_preview}
bind:selected={selectedMatches}
/>
{/if}
{:else}
<p class="empty">{statusMessage}</p>
{/if}
{#if statusMessage && collage}
<p class="status">{statusMessage}</p>
{/if}
</section>
<style>
:global(body) {
font-family: 'Space Grotesk', 'Fira Sans', sans-serif;
margin: 0;
background: radial-gradient(circle at top left, #f8efe1, #f4dfc8 55%, #e6c39b 100%);
color: #1b130b;
}
.page {
padding: 2rem;
max-width: 1100px;
margin: 0 auto;
}
header h1 {
font-size: 2.4rem;
margin-bottom: 0.2rem;
}
header p {
margin-top: 0;
color: #5b4634;
}
.summary {
margin: 1.5rem 0;
display: flex;
justify-content: space-between;
align-items: baseline;
border-bottom: 1px solid #bba486;
padding-bottom: 0.5rem;
}
.meta {
display: flex;
gap: 1rem;
font-weight: 600;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.tile {
background: #fff8ee;
border-radius: 16px;
padding: 0.8rem;
min-height: 120px;
box-shadow: 0 8px 24px rgba(73, 45, 22, 0.15);
}
.badge {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #8d5b3c;
}
.path {
font-size: 0.85rem;
margin-top: 0.6rem;
color: #3b2a1d;
word-break: break-word;
}
.status {
margin-top: 1rem;
font-weight: 600;
}
.empty {
font-style: italic;
padding: 2rem 0;
}
</style>

View File

@@ -0,0 +1,70 @@
export type MediaItem = {
id: string;
user_directory_id: string;
relative_path: string;
size_bytes: number;
media_type: string;
};
export type UntaggedCollage = {
directory_id: string;
directory_name: string;
total_size_bytes: number;
file_count: number;
samples: MediaItem[];
};
export type DeletePreview = {
preview_id: string;
target_paths: string[];
list_file_changes_preview: string[];
can_proceed: boolean;
read_only_mode: boolean;
};
export type DeleteConfirm = {
preview_id: string;
remove_from_list_file: boolean;
selected_matches?: string[];
};
export type DecisionResult = {
outcome: string;
audit_entry_id: string;
};
const API_BASE = import.meta.env.VITE_API_BASE ?? '';
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return (await response.json()) as T;
}
export function fetchNextUntagged(): Promise<UntaggedCollage> {
return request('/directories/untagged/next');
}
export function resampleCollage(directoryId: string): Promise<UntaggedCollage> {
return request(`/directories/untagged/${directoryId}/resample`, { method: 'POST' });
}
export function keepDirectory(directoryId: string): Promise<DecisionResult> {
return request(`/directories/untagged/${directoryId}/keep`, { method: 'POST' });
}
export function previewDelete(directoryId: string): Promise<DeletePreview> {
return request(`/directories/untagged/${directoryId}/preview-delete`, { method: 'POST' });
}
export function confirmDelete(directoryId: string, payload: DeleteConfirm): Promise<DecisionResult> {
return request(`/directories/untagged/${directoryId}/confirm-delete`, {
method: 'POST',
body: JSON.stringify(payload),
});
}

View File

@@ -29,11 +29,11 @@ description: "Task list template for feature implementation"
**Purpose**: Project initialization and basic structure
- [ ] T001 Create backend and frontend directory structure in `backend/src/` and `frontend/src/`
- [ ] T002 Initialize Rust backend crate in `backend/Cargo.toml`
- [ ] T003 Initialize SvelteKit frontend in `frontend/package.json`
- [ ] T004 [P] Add repository-wide formatting and lint configs in `backend/rustfmt.toml` and `frontend/.prettierrc`
- [ ] T005 Add base README for local run notes in `README.md`
- [X] T001 Create backend and frontend directory structure in `backend/src/` and `frontend/src/`
- [X] T002 Initialize Rust backend crate in `backend/Cargo.toml`
- [X] T003 Initialize SvelteKit frontend in `frontend/package.json`
- [X] T004 [P] Add repository-wide formatting and lint configs in `backend/rustfmt.toml` and `frontend/.prettierrc`
- [X] T005 Add base README for local run notes in `README.md`
---
@@ -43,17 +43,17 @@ description: "Task list template for feature implementation"
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
- [ ] T006 Implement configuration model and validation in `backend/src/config.rs`
- [ ] T006a Implement root non-overlap validation (fail-fast) in `backend/src/config.rs`
- [ ] T007 Implement root boundary validation helpers in `backend/src/services/path_guard.rs`
- [ ] T008 Implement read-only mode enforcement guard in `backend/src/services/read_only.rs`
- [ ] T009 Implement state storage access layer in `backend/src/services/state_store.rs`
- [ ] T010 Implement audit log append-only writer in `backend/src/services/audit_log.rs`
- [ ] T011 Implement list-file parser and matcher in `backend/src/services/list_file.rs`
- [ ] T012 Implement preview/confirm action model in `backend/src/services/preview_action.rs`
- [ ] T013 Implement filesystem operations facade in `backend/src/services/ops.rs`
- [ ] T014 Add HTTP server bootstrap and routing in `backend/src/main.rs`
- [ ] T014a Enforce bind address defaults/local-network restriction in `backend/src/main.rs`
- [X] T006 Implement configuration model and validation in `backend/src/config.rs`
- [X] T006a Implement root non-overlap validation (fail-fast) in `backend/src/config.rs`
- [X] T007 Implement root boundary validation helpers in `backend/src/services/path_guard.rs`
- [X] T008 Implement read-only mode enforcement guard in `backend/src/services/read_only.rs`
- [X] T009 Implement state storage access layer in `backend/src/services/state_store.rs`
- [X] T010 Implement audit log append-only writer in `backend/src/services/audit_log.rs`
- [X] T011 Implement list-file parser and matcher in `backend/src/services/list_file.rs`
- [X] T012 Implement preview/confirm action model in `backend/src/services/preview_action.rs`
- [X] T013 Implement filesystem operations facade in `backend/src/services/ops.rs`
- [X] T014 Add HTTP server bootstrap and routing in `backend/src/main.rs`
- [X] T014a Enforce bind address defaults/local-network restriction in `backend/src/main.rs`
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
@@ -63,16 +63,16 @@ description: "Task list template for feature implementation"
**Purpose**: Enforce constitution safety guarantees before any deletion work
- [ ] T015 Implement global read-only mode block in `backend/src/services/read_only.rs`
- [ ] T016 Enforce root-path boundaries for all filesystem operations in `backend/src/services/path_guard.rs`
- [ ] T017 Implement single-writer guard for destructive operations in `backend/src/services/ops_lock.rs`
- [ ] T018 Implement dry-run preview + explicit confirmation flow in `backend/src/services/preview_action.rs`
- [ ] T019 Implement two-stage deletion (trash/staging) in `backend/src/services/ops.rs`
- [ ] T019a Enforce hard-delete disabled by default and require explicit config + confirmation in `backend/src/services/ops.rs`
- [ ] T020 Enforce symlink-safe deletion in `backend/src/services/ops.rs`
- [ ] T021 Append-only audit log for every mutation in `backend/src/services/audit_log.rs`
- [ ] T022 Enforce whitelist protection for directory-level actions in `backend/src/services/ops.rs`
- [ ] T023 Implement list-file edit preview + atomic write in `backend/src/services/list_file.rs`
- [X] T015 Implement global read-only mode block in `backend/src/services/read_only.rs`
- [X] T016 Enforce root-path boundaries for all filesystem operations in `backend/src/services/path_guard.rs`
- [X] T017 Implement single-writer guard for destructive operations in `backend/src/services/ops_lock.rs`
- [X] T018 Implement dry-run preview + explicit confirmation flow in `backend/src/services/preview_action.rs`
- [X] T019 Implement two-stage deletion (trash/staging) in `backend/src/services/ops.rs`
- [X] T019a Enforce hard-delete disabled by default and require explicit config + confirmation in `backend/src/services/ops.rs`
- [X] T020 Enforce symlink-safe deletion in `backend/src/services/ops.rs`
- [X] T021 Append-only audit log for every mutation in `backend/src/services/audit_log.rs`
- [X] T022 Enforce whitelist protection for directory-level actions in `backend/src/services/ops.rs`
- [X] T023 Implement list-file edit preview + atomic write in `backend/src/services/list_file.rs`
**Checkpoint**: Safety guarantees verified - destructive workflows can now begin
@@ -87,18 +87,18 @@ preview delete with list-file matches, and confirm delete with audit entry
### Implementation for User Story 1
- [ ] T024 [P] [US1] Implement untagged directory selection service in `backend/src/services/untagged_queue.rs`
- [ ] T025 [P] [US1] Implement collage sampler in `backend/src/services/collage_sampler.rs`
- [ ] T026 [US1] Implement keep decision (move to kept root) in `backend/src/services/ops.rs`
- [ ] T027 [US1] Implement delete preview for untagged directories in `backend/src/services/preview_action.rs`
- [ ] T028 [US1] Implement delete confirmation for untagged directories in `backend/src/services/ops.rs`
- [ ] T029 [P] [US1] Add API endpoints for untagged review in `backend/src/api/untagged.rs`
- [ ] T030 [P] [US1] Add API endpoints for untagged delete preview/confirm in `backend/src/api/untagged_delete.rs`
- [ ] T030a [P] [US1] Add list-file match selection payload handling in `backend/src/api/untagged_delete.rs`
- [ ] T031 [P] [US1] Create collage UI page in `frontend/src/pages/untagged-collage.svelte`
- [ ] T032 [P] [US1] Create resample and decision controls in `frontend/src/components/untagged-controls.svelte`
- [ ] T032a [P] [US1] Add list-file match selection UI in `frontend/src/components/list-file-matches.svelte`
- [ ] T033 [US1] Wire untagged review API client in `frontend/src/services/untagged_api.ts`
- [X] T024 [P] [US1] Implement untagged directory selection service in `backend/src/services/untagged_queue.rs`
- [X] T025 [P] [US1] Implement collage sampler in `backend/src/services/collage_sampler.rs`
- [X] T026 [US1] Implement keep decision (move to kept root) in `backend/src/services/ops.rs`
- [X] T027 [US1] Implement delete preview for untagged directories in `backend/src/services/preview_action.rs`
- [X] T028 [US1] Implement delete confirmation for untagged directories in `backend/src/services/ops.rs`
- [X] T029 [P] [US1] Add API endpoints for untagged review in `backend/src/api/untagged.rs`
- [X] T030 [P] [US1] Add API endpoints for untagged delete preview/confirm in `backend/src/api/untagged_delete.rs`
- [X] T030a [P] [US1] Add list-file match selection payload handling in `backend/src/api/untagged_delete.rs`
- [X] T031 [P] [US1] Create collage UI page in `frontend/src/pages/untagged-collage.svelte`
- [X] T032 [P] [US1] Create resample and decision controls in `frontend/src/components/untagged-controls.svelte`
- [X] T032a [P] [US1] Add list-file match selection UI in `frontend/src/components/list-file-matches.svelte`
- [X] T033 [US1] Wire untagged review API client in `frontend/src/services/untagged_api.ts`
**Checkpoint**: User Story 1 fully functional and independently testable