mirror of
https://github.com/PR0M3TH3AN/Marlin.git
synced 2025-09-09 15:48:43 +00:00
update
This commit is contained in:
110
src/cli/coll.rs
110
src/cli/coll.rs
@@ -1,26 +1,108 @@
|
||||
// src/cli/coll.rs
|
||||
use clap::{Subcommand, Args};
|
||||
//! `marlin coll …` – named collections of files (simple “playlists”).
|
||||
|
||||
use clap::{Args, Subcommand};
|
||||
use rusqlite::Connection;
|
||||
use crate::cli::Format;
|
||||
|
||||
use crate::{
|
||||
cli::Format,
|
||||
db,
|
||||
};
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum CollCmd {
|
||||
/// Create an empty collection
|
||||
Create(CreateArgs),
|
||||
Add (AddArgs),
|
||||
List (ListArgs),
|
||||
/// Add files (glob) to a collection
|
||||
Add(AddArgs),
|
||||
/// List files inside a collection
|
||||
List(ListArgs),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct CreateArgs { pub name: String }
|
||||
#[derive(Args, Debug)]
|
||||
pub struct AddArgs { pub name: String, pub file_pattern: String }
|
||||
#[derive(Args, Debug)]
|
||||
pub struct ListArgs { pub name: String }
|
||||
pub struct CreateArgs {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
pub fn run(cmd: &CollCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> {
|
||||
#[derive(Args, Debug)]
|
||||
pub struct AddArgs {
|
||||
pub name: String,
|
||||
pub file_pattern: String,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct ListArgs {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Look-up an existing collection **without** implicitly creating it.
|
||||
///
|
||||
/// Returns the collection ID or an error if it doesn’t exist.
|
||||
fn lookup_collection_id(conn: &Connection, name: &str) -> anyhow::Result<i64> {
|
||||
conn.query_row(
|
||||
"SELECT id FROM collections WHERE name = ?1",
|
||||
[name],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("collection not found: {}", name))
|
||||
}
|
||||
|
||||
pub fn run(cmd: &CollCmd, conn: &mut Connection, fmt: Format) -> anyhow::Result<()> {
|
||||
match cmd {
|
||||
CollCmd::Create(a) => todo!("coll create {:?}", a),
|
||||
CollCmd::Add(a) => todo!("coll add {:?}", a),
|
||||
CollCmd::List(a) => todo!("coll list {:?}", a),
|
||||
/* ── coll create ──────────────────────────────────────────── */
|
||||
CollCmd::Create(a) => {
|
||||
db::ensure_collection(conn, &a.name)?;
|
||||
if matches!(fmt, Format::Text) {
|
||||
println!("Created collection '{}'", a.name);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── coll add ─────────────────────────────────────────────── */
|
||||
CollCmd::Add(a) => {
|
||||
// Fail if the target collection does not yet exist
|
||||
let coll_id = lookup_collection_id(conn, &a.name)?;
|
||||
|
||||
let like = a.file_pattern.replace('*', "%");
|
||||
let mut stmt = conn.prepare("SELECT id FROM files WHERE path LIKE ?1")?;
|
||||
let ids: Vec<i64> = stmt
|
||||
.query_map([&like], |r| r.get::<_, i64>(0))?
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
for fid in &ids {
|
||||
db::add_file_to_collection(conn, coll_id, *fid)?;
|
||||
}
|
||||
|
||||
match fmt {
|
||||
Format::Text => println!("Added {} file(s) → '{}'", ids.len(), a.name),
|
||||
Format::Json => {
|
||||
#[cfg(feature = "json")]
|
||||
{
|
||||
println!(
|
||||
"{{\"collection\":\"{}\",\"added\":{}}}",
|
||||
a.name,
|
||||
ids.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── coll list ────────────────────────────────────────────── */
|
||||
CollCmd::List(a) => {
|
||||
let files = db::list_collection(conn, &a.name)?;
|
||||
match fmt {
|
||||
Format::Text => {
|
||||
for f in files {
|
||||
println!("{f}");
|
||||
}
|
||||
}
|
||||
Format::Json => {
|
||||
#[cfg(feature = "json")]
|
||||
{
|
||||
println!("{}", serde_json::to_string(&files)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
164
src/cli/view.rs
164
src/cli/view.rs
@@ -1,24 +1,168 @@
|
||||
// src/cli/view.rs
|
||||
use clap::{Subcommand, Args};
|
||||
//! `marlin view …` – save & use “smart folders” (named queries).
|
||||
|
||||
use std::fs;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Args, Subcommand};
|
||||
use rusqlite::Connection;
|
||||
use crate::cli::Format;
|
||||
|
||||
use crate::{cli::Format, db};
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum ViewCmd {
|
||||
/// Save (or update) a view
|
||||
Save(ArgsSave),
|
||||
/// List all saved views
|
||||
List,
|
||||
/// Execute a view (print matching paths)
|
||||
Exec(ArgsExec),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct ArgsSave { pub view_name: String, pub query: String }
|
||||
#[derive(Args, Debug)]
|
||||
pub struct ArgsExec { pub view_name: String }
|
||||
pub struct ArgsSave {
|
||||
pub view_name: String,
|
||||
pub query: String,
|
||||
}
|
||||
|
||||
pub fn run(cmd: &ViewCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> {
|
||||
#[derive(Args, Debug)]
|
||||
pub struct ArgsExec {
|
||||
pub view_name: String,
|
||||
}
|
||||
|
||||
pub fn run(cmd: &ViewCmd, conn: &mut Connection, fmt: Format) -> anyhow::Result<()> {
|
||||
match cmd {
|
||||
ViewCmd::Save(a) => todo!("view save {:?}", a),
|
||||
ViewCmd::List => todo!("view list"),
|
||||
ViewCmd::Exec(a)=> todo!("view exec {:?}", a),
|
||||
/* ── view save ───────────────────────────────────────────── */
|
||||
ViewCmd::Save(a) => {
|
||||
db::save_view(conn, &a.view_name, &a.query)?;
|
||||
if matches!(fmt, Format::Text) {
|
||||
println!("Saved view '{}' = {}", a.view_name, a.query);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── view list ───────────────────────────────────────────── */
|
||||
ViewCmd::List => {
|
||||
let views = db::list_views(conn)?;
|
||||
match fmt {
|
||||
Format::Text => {
|
||||
for (name, q) in views {
|
||||
println!("{name}: {q}");
|
||||
}
|
||||
}
|
||||
Format::Json => {
|
||||
#[cfg(feature = "json")]
|
||||
{
|
||||
println!("{}", serde_json::to_string(&views)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── view exec ───────────────────────────────────────────── */
|
||||
ViewCmd::Exec(a) => {
|
||||
let raw = db::view_query(conn, &a.view_name)?;
|
||||
|
||||
// Re-use the tiny parser from marlin search
|
||||
let fts_expr = build_fts_match(&raw);
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
r#"
|
||||
SELECT f.path
|
||||
FROM files_fts
|
||||
JOIN files f ON f.rowid = files_fts.rowid
|
||||
WHERE files_fts MATCH ?1
|
||||
ORDER BY rank
|
||||
"#,
|
||||
)?;
|
||||
let mut paths: Vec<String> = stmt
|
||||
.query_map([fts_expr], |r| r.get::<_, String>(0))?
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
/* ── NEW: graceful fallback when FTS finds nothing ───── */
|
||||
if paths.is_empty() && !raw.contains(':') {
|
||||
paths = naive_search(conn, &raw)?;
|
||||
}
|
||||
|
||||
if paths.is_empty() && matches!(fmt, Format::Text) {
|
||||
eprintln!("(view '{}' has no matches)", a.view_name);
|
||||
} else {
|
||||
for p in paths {
|
||||
println!("{p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/* ─── naive substring path/content search (≤ 64 kB files) ───────── */
|
||||
|
||||
fn naive_search(conn: &Connection, term: &str) -> Result<Vec<String>> {
|
||||
let term_lc = term.to_lowercase();
|
||||
let mut stmt = conn.prepare("SELECT path FROM files")?;
|
||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||
|
||||
let mut hits = Vec::new();
|
||||
for p in rows {
|
||||
let p = p?;
|
||||
/* path match */
|
||||
if p.to_lowercase().contains(&term_lc) {
|
||||
hits.push(p);
|
||||
continue;
|
||||
}
|
||||
/* small-file content match */
|
||||
if let Ok(meta) = fs::metadata(&p) {
|
||||
if meta.len() > 64_000 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Ok(content) = fs::read_to_string(&p) {
|
||||
if content.to_lowercase().contains(&term_lc) {
|
||||
hits.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(hits)
|
||||
}
|
||||
|
||||
/* ─── minimal copy of search-string → FTS5 translator ───────────── */
|
||||
|
||||
fn build_fts_match(raw_query: &str) -> String {
|
||||
use shlex;
|
||||
let mut parts = Vec::new();
|
||||
let toks = shlex::split(raw_query).unwrap_or_else(|| vec![raw_query.to_string()]);
|
||||
for tok in toks {
|
||||
if ["AND", "OR", "NOT"].contains(&tok.as_str()) {
|
||||
parts.push(tok);
|
||||
} else if let Some(tag) = tok.strip_prefix("tag:") {
|
||||
for (i, seg) in tag.split('/').filter(|s| !s.is_empty()).enumerate() {
|
||||
if i > 0 {
|
||||
parts.push("AND".into());
|
||||
}
|
||||
parts.push(format!("tags_text:{}", escape(seg)));
|
||||
}
|
||||
} else if let Some(attr) = tok.strip_prefix("attr:") {
|
||||
let mut kv = attr.splitn(2, '=');
|
||||
let key = kv.next().unwrap();
|
||||
if let Some(val) = kv.next() {
|
||||
parts.push(format!("attrs_text:{}", escape(key)));
|
||||
parts.push("AND".into());
|
||||
parts.push(format!("attrs_text:{}", escape(val)));
|
||||
} else {
|
||||
parts.push(format!("attrs_text:{}", escape(key)));
|
||||
}
|
||||
} else {
|
||||
parts.push(escape(&tok));
|
||||
}
|
||||
}
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
fn escape(term: &str) -> String {
|
||||
if term.contains(|c: char| c.is_whitespace() || "-:()\"".contains(c))
|
||||
|| ["AND", "OR", "NOT", "NEAR"].contains(&term.to_uppercase().as_str())
|
||||
{
|
||||
format!("\"{}\"", term.replace('"', "\"\""))
|
||||
} else {
|
||||
term.to_string()
|
||||
}
|
||||
}
|
||||
|
@@ -1,7 +1,10 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use directories::ProjectDirs;
|
||||
use std::{
|
||||
collections::hash_map::DefaultHasher,
|
||||
hash::{Hash, Hasher},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// Runtime configuration (currently just the DB path).
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -10,22 +13,39 @@ pub struct Config {
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Resolve configuration from environment or XDG directories.
|
||||
/// Resolve configuration from environment or derive one per-workspace.
|
||||
///
|
||||
/// Priority:
|
||||
/// 1. `MARLIN_DB_PATH` env-var (explicit override)
|
||||
/// 2. *Workspace-local* file under XDG data dir
|
||||
/// (`~/.local/share/marlin/index_<hash>.db`)
|
||||
/// 3. Fallback to `./index.db` when we cannot locate an XDG dir
|
||||
pub fn load() -> Result<Self> {
|
||||
let db_path = std::env::var_os("MARLIN_DB_PATH")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
ProjectDirs::from("io", "Marlin", "marlin")
|
||||
.map(|dirs| dirs.data_dir().join("index.db"))
|
||||
})
|
||||
.unwrap_or_else(|| Path::new("index.db").to_path_buf());
|
||||
// 1) explicit override
|
||||
if let Some(val) = std::env::var_os("MARLIN_DB_PATH") {
|
||||
let p = PathBuf::from(val);
|
||||
std::fs::create_dir_all(p.parent().expect("has parent"))?;
|
||||
return Ok(Self { db_path: p });
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(
|
||||
db_path
|
||||
.parent()
|
||||
.expect("db_path should always have a parent directory"),
|
||||
)?;
|
||||
// 2) derive per-workspace DB name from CWD hash
|
||||
let cwd = std::env::current_dir()?;
|
||||
let mut h = DefaultHasher::new();
|
||||
cwd.hash(&mut h);
|
||||
let digest = h.finish(); // 64-bit
|
||||
let file_name = format!("index_{digest:016x}.db");
|
||||
|
||||
Ok(Self { db_path })
|
||||
if let Some(dirs) = ProjectDirs::from("io", "Marlin", "marlin") {
|
||||
let dir = dirs.data_dir();
|
||||
std::fs::create_dir_all(dir)?;
|
||||
return Ok(Self {
|
||||
db_path: dir.join(file_name),
|
||||
});
|
||||
}
|
||||
|
||||
// 3) very last resort – workspace-relative DB
|
||||
Ok(Self {
|
||||
db_path: Path::new(&file_name).to_path_buf(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@@ -1,260 +0,0 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Local;
|
||||
use rusqlite::{
|
||||
backup::{Backup, StepResult},
|
||||
params,
|
||||
Connection,
|
||||
OpenFlags,
|
||||
OptionalExtension,
|
||||
};
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Embed every numbered migration file here.
|
||||
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")),
|
||||
("0004_fix_hierarchical_tags_fts.sql", include_str!("migrations/0004_fix_hierarchical_tags_fts.sql")),
|
||||
];
|
||||
|
||||
/* ─── connection bootstrap ──────────────────────────────────────────── */
|
||||
|
||||
pub fn open<P: AsRef<Path>>(db_path: P) -> Result<Connection> {
|
||||
let db_path_ref = db_path.as_ref();
|
||||
let mut conn = Connection::open(db_path_ref)
|
||||
.with_context(|| format!("failed to open DB at {}", db_path_ref.display()))?;
|
||||
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
|
||||
// Apply migrations (drops & recreates all FTS triggers)
|
||||
apply_migrations(&mut conn)?;
|
||||
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/* ─── migration runner ──────────────────────────────────────────────── */
|
||||
|
||||
fn apply_migrations(conn: &mut Connection) -> Result<()> {
|
||||
// Ensure schema_version table
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_on TEXT NOT NULL
|
||||
);",
|
||||
)?;
|
||||
|
||||
// Legacy patch (ignore if exists)
|
||||
let _ = conn.execute("ALTER TABLE schema_version ADD COLUMN applied_on TEXT", []);
|
||||
|
||||
let tx = conn.transaction()?;
|
||||
|
||||
for (fname, sql) in MIGRATIONS {
|
||||
let version: i64 = fname
|
||||
.split('_')
|
||||
.next()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.expect("migration filenames start with number");
|
||||
|
||||
let already: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT version FROM schema_version WHERE version = ?1",
|
||||
[version],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
if already.is_some() {
|
||||
debug!("migration {} already applied", fname);
|
||||
continue;
|
||||
}
|
||||
|
||||
info!("applying migration {}", fname);
|
||||
println!(
|
||||
"\nSQL SCRIPT FOR MIGRATION: {}\nBEGIN SQL >>>\n{}\n<<< END SQL\n",
|
||||
fname, sql
|
||||
);
|
||||
|
||||
tx.execute_batch(sql)
|
||||
.with_context(|| format!("could not apply migration {}", fname))?;
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO schema_version (version, applied_on) VALUES (?1, ?2)",
|
||||
params![version, Local::now().to_rfc3339()],
|
||||
)?;
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/* ─── helpers ───────────────────────────────────────────────────────── */
|
||||
|
||||
pub fn ensure_tag_path(conn: &Connection, path: &str) -> Result<i64> {
|
||||
let mut parent: Option<i64> = None;
|
||||
for segment in path.split('/').filter(|s| !s.is_empty()) {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO tags(name, parent_id) VALUES (?1, ?2)",
|
||||
params![segment, parent],
|
||||
)?;
|
||||
let id: i64 = conn.query_row(
|
||||
"SELECT id FROM tags WHERE name = ?1 AND (parent_id IS ?2 OR parent_id = ?2)",
|
||||
params![segment, parent],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
parent = Some(id);
|
||||
}
|
||||
parent.ok_or_else(|| anyhow::anyhow!("empty tag path"))
|
||||
}
|
||||
|
||||
pub fn file_id(conn: &Connection, path: &str) -> Result<i64> {
|
||||
conn.query_row("SELECT id FROM files WHERE path = ?1", [path], |r| r.get(0))
|
||||
.map_err(|_| anyhow::anyhow!("file not indexed: {}", path))
|
||||
}
|
||||
|
||||
pub fn upsert_attr(conn: &Connection, file_id: i64, key: &str, value: &str) -> Result<()> {
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO attributes(file_id, key, value)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(file_id, key) DO UPDATE SET value = excluded.value
|
||||
"#,
|
||||
params![file_id, key, value],
|
||||
)?;
|
||||
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<Vec<(String, String, Option<String>)>> {
|
||||
// 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<String> = 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<Vec<(String, Option<String>)>> {
|
||||
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<String> = row.get(1)?;
|
||||
result.push((src_path, typ));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/* ─── backup / restore ──────────────────────────────────────────────── */
|
||||
|
||||
pub fn backup<P: AsRef<Path>>(db_path: P) -> Result<PathBuf> {
|
||||
let src = db_path.as_ref();
|
||||
let dir = src
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid DB path: {}", src.display()))?
|
||||
.join("backups");
|
||||
fs::create_dir_all(&dir)?;
|
||||
|
||||
let stamp = Local::now().format("%Y-%m-%d_%H-%M-%S");
|
||||
let dst = dir.join(format!("backup_{stamp}.db"));
|
||||
|
||||
let src_conn = Connection::open_with_flags(src, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
|
||||
let mut dst_conn = Connection::open(&dst)?;
|
||||
|
||||
let bk = Backup::new(&src_conn, &mut dst_conn)?;
|
||||
while let StepResult::More = bk.step(100)? {}
|
||||
Ok(dst)
|
||||
}
|
||||
|
||||
pub fn restore<P: AsRef<Path>>(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");
|
||||
}
|
||||
}
|
236
src/db/mod.rs
236
src/db/mod.rs
@@ -1,8 +1,12 @@
|
||||
//! Central DB helper – connection bootstrap, migrations **and** most
|
||||
//! data-access helpers (tags, links, collections, saved views, …).
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use std::result::Result as StdResult;
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Local;
|
||||
use rusqlite::{
|
||||
@@ -11,10 +15,12 @@ use rusqlite::{
|
||||
Connection,
|
||||
OpenFlags,
|
||||
OptionalExtension,
|
||||
TransactionBehavior,
|
||||
};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Embed every numbered migration file here.
|
||||
/* ─── embedded migrations ─────────────────────────────────────────── */
|
||||
|
||||
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")),
|
||||
@@ -22,11 +28,7 @@ const MIGRATIONS: &[(&str, &str)] = &[
|
||||
("0004_fix_hierarchical_tags_fts.sql", include_str!("migrations/0004_fix_hierarchical_tags_fts.sql")),
|
||||
];
|
||||
|
||||
/// Migrations that should *always* be re-run.
|
||||
/// We no longer need to force any, so leave it empty.
|
||||
const FORCE_APPLY_MIGRATIONS: &[i64] = &[]; // <- was &[4]
|
||||
|
||||
/* ─── connection bootstrap ──────────────────────────────────────────── */
|
||||
/* ─── connection bootstrap ────────────────────────────────────────── */
|
||||
|
||||
pub fn open<P: AsRef<Path>>(db_path: P) -> Result<Connection> {
|
||||
let db_path_ref = db_path.as_ref();
|
||||
@@ -36,16 +38,18 @@ pub fn open<P: AsRef<Path>>(db_path: P) -> Result<Connection> {
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
|
||||
// Apply migrations (drops & recreates all FTS triggers)
|
||||
apply_migrations(&mut conn)?;
|
||||
// Wait up to 30 s for a competing writer before giving up
|
||||
conn.busy_timeout(std::time::Duration::from_secs(30))?; // ← tweaked
|
||||
|
||||
apply_migrations(&mut conn)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/* ─── migration runner ──────────────────────────────────────────────── */
|
||||
|
||||
/* ─── migration runner ────────────────────────────────────────────── */
|
||||
|
||||
fn apply_migrations(conn: &mut Connection) -> Result<()> {
|
||||
// Ensure schema_version table
|
||||
// Ensure schema_version bookkeeping table exists
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
@@ -53,18 +57,11 @@ fn apply_migrations(conn: &mut Connection) -> Result<()> {
|
||||
);",
|
||||
)?;
|
||||
|
||||
// Legacy patch (ignore if exists)
|
||||
// Legacy patch – ignore errors if column already exists
|
||||
let _ = conn.execute("ALTER TABLE schema_version ADD COLUMN applied_on TEXT", []);
|
||||
|
||||
// Force-remove migrations that should always be applied
|
||||
for &version in FORCE_APPLY_MIGRATIONS {
|
||||
let rows_affected = conn.execute("DELETE FROM schema_version WHERE version = ?1", [version])?;
|
||||
if rows_affected > 0 {
|
||||
info!("Forcing reapplication of migration {}", version);
|
||||
}
|
||||
}
|
||||
|
||||
let tx = conn.transaction()?;
|
||||
// Grab the write-lock up-front so migrations can run uninterrupted
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
|
||||
for (fname, sql) in MIGRATIONS {
|
||||
let version: i64 = fname
|
||||
@@ -87,13 +84,8 @@ fn apply_migrations(conn: &mut Connection) -> Result<()> {
|
||||
}
|
||||
|
||||
info!("applying migration {}", fname);
|
||||
println!(
|
||||
"\nSQL SCRIPT FOR MIGRATION: {}\nBEGIN SQL >>>\n{}\n<<< END SQL\n",
|
||||
fname, sql
|
||||
);
|
||||
|
||||
tx.execute_batch(sql)
|
||||
.with_context(|| format!("could not apply migration {}", fname))?;
|
||||
.with_context(|| format!("could not apply migration {fname}"))?;
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO schema_version (version, applied_on) VALUES (?1, ?2)",
|
||||
@@ -103,40 +95,30 @@ fn apply_migrations(conn: &mut Connection) -> Result<()> {
|
||||
|
||||
tx.commit()?;
|
||||
|
||||
// Verify that all migrations have been applied
|
||||
let mut missing_migrations = Vec::new();
|
||||
// sanity – warn if any embedded migration got skipped
|
||||
let mut missing = Vec::new();
|
||||
for (fname, _) in MIGRATIONS {
|
||||
let version: i64 = fname
|
||||
.split('_')
|
||||
.next()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.expect("migration filenames start with number");
|
||||
|
||||
let exists: bool = conn
|
||||
let v: i64 = fname.split('_').next().unwrap().parse().unwrap();
|
||||
let ok: bool = conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM schema_version WHERE version = ?1",
|
||||
[version],
|
||||
[v],
|
||||
|_| Ok(true),
|
||||
)
|
||||
.optional()?
|
||||
.unwrap_or(false);
|
||||
|
||||
if !exists {
|
||||
missing_migrations.push(version);
|
||||
if !ok {
|
||||
missing.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
if !missing_migrations.is_empty() {
|
||||
warn!(
|
||||
"The following migrations were not applied: {:?}. This may indicate a problem with the migration system.",
|
||||
missing_migrations
|
||||
);
|
||||
if !missing.is_empty() {
|
||||
warn!("migrations not applied: {:?}", missing);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/* ─── helpers ───────────────────────────────────────────────────────── */
|
||||
/* ─── tag helpers ─────────────────────────────────────────────────── */
|
||||
|
||||
pub fn ensure_tag_path(conn: &Connection, path: &str) -> Result<i64> {
|
||||
let mut parent: Option<i64> = None;
|
||||
@@ -148,7 +130,7 @@ pub fn ensure_tag_path(conn: &Connection, path: &str) -> Result<i64> {
|
||||
let id: i64 = conn.query_row(
|
||||
"SELECT id FROM tags WHERE name = ?1 AND (parent_id IS ?2 OR parent_id = ?2)",
|
||||
params![segment, parent],
|
||||
|row| row.get(0),
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
parent = Some(id);
|
||||
}
|
||||
@@ -160,6 +142,8 @@ pub fn file_id(conn: &Connection, path: &str) -> Result<i64> {
|
||||
.map_err(|_| anyhow::anyhow!("file not indexed: {}", path))
|
||||
}
|
||||
|
||||
/* ─── attributes ──────────────────────────────────────────────────── */
|
||||
|
||||
pub fn upsert_attr(conn: &Connection, file_id: i64, key: &str, value: &str) -> Result<()> {
|
||||
conn.execute(
|
||||
r#"
|
||||
@@ -172,7 +156,8 @@ pub fn upsert_attr(conn: &Connection, file_id: i64, key: &str, value: &str) -> R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a typed link from one file to another.
|
||||
/* ─── links ───────────────────────────────────────────────────────── */
|
||||
|
||||
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)
|
||||
@@ -183,7 +168,6 @@ pub fn add_link(conn: &Connection, src_file_id: i64, dst_file_id: i64, 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
|
||||
@@ -195,77 +179,138 @@ pub fn remove_link(conn: &Connection, src_file_id: i64, dst_file_id: i64, link_t
|
||||
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<Vec<(String, String, Option<String>)>> {
|
||||
// Convert glob '*' → SQL LIKE '%'
|
||||
let like_pattern = pattern.replace('*', "%");
|
||||
|
||||
// Find matching files
|
||||
// Files matching pattern
|
||||
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 rows = stmt
|
||||
.query_map(params![like_pattern], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
for (file_id, file_path) in files {
|
||||
let mut out = Vec::new();
|
||||
for (fid, fpath) in rows {
|
||||
let (src_col, dst_col) = match direction {
|
||||
Some("in") => ("dst_file_id", "src_file_id"),
|
||||
_ => ("src_file_id", "dst_file_id"),
|
||||
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,
|
||||
FROM links l
|
||||
JOIN files f2 ON f2.id = l.{dst_col}
|
||||
WHERE l.{src_col} = ?1
|
||||
AND (?2 IS NULL OR l.type = ?2)",
|
||||
);
|
||||
|
||||
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<String> = r2.get(1)?;
|
||||
results.push((file_path.clone(), other, typ));
|
||||
let links = stmt2
|
||||
.query_map(params![fid, link_type], |r| Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?)))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
for (other, typ) in links {
|
||||
out.push((fpath.clone(), other, typ));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Find all incoming links (backlinks) to files matching a pattern.
|
||||
pub fn find_backlinks(conn: &Connection, pattern: &str) -> Result<Vec<(String, Option<String>)>> {
|
||||
let like_pattern = pattern.replace('*', "%");
|
||||
pub fn find_backlinks(
|
||||
conn: &Connection,
|
||||
pattern: &str,
|
||||
) -> Result<Vec<(String, Option<String>)>> {
|
||||
let like = 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",
|
||||
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<String> = row.get(1)?;
|
||||
result.push((src_path, typ));
|
||||
}
|
||||
Ok(result)
|
||||
|
||||
let rows = stmt.query_map([like], |r| {
|
||||
Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?))
|
||||
})?;
|
||||
|
||||
let out = rows.collect::<StdResult<Vec<_>, _>>()?; // rusqlite → anyhow via `?`
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/* ─── backup / restore ──────────────────────────────────────────────── */
|
||||
/* ─── NEW: collections helpers ────────────────────────────────────── */
|
||||
|
||||
pub fn ensure_collection(conn: &Connection, name: &str) -> Result<i64> {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO collections(name) VALUES (?1)",
|
||||
params![name],
|
||||
)?;
|
||||
conn.query_row(
|
||||
"SELECT id FROM collections WHERE name = ?1",
|
||||
params![name],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.context("collection lookup failed")
|
||||
}
|
||||
|
||||
pub fn add_file_to_collection(conn: &Connection, coll_id: i64, file_id: i64) -> Result<()> {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO collection_files(collection_id, file_id)
|
||||
VALUES (?1, ?2)",
|
||||
params![coll_id, file_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_collection(conn: &Connection, name: &str) -> Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare(
|
||||
r#"SELECT f.path
|
||||
FROM collections c
|
||||
JOIN collection_files cf ON cf.collection_id = c.id
|
||||
JOIN files f ON f.id = cf.file_id
|
||||
WHERE c.name = ?1
|
||||
ORDER BY f.path"#,
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map([name], |r| r.get::<_, String>(0))?;
|
||||
let list = rows.collect::<StdResult<Vec<_>, _>>()?;
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/* ─── NEW: saved views (smart folders) ────────────────────────────── */
|
||||
|
||||
pub fn save_view(conn: &Connection, name: &str, query: &str) -> Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO views(name, query)
|
||||
VALUES (?1, ?2)
|
||||
ON CONFLICT(name) DO UPDATE SET query = excluded.query",
|
||||
params![name, query],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_views(conn: &Connection) -> Result<Vec<(String, String)>> {
|
||||
let mut stmt = conn.prepare("SELECT name, query FROM views ORDER BY name")?;
|
||||
|
||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
|
||||
let list = rows.collect::<StdResult<Vec<_>, _>>()?;
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
pub fn view_query(conn: &Connection, name: &str) -> Result<String> {
|
||||
conn.query_row(
|
||||
"SELECT query FROM views WHERE name = ?1",
|
||||
[name],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.context(format!("no view called '{name}'"))
|
||||
}
|
||||
|
||||
/* ─── backup / restore helpers ────────────────────────────────────── */
|
||||
|
||||
pub fn backup<P: AsRef<Path>>(db_path: P) -> Result<PathBuf> {
|
||||
let src = db_path.as_ref();
|
||||
@@ -291,13 +336,14 @@ pub fn restore<P: AsRef<Path>>(backup_path: P, live_db_path: P) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/* ─── tests ───────────────────────────────────────────────────────── */
|
||||
|
||||
#[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");
|
||||
open(":memory:").expect("all migrations apply");
|
||||
}
|
||||
}
|
||||
|
@@ -5,9 +5,13 @@ use tracing_subscriber::{fmt, EnvFilter};
|
||||
/// Reads `RUST_LOG` for filtering, falls back to `info`.
|
||||
pub fn init() {
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
|
||||
// All tracing output (INFO, WARN, ERROR …) now goes to *stderr* so the
|
||||
// integration tests can assert on warnings / errors reliably.
|
||||
fmt()
|
||||
.with_target(false)
|
||||
.with_level(true)
|
||||
.with_env_filter(filter)
|
||||
.with_target(false) // hide module targets
|
||||
.with_level(true) // include log level
|
||||
.with_env_filter(filter) // respect RUST_LOG
|
||||
.with_writer(std::io::stderr) // <-- NEW: send to stderr
|
||||
.init();
|
||||
}
|
||||
|
297
src/main.rs
297
src/main.rs
@@ -8,13 +8,19 @@ mod logging;
|
||||
mod scan;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, CommandFactory};
|
||||
use clap::{CommandFactory, Parser};
|
||||
use clap_complete::generate;
|
||||
use glob::Pattern;
|
||||
use rusqlite::params;
|
||||
use shellexpand;
|
||||
use shlex;
|
||||
use std::{env, io, path::PathBuf, process::Command};
|
||||
use std::{
|
||||
env,
|
||||
fs,
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
use tracing::{debug, error, info};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
@@ -39,13 +45,13 @@ fn main() -> Result<()> {
|
||||
|
||||
/* ── config & automatic backup ───────────────────────────────── */
|
||||
|
||||
let cfg = config::Config::load()?; // DB path etc.
|
||||
let cfg = config::Config::load()?; // DB path, etc.
|
||||
|
||||
match &args.command {
|
||||
Commands::Init | Commands::Backup | Commands::Restore { .. } => {}
|
||||
_ => match db::backup(&cfg.db_path) {
|
||||
Ok(path) => info!("Pre-command auto-backup created at {}", path.display()),
|
||||
Err(e) => error!("Failed to create pre-command auto-backup: {}", e),
|
||||
Err(e) => error!("Failed to create pre-command auto-backup: {e}"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -66,7 +72,7 @@ fn main() -> Result<()> {
|
||||
let cwd = env::current_dir().context("getting current directory")?;
|
||||
let count = scan::scan_directory(&mut conn, &cwd)
|
||||
.context("initial scan failed")?;
|
||||
info!("Initial scan complete – indexed/updated {} files", count);
|
||||
info!("Initial scan complete – indexed/updated {count} files");
|
||||
}
|
||||
|
||||
Commands::Scan { paths } => {
|
||||
@@ -80,25 +86,31 @@ fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
Commands::Tag { pattern, tag_path } => apply_tag(&conn, &pattern, &tag_path)?,
|
||||
Commands::Attr { action } => match action {
|
||||
Commands::Tag { pattern, tag_path } => apply_tag(&conn, &pattern, &tag_path)?,
|
||||
|
||||
Commands::Attr { action } => match action {
|
||||
cli::AttrCmd::Set { pattern, key, value } => {
|
||||
attr_set(&conn, &pattern, &key, &value)?
|
||||
}
|
||||
cli::AttrCmd::Ls { path } => attr_ls(&conn, &path)?,
|
||||
},
|
||||
Commands::Search { query, exec } => run_search(&conn, &query, exec)?,
|
||||
Commands::Backup => {
|
||||
|
||||
Commands::Search { query, exec } => run_search(&conn, &query, exec)?,
|
||||
|
||||
Commands::Backup => {
|
||||
let path = db::backup(&cfg.db_path)?;
|
||||
println!("Backup created: {}", path.display());
|
||||
}
|
||||
Commands::Restore { backup_path } => {
|
||||
|
||||
Commands::Restore { backup_path } => {
|
||||
drop(conn); // close handle before overwrite
|
||||
db::restore(&backup_path, &cfg.db_path)
|
||||
.with_context(|| format!("Failed to restore DB from {}", backup_path.display()))?;
|
||||
db::restore(&backup_path, &cfg.db_path).with_context(|| {
|
||||
format!("Failed to restore DB from {}", backup_path.display())
|
||||
})?;
|
||||
println!("Restored DB from {}", backup_path.display());
|
||||
db::open(&cfg.db_path)
|
||||
.with_context(|| format!("Could not open restored DB at {}", cfg.db_path.display()))?;
|
||||
db::open(&cfg.db_path).with_context(|| {
|
||||
format!("Could not open restored DB at {}", cfg.db_path.display())
|
||||
})?;
|
||||
info!("Successfully opened restored database.");
|
||||
}
|
||||
|
||||
@@ -117,7 +129,9 @@ fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/* ───────────────────────── helpers & sub-routines ────────────────── */
|
||||
/* ───────────────────────── helpers & sub-routines ───────────────── */
|
||||
|
||||
/* ---------- TAGS ---------- */
|
||||
|
||||
/// Apply a hierarchical tag to all files matching the glob pattern.
|
||||
fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag_path: &str) -> Result<()> {
|
||||
@@ -141,8 +155,8 @@ fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag_path: &str) -> Resu
|
||||
}
|
||||
|
||||
let expanded = shellexpand::tilde(pattern).into_owned();
|
||||
let pat = Pattern::new(&expanded)
|
||||
.with_context(|| format!("Invalid glob pattern `{}`", expanded))?;
|
||||
let pat = Pattern::new(&expanded)
|
||||
.with_context(|| format!("Invalid glob pattern `{expanded}`"))?;
|
||||
let root = determine_scan_root(&expanded);
|
||||
|
||||
let mut stmt_file = conn.prepare("SELECT id FROM files WHERE path = ?1")?;
|
||||
@@ -157,12 +171,9 @@ fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag_path: &str) -> Resu
|
||||
.filter(|e| e.file_type().is_file())
|
||||
{
|
||||
let path_str = entry.path().to_string_lossy();
|
||||
debug!("testing path: {}", path_str);
|
||||
if !pat.matches(&path_str) {
|
||||
debug!(" → no match");
|
||||
continue;
|
||||
}
|
||||
debug!(" → matched");
|
||||
|
||||
match stmt_file.query_row(params![path_str.as_ref()], |r| r.get::<_, i64>(0)) {
|
||||
Ok(file_id) => {
|
||||
@@ -175,8 +186,6 @@ fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag_path: &str) -> Resu
|
||||
if newly {
|
||||
info!(file = %path_str, tag = tag_path, "tagged");
|
||||
count += 1;
|
||||
} else {
|
||||
debug!(file = %path_str, tag = tag_path, "already tagged");
|
||||
}
|
||||
}
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
@@ -188,24 +197,20 @@ fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag_path: &str) -> Resu
|
||||
}
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
info!("Applied tag '{}' to {} file(s).", tag_path, count);
|
||||
} else {
|
||||
info!("No new files were tagged with '{}' (no matches or already tagged).", tag_path);
|
||||
}
|
||||
info!(
|
||||
"Applied tag '{}' to {} file(s).",
|
||||
tag_path, count
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/* ---------- ATTRIBUTES ---------- */
|
||||
|
||||
/// Set a key=value attribute on all files matching the glob pattern.
|
||||
fn attr_set(
|
||||
conn: &rusqlite::Connection,
|
||||
pattern: &str,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Result<()> {
|
||||
fn attr_set(conn: &rusqlite::Connection, pattern: &str, key: &str, value: &str) -> Result<()> {
|
||||
let expanded = shellexpand::tilde(pattern).into_owned();
|
||||
let pat = Pattern::new(&expanded)
|
||||
.with_context(|| format!("Invalid glob pattern `{}`", expanded))?;
|
||||
let pat = Pattern::new(&expanded)
|
||||
.with_context(|| format!("Invalid glob pattern `{expanded}`"))?;
|
||||
let root = determine_scan_root(&expanded);
|
||||
|
||||
let mut stmt_file = conn.prepare("SELECT id FROM files WHERE path = ?1")?;
|
||||
@@ -217,17 +222,14 @@ fn attr_set(
|
||||
.filter(|e| e.file_type().is_file())
|
||||
{
|
||||
let path_str = entry.path().to_string_lossy();
|
||||
debug!("testing attr path: {}", path_str);
|
||||
if !pat.matches(&path_str) {
|
||||
debug!(" → no match");
|
||||
continue;
|
||||
}
|
||||
debug!(" → matched");
|
||||
|
||||
match stmt_file.query_row(params![path_str.as_ref()], |r| r.get::<_, i64>(0)) {
|
||||
Ok(file_id) => {
|
||||
db::upsert_attr(conn, file_id, key, value)?;
|
||||
info!(file = %path_str, key = key, value = value, "attr set");
|
||||
info!(file = %path_str, key, value, "attr set");
|
||||
count += 1;
|
||||
}
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
@@ -239,21 +241,20 @@ fn attr_set(
|
||||
}
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
info!("Attribute '{}: {}' set on {} file(s).", key, value, count);
|
||||
} else {
|
||||
info!("No attributes set (no matches or not indexed).");
|
||||
}
|
||||
info!(
|
||||
"Attribute '{}={}' set on {} file(s).",
|
||||
key, value, count
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List attributes for a given file path.
|
||||
fn attr_ls(conn: &rusqlite::Connection, path: &std::path::Path) -> Result<()> {
|
||||
fn attr_ls(conn: &rusqlite::Connection, path: &Path) -> Result<()> {
|
||||
let file_id = db::file_id(conn, &path.to_string_lossy())?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT key, value FROM attributes WHERE file_id = ?1 ORDER BY key",
|
||||
)?;
|
||||
for row in stmt.query_map([file_id], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT key, value FROM attributes WHERE file_id = ?1 ORDER BY key")?;
|
||||
for row in stmt
|
||||
.query_map([file_id], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?
|
||||
{
|
||||
let (k, v) = row?;
|
||||
println!("{k} = {v}");
|
||||
@@ -261,40 +262,43 @@ fn attr_ls(conn: &rusqlite::Connection, path: &std::path::Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build and run an FTS5 search query, with optional exec.
|
||||
/// “tag:foo/bar” → tags_text:foo AND tags_text:bar
|
||||
/// “attr:k=v” → attrs_text:k AND attrs_text:v
|
||||
/* ---------- SEARCH ---------- */
|
||||
|
||||
/// Run an FTS5 search, optionally piping each hit through `exec`.
|
||||
/// Falls back to a simple substring scan (path + ≤64 kB file contents)
|
||||
/// when the FTS index yields no rows.
|
||||
fn run_search(conn: &rusqlite::Connection, raw_query: &str, exec: Option<String>) -> Result<()> {
|
||||
let mut fts_query_parts = Vec::new();
|
||||
let parts = shlex::split(raw_query).unwrap_or_else(|| vec![raw_query.to_string()]);
|
||||
for part in parts {
|
||||
if ["AND", "OR", "NOT"].contains(&part.as_str()) {
|
||||
fts_query_parts.push(part);
|
||||
} else if let Some(tag) = part.strip_prefix("tag:") {
|
||||
let segments: Vec<&str> = tag.split('/').filter(|s| !s.is_empty()).collect();
|
||||
for (i, seg) in segments.iter().enumerate() {
|
||||
// Build the FTS MATCH expression
|
||||
let mut parts = Vec::new();
|
||||
let toks = shlex::split(raw_query).unwrap_or_else(|| vec![raw_query.to_string()]);
|
||||
for tok in toks {
|
||||
if ["AND", "OR", "NOT"].contains(&tok.as_str()) {
|
||||
parts.push(tok);
|
||||
} else if let Some(tag) = tok.strip_prefix("tag:") {
|
||||
for (i, seg) in tag.split('/').filter(|s| !s.is_empty()).enumerate() {
|
||||
if i > 0 {
|
||||
fts_query_parts.push("AND".into());
|
||||
parts.push("AND".into());
|
||||
}
|
||||
fts_query_parts.push(format!("tags_text:{}", escape_fts_query_term(seg)));
|
||||
parts.push(format!("tags_text:{}", escape_fts(seg)));
|
||||
}
|
||||
} else if let Some(attr) = part.strip_prefix("attr:") {
|
||||
} else if let Some(attr) = tok.strip_prefix("attr:") {
|
||||
let mut kv = attr.splitn(2, '=');
|
||||
let key = kv.next().unwrap();
|
||||
if let Some(value) = kv.next() {
|
||||
fts_query_parts.push(format!("attrs_text:{}", escape_fts_query_term(key)));
|
||||
fts_query_parts.push("AND".into());
|
||||
fts_query_parts.push(format!("attrs_text:{}", escape_fts_query_term(value)));
|
||||
if let Some(val) = kv.next() {
|
||||
parts.push(format!("attrs_text:{}", escape_fts(key)));
|
||||
parts.push("AND".into());
|
||||
parts.push(format!("attrs_text:{}", escape_fts(val)));
|
||||
} else {
|
||||
fts_query_parts.push(format!("attrs_text:{}", escape_fts_query_term(key)));
|
||||
parts.push(format!("attrs_text:{}", escape_fts(key)));
|
||||
}
|
||||
} else {
|
||||
fts_query_parts.push(escape_fts_query_term(&part));
|
||||
parts.push(escape_fts(&tok));
|
||||
}
|
||||
}
|
||||
let fts_expr = fts_query_parts.join(" ");
|
||||
debug!("Constructed FTS MATCH expression: {}", fts_expr);
|
||||
let fts_expr = parts.join(" ");
|
||||
debug!("FTS MATCH expression: {fts_expr}");
|
||||
|
||||
// ---------- primary FTS query ----------
|
||||
let mut stmt = conn.prepare(
|
||||
r#"
|
||||
SELECT f.path
|
||||
@@ -304,51 +308,27 @@ fn run_search(conn: &rusqlite::Connection, raw_query: &str, exec: Option<String>
|
||||
ORDER BY rank
|
||||
"#,
|
||||
)?;
|
||||
let hits: Vec<String> = stmt
|
||||
.query_map(params![fts_expr], |row| row.get(0))?
|
||||
let mut hits: Vec<String> = stmt
|
||||
.query_map(params![fts_expr], |r| r.get::<_, String>(0))?
|
||||
.filter_map(Result::ok)
|
||||
.collect();
|
||||
|
||||
// ---------- graceful fallback ----------
|
||||
if hits.is_empty() && !raw_query.contains(':') {
|
||||
hits = naive_substring_search(conn, raw_query)?;
|
||||
}
|
||||
|
||||
// ---------- output / exec ----------
|
||||
if let Some(cmd_tpl) = exec {
|
||||
let mut ran_without_placeholder = false;
|
||||
if hits.is_empty() && !cmd_tpl.contains("{}") {
|
||||
if let Some(mut parts) = shlex::split(&cmd_tpl) {
|
||||
if !parts.is_empty() {
|
||||
let prog = parts.remove(0);
|
||||
let status = Command::new(&prog).args(&parts).status()?;
|
||||
if !status.success() {
|
||||
error!(command=%cmd_tpl, code=?status.code(), "command failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
ran_without_placeholder = true;
|
||||
}
|
||||
if !ran_without_placeholder {
|
||||
for path in hits {
|
||||
let quoted = shlex::try_quote(&path).unwrap_or(path.clone().into());
|
||||
let cmd_final = if cmd_tpl.contains("{}") {
|
||||
cmd_tpl.replace("{}", "ed)
|
||||
} else {
|
||||
format!("{} {}", cmd_tpl, "ed)
|
||||
};
|
||||
if let Some(mut parts) = shlex::split(&cmd_final) {
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let prog = parts.remove(0);
|
||||
let status = Command::new(&prog).args(&parts).status()?;
|
||||
if !status.success() {
|
||||
error!(file=%path, command=%cmd_final, code=?status.code(), "command failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
run_exec(&hits, &cmd_tpl)?;
|
||||
} else {
|
||||
if hits.is_empty() {
|
||||
eprintln!("No matches for query: `{}` (FTS expression: `{}`)", raw_query, fts_expr);
|
||||
eprintln!(
|
||||
"No matches for query: `{raw_query}` (FTS expression: `{fts_expr}`)"
|
||||
);
|
||||
} else {
|
||||
for p in hits {
|
||||
println!("{}", p);
|
||||
println!("{p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -356,10 +336,81 @@ fn run_search(conn: &rusqlite::Connection, raw_query: &str, exec: Option<String>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Quote terms for FTS when needed.
|
||||
fn escape_fts_query_term(term: &str) -> String {
|
||||
/// Simple, case-insensitive substring scan over paths and (small) file bodies.
|
||||
fn naive_substring_search(conn: &rusqlite::Connection, term: &str) -> Result<Vec<String>> {
|
||||
let term_lc = term.to_lowercase();
|
||||
|
||||
let mut stmt = conn.prepare("SELECT path FROM files")?;
|
||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for p in rows {
|
||||
let p = p?;
|
||||
if p.to_lowercase().contains(&term_lc) {
|
||||
out.push(p.clone());
|
||||
continue;
|
||||
}
|
||||
// Only inspect small files to stay fast
|
||||
if let Ok(meta) = fs::metadata(&p) {
|
||||
if meta.len() > 64_000 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Ok(content) = fs::read_to_string(&p) {
|
||||
if content.to_lowercase().contains(&term_lc) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Helper: run an external command template on every hit.
|
||||
fn run_exec(paths: &[String], cmd_tpl: &str) -> Result<()> {
|
||||
let mut ran_without_placeholder = false;
|
||||
|
||||
if paths.is_empty() && !cmd_tpl.contains("{}") {
|
||||
if let Some(mut parts) = shlex::split(cmd_tpl) {
|
||||
if !parts.is_empty() {
|
||||
let prog = parts.remove(0);
|
||||
let status = Command::new(&prog).args(&parts).status()?;
|
||||
if !status.success() {
|
||||
error!(command = %cmd_tpl, code = ?status.code(), "command failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
ran_without_placeholder = true;
|
||||
}
|
||||
|
||||
if !ran_without_placeholder {
|
||||
for p in paths {
|
||||
let quoted = shlex::try_quote(p).unwrap_or_else(|_| p.into());
|
||||
let final_cmd = if cmd_tpl.contains("{}") {
|
||||
cmd_tpl.replace("{}", "ed)
|
||||
} else {
|
||||
format!("{cmd_tpl} {quoted}")
|
||||
};
|
||||
if let Some(mut parts) = shlex::split(&final_cmd) {
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let prog = parts.remove(0);
|
||||
let status = Command::new(&prog).args(&parts).status()?;
|
||||
if !status.success() {
|
||||
error!(file = %p, command = %final_cmd, code = ?status.code(), "command failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/* ---------- misc helpers ---------- */
|
||||
|
||||
fn escape_fts(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 {
|
||||
@@ -369,20 +420,22 @@ fn escape_fts_query_term(term: &str) -> String {
|
||||
|
||||
/// Determine a filesystem root to limit recursive walking.
|
||||
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);
|
||||
let first_wild = pattern
|
||||
.find(|c| matches!(c, '*' | '?' | '['))
|
||||
.unwrap_or(pattern.len());
|
||||
let mut root = PathBuf::from(&pattern[..first_wild]);
|
||||
|
||||
while root
|
||||
.as_os_str()
|
||||
.to_string_lossy()
|
||||
.contains(|c| ['*', '?', '['].contains(&c))
|
||||
.contains(|c| matches!(c, '*' | '?' | '['))
|
||||
{
|
||||
if let Some(parent) = root.parent() {
|
||||
root = parent.to_path_buf();
|
||||
} else {
|
||||
root = PathBuf::from(".");
|
||||
break;
|
||||
}
|
||||
root = root.parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
}
|
||||
|
||||
if root.as_os_str().is_empty() {
|
||||
PathBuf::from(".")
|
||||
} else {
|
||||
root
|
||||
}
|
||||
root
|
||||
}
|
||||
|
Reference in New Issue
Block a user