This commit is contained in:
thePR0M3TH3AN committed 2025-05-18 16:02:48 -04:00
1 parent 6157ac5233
commit f6fca2c0dd
44 files changed
+492 -508

No files matched your search

+28
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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()
}
}