mirror of
https://github.com/PR0M3TH3AN/Marlin.git
synced 2025-09-09 15:48:43 +00:00
update
This commit is contained in:
127
cli-bin/src/cli.rs
Normal file
127
cli-bin/src/cli.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
// src/cli.rs
|
||||
pub mod link;
|
||||
pub mod coll;
|
||||
pub mod view;
|
||||
pub mod state;
|
||||
pub mod task;
|
||||
pub mod remind;
|
||||
pub mod annotate;
|
||||
pub mod version;
|
||||
pub mod event;
|
||||
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use clap_complete::Shell;
|
||||
|
||||
/// Output format for commands.
|
||||
#[derive(ValueEnum, Clone, Copy, Debug)]
|
||||
pub enum Format {
|
||||
Text,
|
||||
Json,
|
||||
}
|
||||
|
||||
/// Marlin – metadata-driven file explorer (CLI utilities)
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, propagate_version = true)]
|
||||
pub struct Cli {
|
||||
/// Enable debug logging and extra output
|
||||
#[arg(long)]
|
||||
pub verbose: bool,
|
||||
|
||||
/// Output format (text or JSON)
|
||||
#[arg(long, default_value = "text", value_enum, global = true)]
|
||||
pub format: Format,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Commands {
|
||||
/// Initialise the database (idempotent)
|
||||
Init,
|
||||
|
||||
/// Scan one or more directories and populate the file index
|
||||
Scan {
|
||||
/// Directories to scan (defaults to cwd)
|
||||
paths: Vec<std::path::PathBuf>,
|
||||
},
|
||||
|
||||
/// Tag files matching a glob pattern (hierarchical tags use `/`)
|
||||
Tag {
|
||||
/// Glob or path pattern
|
||||
pattern: String,
|
||||
/// Hierarchical tag name (`foo/bar`)
|
||||
tag_path: String,
|
||||
},
|
||||
|
||||
/// Manage custom attributes
|
||||
Attr {
|
||||
#[command(subcommand)]
|
||||
action: AttrCmd,
|
||||
},
|
||||
|
||||
/// Full-text search; `--exec CMD` runs CMD on each hit (`{}` placeholder)
|
||||
Search {
|
||||
query: String,
|
||||
#[arg(long)]
|
||||
exec: Option<String>,
|
||||
},
|
||||
|
||||
/// Create a timestamped backup of the database
|
||||
Backup,
|
||||
|
||||
/// Restore from a backup file (overwrites current DB)
|
||||
Restore {
|
||||
backup_path: std::path::PathBuf,
|
||||
},
|
||||
|
||||
/// Generate shell completions (hidden)
|
||||
#[command(hide = true)]
|
||||
Completions {
|
||||
/// Which shell to generate for
|
||||
#[arg(value_enum)]
|
||||
shell: Shell,
|
||||
},
|
||||
|
||||
/// File-to-file links
|
||||
#[command(subcommand)]
|
||||
Link(link::LinkCmd),
|
||||
|
||||
/// Collections (groups) of files
|
||||
#[command(subcommand)]
|
||||
Coll(coll::CollCmd),
|
||||
|
||||
/// Smart views (saved queries)
|
||||
#[command(subcommand)]
|
||||
View(view::ViewCmd),
|
||||
|
||||
/// Workflow states on files
|
||||
#[command(subcommand)]
|
||||
State(state::StateCmd),
|
||||
|
||||
/// TODO/tasks management
|
||||
#[command(subcommand)]
|
||||
Task(task::TaskCmd),
|
||||
|
||||
/// Reminders on files
|
||||
#[command(subcommand)]
|
||||
Remind(remind::RemindCmd),
|
||||
|
||||
/// File annotations and highlights
|
||||
#[command(subcommand)]
|
||||
Annotate(annotate::AnnotateCmd),
|
||||
|
||||
/// Version diffs
|
||||
#[command(subcommand)]
|
||||
Version(version::VersionCmd),
|
||||
|
||||
/// Calendar events & timelines
|
||||
#[command(subcommand)]
|
||||
Event(event::EventCmd),
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum AttrCmd {
|
||||
Set { pattern: String, key: String, value: String },
|
||||
Ls { path: std::path::PathBuf },
|
||||
}
|
28
cli-bin/src/cli/annotate.rs
Normal file
28
cli-bin/src/cli/annotate.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
// src/cli/annotate.rs
|
||||
use clap::{Subcommand, Args};
|
||||
use rusqlite::Connection;
|
||||
use crate::cli::Format;
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum AnnotateCmd {
|
||||
Add (ArgsAdd),
|
||||
List(ArgsList),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct ArgsAdd {
|
||||
pub file: String,
|
||||
pub note: String,
|
||||
#[arg(long)] pub range: Option<String>,
|
||||
#[arg(long)] pub highlight: bool,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct ArgsList { pub file_pattern: String }
|
||||
|
||||
pub fn run(cmd: &AnnotateCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> {
|
||||
match cmd {
|
||||
AnnotateCmd::Add(a) => todo!("annotate add {:?}", a),
|
||||
AnnotateCmd::List(a) => todo!("annotate list {:?}", a),
|
||||
}
|
||||
}
|
106
cli-bin/src/cli/coll.rs
Normal file
106
cli-bin/src/cli/coll.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
//! `marlin coll …` – named collections of files (simple “playlists”).
|
||||
|
||||
use clap::{Args, Subcommand};
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::cli::Format; // local enum for text / json output
|
||||
use libmarlin::db; // core DB helpers from the library crate
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum CollCmd {
|
||||
/// Create an empty collection
|
||||
Create(CreateArgs),
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/* ── 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(())
|
||||
}
|
81
cli-bin/src/cli/commands.yaml
Normal file
81
cli-bin/src/cli/commands.yaml
Normal file
@@ -0,0 +1,81 @@
|
||||
# cli/commands.yaml
|
||||
# Philosophy: one canonical spec stops drift between docs & code.
|
||||
link:
|
||||
description: "Manage typed relationships between files"
|
||||
actions:
|
||||
add:
|
||||
args: [from, to]
|
||||
flags: ["--type"]
|
||||
rm:
|
||||
args: [from, to]
|
||||
flags: ["--type"]
|
||||
list:
|
||||
args: [pattern]
|
||||
flags: ["--direction", "--type"]
|
||||
backlinks:
|
||||
args: [pattern]
|
||||
|
||||
coll:
|
||||
description: "Manage named collections of files"
|
||||
actions:
|
||||
create:
|
||||
args: [name]
|
||||
add:
|
||||
args: [name, file_pattern]
|
||||
list:
|
||||
args: [name]
|
||||
|
||||
view:
|
||||
description: "Save and use smart views (saved queries)"
|
||||
actions:
|
||||
save:
|
||||
args: [view_name, query]
|
||||
list: {}
|
||||
exec:
|
||||
args: [view_name]
|
||||
|
||||
state:
|
||||
description: "Track workflow states on files"
|
||||
actions:
|
||||
set:
|
||||
args: [file_pattern, new_state]
|
||||
transitions-add:
|
||||
args: [from_state, to_state]
|
||||
log:
|
||||
args: [file_pattern]
|
||||
|
||||
task:
|
||||
description: "Extract TODOs and manage tasks"
|
||||
actions:
|
||||
scan:
|
||||
args: [directory]
|
||||
list:
|
||||
flags: ["--due-today"]
|
||||
|
||||
remind:
|
||||
description: "Attach reminders to files"
|
||||
actions:
|
||||
set:
|
||||
args: [file_pattern, timestamp, message]
|
||||
|
||||
annotate:
|
||||
description: "Add notes or highlights to files"
|
||||
actions:
|
||||
add:
|
||||
args: [file, note]
|
||||
flags: ["--range", "--highlight"]
|
||||
list:
|
||||
args: [file_pattern]
|
||||
|
||||
version:
|
||||
description: "Versioning and diffs"
|
||||
actions:
|
||||
diff:
|
||||
args: [file]
|
||||
|
||||
event:
|
||||
description: "Link files to dates/events"
|
||||
actions:
|
||||
add:
|
||||
args: [file, date, description]
|
||||
timeline: {}
|
24
cli-bin/src/cli/event.rs
Normal file
24
cli-bin/src/cli/event.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
// src/cli/event.rs
|
||||
use clap::{Subcommand, Args};
|
||||
use rusqlite::Connection;
|
||||
use crate::cli::Format;
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum EventCmd {
|
||||
Add (ArgsAdd),
|
||||
Timeline,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct ArgsAdd {
|
||||
pub file: String,
|
||||
pub date: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
pub fn run(cmd: &EventCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> {
|
||||
match cmd {
|
||||
EventCmd::Add(a) => todo!("event add {:?}", a),
|
||||
EventCmd::Timeline => todo!("event timeline"),
|
||||
}
|
||||
}
|
156
cli-bin/src/cli/link.rs
Normal file
156
cli-bin/src/cli/link.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
//! src/cli/link.rs – manage typed relationships between files
|
||||
|
||||
use clap::{Subcommand, Args};
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::cli::Format; // output selector
|
||||
use libmarlin::db; // ← switched from `crate::db`
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum LinkCmd {
|
||||
Add(LinkArgs),
|
||||
Rm (LinkArgs),
|
||||
List(ListArgs),
|
||||
Backlinks(BacklinksArgs),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct LinkArgs {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
#[arg(long)]
|
||||
pub r#type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct ListArgs {
|
||||
pub pattern: String,
|
||||
#[arg(long)]
|
||||
pub direction: Option<String>,
|
||||
#[arg(long)]
|
||||
pub r#type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct BacklinksArgs {
|
||||
pub pattern: String,
|
||||
}
|
||||
|
||||
pub fn run(cmd: &LinkCmd, conn: &mut Connection, format: Format) -> anyhow::Result<()> {
|
||||
match cmd {
|
||||
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<String> = 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<String> = 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(())
|
||||
}
|
22
cli-bin/src/cli/remind.rs
Normal file
22
cli-bin/src/cli/remind.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
// src/cli/remind.rs
|
||||
use clap::{Subcommand, Args};
|
||||
use rusqlite::Connection;
|
||||
use crate::cli::Format;
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum RemindCmd {
|
||||
Set(ArgsSet),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct ArgsSet {
|
||||
pub file_pattern: String,
|
||||
pub timestamp: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub fn run(cmd: &RemindCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> {
|
||||
match cmd {
|
||||
RemindCmd::Set(a) => todo!("remind set {:?}", a),
|
||||
}
|
||||
}
|
26
cli-bin/src/cli/state.rs
Normal file
26
cli-bin/src/cli/state.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
// src/cli/state.rs
|
||||
use clap::{Subcommand, Args};
|
||||
use rusqlite::Connection;
|
||||
use crate::cli::Format;
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum StateCmd {
|
||||
Set(ArgsSet),
|
||||
TransitionsAdd(ArgsTrans),
|
||||
Log(ArgsLog),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct ArgsSet { pub file_pattern: String, pub new_state: String }
|
||||
#[derive(Args, Debug)]
|
||||
pub struct ArgsTrans { pub from_state: String, pub to_state: String }
|
||||
#[derive(Args, Debug)]
|
||||
pub struct ArgsLog { pub file_pattern: String }
|
||||
|
||||
pub fn run(cmd: &StateCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> {
|
||||
match cmd {
|
||||
StateCmd::Set(a) => todo!("state set {:?}", a),
|
||||
StateCmd::TransitionsAdd(a)=> todo!("state transitions-add {:?}", a),
|
||||
StateCmd::Log(a) => todo!("state log {:?}", a),
|
||||
}
|
||||
}
|
22
cli-bin/src/cli/task.rs
Normal file
22
cli-bin/src/cli/task.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
// src/cli/task.rs
|
||||
use clap::{Subcommand, Args};
|
||||
use rusqlite::Connection;
|
||||
use crate::cli::Format;
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum TaskCmd {
|
||||
Scan(ArgsScan),
|
||||
List(ArgsList),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct ArgsScan { pub directory: String }
|
||||
#[derive(Args, Debug)]
|
||||
pub struct ArgsList { #[arg(long)] pub due_today: bool }
|
||||
|
||||
pub fn run(cmd: &TaskCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> {
|
||||
match cmd {
|
||||
TaskCmd::Scan(a) => todo!("task scan {:?}", a),
|
||||
TaskCmd::List(a) => todo!("task list {:?}", a),
|
||||
}
|
||||
}
|
18
cli-bin/src/cli/version.rs
Normal file
18
cli-bin/src/cli/version.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
// src/cli/version.rs
|
||||
use clap::{Subcommand, Args};
|
||||
use rusqlite::Connection;
|
||||
use crate::cli::Format;
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum VersionCmd {
|
||||
Diff(ArgsDiff),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct ArgsDiff { pub file: String }
|
||||
|
||||
pub fn run(cmd: &VersionCmd, _conn: &mut Connection, _format: Format) -> anyhow::Result<()> {
|
||||
match cmd {
|
||||
VersionCmd::Diff(a) => todo!("version diff {:?}", a),
|
||||
}
|
||||
}
|
169
cli-bin/src/cli/view.rs
Normal file
169
cli-bin/src/cli/view.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
//! `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; // output selector stays local
|
||||
use libmarlin::db; // ← path switched from `crate::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 fn run(cmd: &ViewCmd, conn: &mut Connection, fmt: Format) -> anyhow::Result<()> {
|
||||
match cmd {
|
||||
/* ── 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()
|
||||
}
|
||||
}
|
395
cli-bin/src/main.rs
Normal file
395
cli-bin/src/main.rs
Normal file
@@ -0,0 +1,395 @@
|
||||
//! Marlin CLI entry-point (post crate-split)
|
||||
//!
|
||||
//! All heavy lifting now lives in the `libmarlin` crate; this file
|
||||
//! handles argument parsing, logging, orchestration and the few
|
||||
//! helpers that remain CLI-specific.
|
||||
|
||||
#![deny(warnings)]
|
||||
|
||||
mod cli; // sub-command definitions and argument structs
|
||||
|
||||
/* ── shared modules re-exported from libmarlin ─────────────────── */
|
||||
use libmarlin::{
|
||||
config,
|
||||
db,
|
||||
logging,
|
||||
scan,
|
||||
utils::determine_scan_root,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{CommandFactory, Parser};
|
||||
use clap_complete::generate;
|
||||
use glob::Pattern;
|
||||
use shellexpand;
|
||||
use shlex;
|
||||
use std::{
|
||||
env,
|
||||
fs,
|
||||
io,
|
||||
path::Path,
|
||||
process::Command,
|
||||
};
|
||||
use tracing::{debug, error, info};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use cli::{Cli, Commands};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
/* ── CLI parsing & logging ────────────────────────────────── */
|
||||
|
||||
let args = Cli::parse();
|
||||
if args.verbose {
|
||||
env::set_var("RUST_LOG", "debug");
|
||||
}
|
||||
logging::init();
|
||||
|
||||
/* ── shell-completion shortcut ────────────────────────────── */
|
||||
|
||||
if let Commands::Completions { shell } = &args.command {
|
||||
let mut cmd = Cli::command();
|
||||
generate(*shell, &mut cmd, "marlin", &mut io::stdout());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
/* ── config & automatic backup ───────────────────────────── */
|
||||
|
||||
let cfg = config::Config::load()?; // resolves DB path
|
||||
|
||||
match &args.command {
|
||||
Commands::Init | Commands::Backup | Commands::Restore { .. } => {}
|
||||
_ => match db::backup(&cfg.db_path) {
|
||||
Ok(p) => info!("Pre-command auto-backup created at {}", p.display()),
|
||||
Err(e) => error!("Failed to create pre-command auto-backup: {e}"),
|
||||
},
|
||||
}
|
||||
|
||||
/* ── open DB (runs migrations) ───────────────────────────── */
|
||||
|
||||
let mut conn = db::open(&cfg.db_path)?;
|
||||
|
||||
/* ── command dispatch ────────────────────────────────────── */
|
||||
|
||||
match args.command {
|
||||
Commands::Completions { .. } => {} // handled above
|
||||
|
||||
/* ---- init ------------------------------------------------ */
|
||||
Commands::Init => {
|
||||
info!("Database initialised at {}", cfg.db_path.display());
|
||||
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 {count} files");
|
||||
}
|
||||
|
||||
/* ---- scan ------------------------------------------------ */
|
||||
Commands::Scan { paths } => {
|
||||
let scan_paths = if paths.is_empty() {
|
||||
vec![env::current_dir()?]
|
||||
} else { paths };
|
||||
|
||||
for p in scan_paths {
|
||||
scan::scan_directory(&mut conn, &p)?;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- tag / attribute / search --------------------------- */
|
||||
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)?,
|
||||
|
||||
/* ---- maintenance ---------------------------------------- */
|
||||
Commands::Backup => {
|
||||
let p = db::backup(&cfg.db_path)?;
|
||||
println!("Backup created: {}", p.display());
|
||||
}
|
||||
|
||||
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())
|
||||
})?;
|
||||
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())
|
||||
})?;
|
||||
info!("Successfully opened restored database.");
|
||||
}
|
||||
|
||||
/* ---- passthrough sub-modules (some still stubs) ---------- */
|
||||
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)?,
|
||||
Commands::State(state_cmd) => cli::state::run(&state_cmd, &mut conn, args.format)?,
|
||||
Commands::Task(task_cmd) => cli::task::run(&task_cmd, &mut conn, args.format)?,
|
||||
Commands::Remind(rm_cmd) => cli::remind::run(&rm_cmd, &mut conn, args.format)?,
|
||||
Commands::Annotate(a_cmd) => cli::annotate::run(&a_cmd, &mut conn, args.format)?,
|
||||
Commands::Version(v_cmd) => cli::version::run(&v_cmd, &mut conn, args.format)?,
|
||||
Commands::Event(e_cmd) => cli::event::run(&e_cmd, &mut conn, args.format)?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/* ─────────────────── helpers & sub-routines ─────────────────── */
|
||||
|
||||
/* ---------- TAGS ---------- */
|
||||
|
||||
fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag_path: &str) -> Result<()> {
|
||||
// ensure_tag_path returns ID of deepest node
|
||||
let leaf_tag_id = db::ensure_tag_path(conn, tag_path)?;
|
||||
|
||||
// collect leaf + ancestors
|
||||
let mut tag_ids = Vec::new();
|
||||
let mut current = Some(leaf_tag_id);
|
||||
while let Some(id) = current {
|
||||
tag_ids.push(id);
|
||||
current = conn.query_row(
|
||||
"SELECT parent_id FROM tags WHERE id=?1",
|
||||
[id],
|
||||
|r| r.get::<_, Option<i64>>(0),
|
||||
)?;
|
||||
}
|
||||
|
||||
let expanded = shellexpand::tilde(pattern).into_owned();
|
||||
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")?;
|
||||
let mut stmt_insert = conn.prepare(
|
||||
"INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?1, ?2)",
|
||||
)?;
|
||||
|
||||
let mut count = 0usize;
|
||||
for entry in WalkDir::new(&root)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
.filter(|e| e.file_type().is_file())
|
||||
{
|
||||
let p = entry.path().to_string_lossy();
|
||||
if !pat.matches(&p) { continue; }
|
||||
|
||||
match stmt_file.query_row([p.as_ref()], |r| r.get::<_, i64>(0)) {
|
||||
Ok(fid) => {
|
||||
let mut newly = false;
|
||||
for &tid in &tag_ids {
|
||||
if stmt_insert.execute([fid, tid])? > 0 {
|
||||
newly = true;
|
||||
}
|
||||
}
|
||||
if newly {
|
||||
info!(file=%p, tag=tag_path, "tagged");
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) =>
|
||||
error!(file=%p, "not indexed – run `marlin scan` first"),
|
||||
Err(e) =>
|
||||
error!(file=%p, error=%e, "could not lookup file ID"),
|
||||
}
|
||||
}
|
||||
|
||||
info!("Applied tag '{}' to {} file(s).", tag_path, count);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/* ---------- ATTRIBUTES ---------- */
|
||||
|
||||
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 root = determine_scan_root(&expanded);
|
||||
|
||||
let mut stmt_file = conn.prepare("SELECT id FROM files WHERE path=?1")?;
|
||||
let mut count = 0usize;
|
||||
|
||||
for entry in WalkDir::new(&root)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
.filter(|e| e.file_type().is_file())
|
||||
{
|
||||
let p = entry.path().to_string_lossy();
|
||||
if !pat.matches(&p) { continue; }
|
||||
|
||||
match stmt_file.query_row([p.as_ref()], |r| r.get::<_, i64>(0)) {
|
||||
Ok(fid) => {
|
||||
db::upsert_attr(conn, fid, key, value)?;
|
||||
info!(file=%p, key, value, "attr set");
|
||||
count += 1;
|
||||
}
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) =>
|
||||
error!(file=%p, "not indexed – run `marlin scan` first"),
|
||||
Err(e) =>
|
||||
error!(file=%p, error=%e, "could not lookup file ID"),
|
||||
}
|
||||
}
|
||||
|
||||
info!("Attribute '{}={}' set on {} file(s).", key, value, count);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn attr_ls(conn: &rusqlite::Connection, path: &Path) -> Result<()> {
|
||||
let fid = 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([fid], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?
|
||||
{
|
||||
let (k, v) = row?;
|
||||
println!("{k} = {v}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/* ---------- SEARCH ---------- */
|
||||
|
||||
fn run_search(conn: &rusqlite::Connection, raw_query: &str, exec: Option<String>) -> Result<()> {
|
||||
/* ── build FTS 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 { parts.push("AND".into()); }
|
||||
parts.push(format!("tags_text:{}", escape_fts(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_fts(key)));
|
||||
parts.push("AND".into());
|
||||
parts.push(format!("attrs_text:{}", escape_fts(val)));
|
||||
} else {
|
||||
parts.push(format!("attrs_text:{}", escape_fts(key)));
|
||||
}
|
||||
} else {
|
||||
parts.push(escape_fts(&tok));
|
||||
}
|
||||
}
|
||||
let fts_expr = parts.join(" ");
|
||||
debug!("FTS MATCH expression: {fts_expr}");
|
||||
|
||||
/* ── primary FTS query ---------------------------------- */
|
||||
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 hits: Vec<String> = stmt
|
||||
.query_map([&fts_expr], |r| r.get::<_, String>(0))?
|
||||
.filter_map(Result::ok)
|
||||
.collect();
|
||||
|
||||
/* ── graceful fallback (substring scan) ----------------- */
|
||||
if hits.is_empty() && !raw_query.contains(':') {
|
||||
hits = naive_substring_search(conn, raw_query)?;
|
||||
}
|
||||
|
||||
/* ── output / exec -------------------------------------- */
|
||||
if let Some(cmd_tpl) = exec {
|
||||
run_exec(&hits, &cmd_tpl)?;
|
||||
} else {
|
||||
if hits.is_empty() {
|
||||
eprintln!(
|
||||
"No matches for query: `{raw_query}` (FTS expr: `{fts_expr}`)"
|
||||
);
|
||||
} else {
|
||||
for p in hits { println!("{p}"); }
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fallback: case-insensitive substring scan over paths *and* small file bodies.
|
||||
fn naive_substring_search(conn: &rusqlite::Connection, term: &str) -> Result<Vec<String>> {
|
||||
let needle = 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(&needle) {
|
||||
out.push(p.clone());
|
||||
continue;
|
||||
}
|
||||
// Only scan files ≤ 64 kB
|
||||
if let Ok(meta) = fs::metadata(&p) {
|
||||
if meta.len() > 65_536 { continue; }
|
||||
}
|
||||
if let Ok(body) = fs::read_to_string(&p) {
|
||||
if body.to_lowercase().contains(&needle) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Run external command template on every hit (`{}` placeholder supported).
|
||||
fn run_exec(paths: &[String], cmd_tpl: &str) -> Result<()> {
|
||||
let mut ran_without_placeholder = false;
|
||||
|
||||
// optimisation: if no hits and no placeholder, run once
|
||||
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())
|
||||
{
|
||||
format!("\"{}\"", term.replace('"', "\"\""))
|
||||
} else { term.to_string() }
|
||||
}
|
Reference in New Issue
Block a user