diff --git a/README.md b/README.md index e3de85f..c4a57b2 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ _No cloud, no telemetry – your data never leaves the machine._ ▲ search / exec └──────┬──────┘ └────────── backup / restore ▼ timestamped snapshots -```` +``` --- @@ -56,34 +56,10 @@ cargo build --release sudo install -Dm755 target/release/marlin /usr/local/bin/marlin ``` ---- - ## Quick start -```bash -marlin init # create DB (idempotent) -marlin scan ~/Pictures ~/Documents # index files -marlin tag ~/Pictures/**/*.jpg photos/trip-2024 # add hierarchical tag -marlin attr set ~/Documents/**/*.pdf reviewed yes # set custom attribute -marlin search reviewed --exec "xdg-open {}" # open matches -marlin backup # snapshot DB -``` +For a concise walkthrough, see [Quick start & Demo](marlin_demo.md). ---- - -### Enable shell completions (optional but handy) - -```bash -# create the directory if needed -mkdir -p ~/.config/bash_completion.d - -# dump Bash completion -marlin completions bash > ~/.config/bash_completion.d/marlin -``` - -For Zsh, Fish, etc., redirect into your shell’s completions folder. - ---- ### Database location diff --git a/bar.txt b/bar.txt new file mode 100644 index 0000000..e69de29 diff --git a/foo.txt b/foo.txt new file mode 100644 index 0000000..e69de29 diff --git a/marlin_demo.md b/marlin_demo.md index 55b4329..d49e7e6 100644 --- a/marlin_demo.md +++ b/marlin_demo.md @@ -1,9 +1,42 @@ +# Quick start & Demo + +## Quick start + +```bash +# initialize the demo database +marlin init + +# index only your demo folder +marlin scan ~/marlin_demo_complex + +# tag all markdown in your demo Projects as “project/md” +marlin tag "~/marlin_demo_complex/Projects/**/*.md" project/md + +# mark your demo reports as reviewed +marlin attr set "~/marlin_demo_complex/Reports/*.pdf" reviewed yes + +# search for any reviewed files +marlin search "attr:reviewed=yes" + +# snapshot the demo database +marlin backup + +# test linking within your demo +touch ~/marlin_demo_complex/foo.txt ~/marlin_demo_complex/bar.txt +marlin scan ~/marlin_demo_complex +foo=~/marlin_demo_complex/foo.txt +bar=~/marlin_demo_complex/bar.txt +marlin link add "$foo" "$bar" +marlin link list "$foo" +marlin link backlinks "$bar" +```` + +--- + # Marlin Demo Here’s a little “complex‐demo” you can spin up to exercise tags, attributes, FTS queries, `--exec` hooks, backups & restores. Just copy–paste each block into your terminal: ---- - ### 0 Create the demo folder and some files ```bash @@ -148,18 +181,4 @@ marlin restore "$snap" # Confirm you still see “TODO” marlin search TODO -``` - ---- - -That gives you: - -* **wide folder structures** (Projects, Logs, Reports, Scripts, Media) -* **hierarchical tags** you can mix and match -* **key-value attributes** to flag state & review -* **FTS5 queries** with AND/OR/NOT -* **`--exec` hooks** to trigger external commands -* **JSON output** for programmatic gluing -* **backups & restores** to guard your data - -Have fun playing around! +``` \ No newline at end of file diff --git a/src/cli/link.rs b/src/cli/link.rs index f696360..16c23c1 100644 --- a/src/cli/link.rs +++ b/src/cli/link.rs @@ -1,4 +1,6 @@ // src/cli/link.rs + +use crate::db; use clap::{Subcommand, Args}; use rusqlite::Connection; use crate::cli::Format; @@ -35,9 +37,119 @@ pub struct BacklinksArgs { pub fn run(cmd: &LinkCmd, conn: &mut Connection, format: Format) -> anyhow::Result<()> { match cmd { - LinkCmd::Add(args) => todo!("link add {:?}", args), - LinkCmd::Rm(args) => todo!("link rm {:?}", args), - LinkCmd::List(args) => todo!("link list {:?}", args), - LinkCmd::Backlinks(args) => todo!("link backlinks {:?}", args), + LinkCmd::Add(args) => { + let src_id = db::file_id(conn, &args.from)?; + let dst_id = db::file_id(conn, &args.to)?; + db::add_link(conn, src_id, dst_id, args.r#type.as_deref())?; + match format { + Format::Text => { + if let Some(t) = &args.r#type { + println!("Linked '{}' → '{}' [type='{}']", args.from, args.to, t); + } else { + println!("Linked '{}' → '{}'", args.from, args.to); + } + } + Format::Json => { + let typ = args + .r#type + .as_ref() + .map(|s| format!("\"{}\"", s)) + .unwrap_or_else(|| "null".into()); + println!( + "{{\"from\":\"{}\",\"to\":\"{}\",\"type\":{}}}", + args.from, args.to, typ + ); + } + } + } + LinkCmd::Rm(args) => { + let src_id = db::file_id(conn, &args.from)?; + let dst_id = db::file_id(conn, &args.to)?; + db::remove_link(conn, src_id, dst_id, args.r#type.as_deref())?; + match format { + Format::Text => { + if let Some(t) = &args.r#type { + println!("Removed link '{}' → '{}' [type='{}']", args.from, args.to, t); + } else { + println!("Removed link '{}' → '{}'", args.from, args.to); + } + } + Format::Json => { + let typ = args + .r#type + .as_ref() + .map(|s| format!("\"{}\"", s)) + .unwrap_or_else(|| "null".into()); + println!( + "{{\"from\":\"{}\",\"to\":\"{}\",\"type\":{}}}", + args.from, args.to, typ + ); + } + } + } + LinkCmd::List(args) => { + let results = db::list_links( + conn, + &args.pattern, + args.direction.as_deref(), + args.r#type.as_deref(), + )?; + match format { + Format::Json => { + let items: Vec = results + .into_iter() + .map(|(src, dst, t)| { + let typ = t + .as_ref() + .map(|s| format!("\"{}\"", s)) + .unwrap_or_else(|| "null".into()); + format!( + "{{\"from\":\"{}\",\"to\":\"{}\",\"type\":{}}}", + src, dst, typ + ) + }) + .collect(); + println!("[{}]", items.join(",")); + } + Format::Text => { + for (src, dst, t) in results { + if let Some(t) = t { + println!("{} → {} [type='{}']", src, dst, t); + } else { + println!("{} → {}", src, dst); + } + } + } + } + } + LinkCmd::Backlinks(args) => { + let results = db::find_backlinks(conn, &args.pattern)?; + match format { + Format::Json => { + let items: Vec = results + .into_iter() + .map(|(src, t)| { + let typ = t + .as_ref() + .map(|s| format!("\"{}\"", s)) + .unwrap_or_else(|| "null".into()); + format!("{{\"from\":\"{}\",\"type\":{}}}", src, typ) + }) + .collect(); + println!("[{}]", items.join(",")); + } + Format::Text => { + for (src, t) in results { + if let Some(t) = t { + println!("{} [type='{}']", src, t); + } else { + println!("{}", src); + } + } + } + } + } } + + Ok(()) } diff --git a/src/db/migrations/0003_create_links_collections_views.sql b/src/db/migrations/0003_create_links_collections_views.sql new file mode 100644 index 0000000..7ffca89 --- /dev/null +++ b/src/db/migrations/0003_create_links_collections_views.sql @@ -0,0 +1,28 @@ +PRAGMA foreign_keys = ON; + +-- File-to-file links +CREATE TABLE IF NOT EXISTS links ( + id INTEGER PRIMARY KEY, + src_file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + dst_file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + type TEXT, + UNIQUE(src_file_id, dst_file_id, type) +); + +-- Named collections +CREATE TABLE IF NOT EXISTS collections ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); +CREATE TABLE IF NOT EXISTS collection_files ( + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + PRIMARY KEY(collection_id, file_id) +); + +-- Saved views +CREATE TABLE IF NOT EXISTS views ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + query TEXT NOT NULL +); diff --git a/src/db/mod.rs b/src/db/mod.rs index 2e8286b..85c69bd 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,4 +1,3 @@ -// src/db/mod.rs use std::{ fs, path::{Path, PathBuf}, @@ -19,6 +18,7 @@ use tracing::{debug, info}; const MIGRATIONS: &[(&str, &str)] = &[ ("0001_initial_schema.sql", include_str!("migrations/0001_initial_schema.sql")), ("0002_update_fts_and_triggers.sql", include_str!("migrations/0002_update_fts_and_triggers.sql")), + ("0003_create_links_collections_views.sql", include_str!("migrations/0003_create_links_collections_views.sql")), ]; /* ─── connection bootstrap ──────────────────────────────────────────── */ @@ -128,6 +128,99 @@ pub fn upsert_attr(conn: &Connection, file_id: i64, key: &str, value: &str) -> R Ok(()) } +/// Add a typed link from one file to another. +pub fn add_link(conn: &Connection, src_file_id: i64, dst_file_id: i64, link_type: Option<&str>) -> Result<()> { + conn.execute( + "INSERT INTO links(src_file_id, dst_file_id, type) + VALUES (?1, ?2, ?3) + ON CONFLICT(src_file_id, dst_file_id, type) DO NOTHING", + params![src_file_id, dst_file_id, link_type], + )?; + Ok(()) +} + +/// Remove a typed link between two files. +pub fn remove_link(conn: &Connection, src_file_id: i64, dst_file_id: i64, link_type: Option<&str>) -> Result<()> { + conn.execute( + "DELETE FROM links + WHERE src_file_id = ?1 + AND dst_file_id = ?2 + AND (type IS ?3 OR type = ?3)", + params![src_file_id, dst_file_id, link_type], + )?; + Ok(()) +} + +/// List all links for files matching a glob-style pattern. +/// `direction` may be `"in"` (incoming), `"out"` (outgoing), or `None` (outgoing). +pub fn list_links( + conn: &Connection, + pattern: &str, + direction: Option<&str>, + link_type: Option<&str>, +) -> Result)>> { + // Convert glob '*' → SQL LIKE '%' + let like_pattern = pattern.replace('*', "%"); + + // Find matching files + let mut stmt = conn.prepare("SELECT id, path FROM files WHERE path LIKE ?1")?; + let mut rows = stmt.query(params![like_pattern])?; + let mut files = Vec::new(); + while let Some(row) = rows.next()? { + let id: i64 = row.get(0)?; + let path: String = row.get(1)?; + files.push((id, path)); + } + + let mut results = Vec::new(); + for (file_id, file_path) in files { + let (src_col, dst_col) = match direction { + Some("in") => ("dst_file_id", "src_file_id"), + _ => ("src_file_id", "dst_file_id"), + }; + + let sql = format!( + "SELECT f2.path, l.type + FROM links l + JOIN files f2 ON f2.id = l.{dst} + WHERE l.{src} = ?1 + AND (?2 IS NULL OR l.type = ?2)", + src = src_col, + dst = dst_col, + ); + + let mut stmt2 = conn.prepare(&sql)?; + let mut rows2 = stmt2.query(params![file_id, link_type])?; + while let Some(r2) = rows2.next()? { + let other: String = r2.get(0)?; + let typ: Option = r2.get(1)?; + results.push((file_path.clone(), other, typ)); + } + } + + Ok(results) +} + +/// Find all incoming links (backlinks) to files matching a pattern. +pub fn find_backlinks(conn: &Connection, pattern: &str) -> Result)>> { + let like_pattern = pattern.replace('*', "%"); + let mut stmt = conn.prepare( + "SELECT f1.path, l.type + FROM links l + JOIN files f1 ON f1.id = l.src_file_id + JOIN files f2 ON f2.id = l.dst_file_id + WHERE f2.path LIKE ?1", + )?; + let mut rows = stmt.query(params![like_pattern])?; + let mut result = Vec::new(); + while let Some(row) = rows.next()? { + let src_path: String = row.get(0)?; + let typ: Option = row.get(1)?; + result.push((src_path, typ)); + } + Ok(result) +} + /* ─── backup / restore ──────────────────────────────────────────────── */ pub fn backup>(db_path: P) -> Result { @@ -153,3 +246,14 @@ pub fn restore>(backup_path: P, live_db_path: P) -> Result<()> { fs::copy(&backup_path, &live_db_path)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn migrations_apply_in_memory() { + // Opening an in-memory database should apply every migration without error. + let _conn = open(":memory:").expect("in-memory migrations should run cleanly"); + } +} diff --git a/src/main.rs b/src/main.rs index b648bf2..d9b8050 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,7 +29,6 @@ fn main() -> Result<()> { // If the user asked for completions, generate and exit immediately. if let Commands::Completions { shell } = &args.command { let mut cmd = Cli::command(); - // Shell is Copy so we can deref it safely generate(*shell, &mut cmd, "marlin", &mut io::stdout()); return Ok(()); } @@ -50,9 +49,7 @@ fn main() -> Result<()> { // Dispatch all commands match args.command { - Commands::Completions { .. } => { - // no-op, already handled above - } + Commands::Completions { .. } => {} Commands::Init => { info!("Database initialised at {}", cfg.db_path.display()); } @@ -93,7 +90,6 @@ fn main() -> Result<()> { .with_context(|| format!("Could not open restored DB at {}", cfg.db_path.display()))?; info!("Successfully opened restored database."); } - // new domains delegate to their run() functions Commands::Link(link_cmd) => cli::link::run(&link_cmd, &mut conn, args.format)?, Commands::Coll(coll_cmd) => cli::coll::run(&coll_cmd, &mut conn, args.format)?, Commands::View(view_cmd) => cli::view::run(&view_cmd, &mut conn, args.format)?, @@ -221,6 +217,8 @@ fn attr_ls(conn: &rusqlite::Connection, path: &std::path::Path) -> Result<()> { } /// Build and run an FTS5 search query, with optional exec. +/// Now splits “tag:foo/bar” into `tags_text:foo AND tags_text:bar` +/// and “attr:key=value” into `attrs_text:key AND attrs_text:value`. fn run_search(conn: &rusqlite::Connection, raw_query: &str, exec: Option) -> Result<()> { let mut fts_query_parts = Vec::new(); let parts = shlex::split(raw_query).unwrap_or_else(|| vec![raw_query.to_string()]); @@ -228,9 +226,21 @@ fn run_search(conn: &rusqlite::Connection, raw_query: &str, exec: Option if ["AND", "OR", "NOT"].contains(&part.as_str()) { fts_query_parts.push(part); } else if let Some(tag) = part.strip_prefix("tag:") { - fts_query_parts.push(format!("tags_text:{}", escape_fts_query_term(tag))); + let segments: Vec<&str> = tag.split('/').filter(|s| !s.is_empty()).collect(); + for (i, seg) in segments.iter().enumerate() { + if i > 0 { + fts_query_parts.push("AND".into()); + } + fts_query_parts.push(format!("tags_text:{}", escape_fts_query_term(seg))); + } } else if let Some(attr) = part.strip_prefix("attr:") { - fts_query_parts.push(format!("attrs_text:{}", escape_fts_query_term(attr))); + if let Some((k, v)) = attr.split_once('=') { + fts_query_parts.push(format!("attrs_text:{}", escape_fts_query_term(k))); + fts_query_parts.push("AND".into()); + fts_query_parts.push(format!("attrs_text:{}", escape_fts_query_term(v))); + } else { + fts_query_parts.push(format!("attrs_text:{}", escape_fts_query_term(attr))); + } } else { fts_query_parts.push(escape_fts_query_term(&part)); } @@ -302,7 +312,7 @@ fn run_search(conn: &rusqlite::Connection, raw_query: &str, exec: Option /// Quote terms for FTS when needed. fn escape_fts_query_term(term: &str) -> String { if term.contains(|c: char| c.is_whitespace() || "-:()\"".contains(c)) - || ["AND","OR","NOT","NEAR"].contains(&term.to_uppercase().as_str()) + || ["AND", "OR", "NOT", "NEAR"].contains(&term.to_uppercase().as_str()) { format!("\"{}\"", term.replace('"', "\"\"")) } else { @@ -315,7 +325,7 @@ fn determine_scan_root(pattern: &str) -> PathBuf { let wildcard_pos = pattern.find(|c| c == '*' || c == '?' || c == '[').unwrap_or(pattern.len()); let prefix = &pattern[..wildcard_pos]; let mut root = PathBuf::from(prefix); - while root.as_os_str().to_string_lossy().contains(|c| ['*','?','['].contains(&c)) { + while root.as_os_str().to_string_lossy().contains(|c| ['*', '?', '['].contains(&c)) { if let Some(parent) = root.parent() { root = parent.to_path_buf(); } else { diff --git a/target/release/marlin b/target/release/marlin index a0b1a83..b5ccecb 100755 Binary files a/target/release/marlin and b/target/release/marlin differ diff --git a/target/release/marlin.d b/target/release/marlin.d index 2ef8e0d..e155977 100644 --- a/target/release/marlin.d +++ b/target/release/marlin.d @@ -1 +1 @@ -/home/user/Documents/GitHub/Marlin/target/release/marlin: /home/user/Documents/GitHub/Marlin/src/cli/annotate.rs /home/user/Documents/GitHub/Marlin/src/cli/coll.rs /home/user/Documents/GitHub/Marlin/src/cli/event.rs /home/user/Documents/GitHub/Marlin/src/cli/link.rs /home/user/Documents/GitHub/Marlin/src/cli/remind.rs /home/user/Documents/GitHub/Marlin/src/cli/state.rs /home/user/Documents/GitHub/Marlin/src/cli/task.rs /home/user/Documents/GitHub/Marlin/src/cli/version.rs /home/user/Documents/GitHub/Marlin/src/cli/view.rs /home/user/Documents/GitHub/Marlin/src/cli.rs /home/user/Documents/GitHub/Marlin/src/config.rs /home/user/Documents/GitHub/Marlin/src/db/migrations/0001_initial_schema.sql /home/user/Documents/GitHub/Marlin/src/db/migrations/0002_update_fts_and_triggers.sql /home/user/Documents/GitHub/Marlin/src/db/mod.rs /home/user/Documents/GitHub/Marlin/src/logging.rs /home/user/Documents/GitHub/Marlin/src/main.rs /home/user/Documents/GitHub/Marlin/src/scan.rs +/home/user/Documents/GitHub/Marlin/target/release/marlin: /home/user/Documents/GitHub/Marlin/src/cli/annotate.rs /home/user/Documents/GitHub/Marlin/src/cli/coll.rs /home/user/Documents/GitHub/Marlin/src/cli/event.rs /home/user/Documents/GitHub/Marlin/src/cli/link.rs /home/user/Documents/GitHub/Marlin/src/cli/remind.rs /home/user/Documents/GitHub/Marlin/src/cli/state.rs /home/user/Documents/GitHub/Marlin/src/cli/task.rs /home/user/Documents/GitHub/Marlin/src/cli/version.rs /home/user/Documents/GitHub/Marlin/src/cli/view.rs /home/user/Documents/GitHub/Marlin/src/cli.rs /home/user/Documents/GitHub/Marlin/src/config.rs /home/user/Documents/GitHub/Marlin/src/db/migrations/0001_initial_schema.sql /home/user/Documents/GitHub/Marlin/src/db/migrations/0002_update_fts_and_triggers.sql /home/user/Documents/GitHub/Marlin/src/db/migrations/0003_create_links_collections_views.sql /home/user/Documents/GitHub/Marlin/src/db/mod.rs /home/user/Documents/GitHub/Marlin/src/logging.rs /home/user/Documents/GitHub/Marlin/src/main.rs /home/user/Documents/GitHub/Marlin/src/scan.rs