mirror of
https://github.com/PR0M3TH3AN/Marlin.git
synced 2025-09-07 06:38:44 +00:00
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,7 +1,6 @@
|
||||
# === Rust build artifacts ===
|
||||
|
||||
/target/
|
||||
/Cargo.lock
|
||||
|
||||
# === IDE & Editor settings ===
|
||||
|
||||
|
50
Cargo.lock
generated
50
Cargo.lock
generated
@@ -79,12 +79,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.7"
|
||||
version = "3.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e"
|
||||
checksum = "6680de5231bd6ee4c6191b8a1325daa282b415391ec9d3a37bd34f2060dc73fa"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
@@ -156,9 +156,9 @@ checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.23"
|
||||
version = "1.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f4ac86a9e5bc1e2b3449ab9d7d3a6a405e3d1bb28d7b9be8614f55846ae3766"
|
||||
checksum = "16595d3be041c03b09d08d0858631facccee9221e579704070e6e9e4915d3bc7"
|
||||
dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
@@ -470,12 +470,6 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
@@ -530,16 +524,6 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"hashbrown 0.12.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.9.0"
|
||||
@@ -635,8 +619,8 @@ dependencies = [
|
||||
"glob",
|
||||
"lazy_static",
|
||||
"notify",
|
||||
"priority-queue",
|
||||
"rusqlite",
|
||||
"same-file",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"shellexpand",
|
||||
@@ -820,6 +804,12 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -874,16 +864,6 @@ dependencies = [
|
||||
"termtree",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "priority-queue"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0bda9164fe05bc9225752d54aae413343c36f684380005398a6a8fde95fe785"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"indexmap 1.9.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.95"
|
||||
@@ -1012,9 +992,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.20"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2"
|
||||
checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
@@ -1069,7 +1049,7 @@ version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap 2.9.0",
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
|
@@ -22,3 +22,6 @@
|
||||
| `event add` | — |
|
||||
| `event timeline` | — |
|
||||
| `backup run` | --dir, --prune, --verify, --file |
|
||||
| `watch start` | --debounce-ms |
|
||||
| `watch status` | — |
|
||||
| `watch stop` | — |
|
||||
|
@@ -8,14 +8,14 @@
|
||||
|
||||
We’ve landed a basic SQLite-backed `files` table and a contentless FTS5 index. Before we build out higher-level features, we need to lock down our **v1.1** metadata schema for:
|
||||
|
||||
- **Hierarchical tags** (`tags` + `file_tags`) – optional `canonical_id` for aliases
|
||||
- **Hierarchical tags** (`tags` + `file_tags`) – alias resolution handled at query time
|
||||
- **Custom attributes** (`attributes`)
|
||||
- **File-to-file relationships** (`links`)
|
||||
- **Named collections** (`collections` + `collection_files`)
|
||||
- **Views** (`views`)
|
||||
|
||||
Locking this schema now lets downstream CLI & GUI work against a stable model and ensures our migrations stay easy to reason about.
|
||||
Tags optionally reference a canonical tag via the `canonical_id` column.
|
||||
Locking this schema now lets downstream CLI & GUI work against a stable model and ensures our migrations stay easy to reason about.
|
||||
Alias relationships are resolved outside the table itself; there is no `canonical_id` column.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
@@ -58,7 +58,6 @@ entity tags {
|
||||
--
|
||||
name : TEXT
|
||||
parent_id : INTEGER <<FK>>
|
||||
canonical_id : INTEGER <<FK>>
|
||||
}
|
||||
|
||||
entity file_tags {
|
||||
@@ -151,6 +150,7 @@ Or in plain-ASCII:
|
||||
| **0003\_create\_links\_collections\_views.sql** | Add `links`, `collections`, `collection_files`, `views` |
|
||||
| **0004\_fix\_hierarchical\_tags\_fts.sql** | Recursive CTE for full tag-path indexing in FTS triggers |
|
||||
| **0005_add_dirty_table.sql** | Track modified files needing reindexing |
|
||||
| **0006_drop_tags_canonical_id.sql** | Remove legacy `canonical_id` column from `tags` |
|
||||
|
||||
### Performance-Critical Indexes
|
||||
|
||||
|
@@ -46,7 +46,7 @@
|
||||
| Tarpaulin coverage gate | S0 | — | – |
|
||||
| Watch mode (FS events) | Epic 1 | `marlin watch .` | DP‑002 |
|
||||
| Backup auto‑prune | Epic 1 | `backup --prune N` | – |
|
||||
| Rename/move tracking | Epic 2 | automatic path update | Spec‑RMH |
|
||||
| ~~Rename/move tracking~~ | Epic 2 | automatic path update | Spec‑RMH |
|
||||
| Dirty‑scan | Epic 2 | `scan --dirty` | DP‑002 |
|
||||
| Grep snippets | Phase 3 | `search -C3 …` | DP‑004 |
|
||||
| Hash / dedupe | Phase 4 | `scan --rehash` | DP‑005 |
|
||||
@@ -75,7 +75,7 @@ Before a milestone is declared “shipped”:
|
||||
| - | ------------------------------ | ------ | ------------- |
|
||||
| ~~1~~ | ~~Crate split + CI baseline~~ | @alice | ~~26 May 25~~ |
|
||||
| ~~2~~ | ~~Tarpaulin + Hyperfine jobs~~ | @bob | ~~26 May 25~~ |
|
||||
| 3 | **DP‑001 Schema v1.1** draft | @carol | **30 May 25** |
|
||||
| ~~3~~ | ~~DP‑001 Schema v1.1 draft~~ | @carol | ~~30 May 25~~ |
|
||||
| ~~4~~ | ~~backup prune CLI + nightly job~~ | @dave | ~~05 Jun 25~~ |
|
||||
|
||||
> *This roadmap now contains both product-level “what” and engineering-level “how/when/prove it”. It should allow a new contributor to jump in, pick the matching DP, and know exactly the bar they must clear for their code to merge.*
|
||||
|
@@ -8,7 +8,7 @@
|
||||
|
||||
| Feature Area | Capabilities |
|
||||
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Tagging System** | • Unlimited, hierarchical or flat tags.<br>• Alias/synonym support with precedence rules (admin‑defined canonical name).<br>• **Bulk tag editing** via multi‑select context menu.<br>• Folder‑to‑Tag import with optional *watch & sync* mode so new sub‑folders inherit tags automatically. |
|
||||
| **Tagging System** | • Unlimited, hierarchical or flat tags.<br>• Alias/synonym support via admin‑defined mappings (canonical names resolved at query time).<br>• **Bulk tag editing** via multi‑select context menu.<br>• Folder‑to‑Tag import with optional *watch & sync* mode so new sub‑folders inherit tags automatically. |
|
||||
| **Custom Metadata Attributes** | • User‑defined fields (text, number, date, enum, boolean).<br>• Per‑template **Custom Metadata Schemas** (e.g. *Photo* → *Date, Location*). |
|
||||
| **File Relationships** | • Typed, directional or bidirectional links (*related to*, *duplicate of*, *cites*…).<br>• Plugin API can register new relationship sets. |
|
||||
| **Version Control for Metadata** | • Every change logged; unlimited roll‑back.<br>• Side‑by‑side diff viewer and *blame* panel showing *who/when/what*.<br>• Offline edits stored locally and merged (Git‑style optimistic merge with conflict prompts). |
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
```text
|
||||
files(id PK, path, inode, size, mtime, ctime, hash)
|
||||
tags(id PK, name, parent_id, canonical_id)
|
||||
tags(id PK, name, parent_id)
|
||||
file_tags(file_id FK, tag_id FK)
|
||||
attributes(id PK, file_id FK, key, value, value_type)
|
||||
relationships(id PK, src_file_id FK, dst_file_id FK, rel_type, direction)
|
||||
|
@@ -11,13 +11,13 @@ crossbeam-channel = "0.5"
|
||||
directories = "5"
|
||||
glob = "0.3"
|
||||
notify = "6.0"
|
||||
priority-queue = "1.3"
|
||||
rusqlite = { version = "0.31", features = ["bundled", "backup"] }
|
||||
sha2 = "0.10"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
||||
walkdir = "2.5"
|
||||
shlex = "1.3"
|
||||
same-file = "1"
|
||||
shellexpand = "3.1"
|
||||
serde_json = { version = "1", optional = true }
|
||||
|
||||
|
20
libmarlin/src/db/migrations/0007_fix_rename_trigger.sql
Normal file
20
libmarlin/src/db/migrations/0007_fix_rename_trigger.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- src/db/migrations/0007_fix_rename_trigger.sql
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA journal_mode = WAL;
|
||||
|
||||
-- Recreate files_fts_au_file trigger using INSERT OR REPLACE
|
||||
DROP TRIGGER IF EXISTS files_fts_au_file;
|
||||
CREATE TRIGGER files_fts_au_file
|
||||
AFTER UPDATE OF path ON files
|
||||
BEGIN
|
||||
INSERT OR REPLACE INTO files_fts(rowid, path, tags_text, attrs_text)
|
||||
SELECT NEW.id,
|
||||
NEW.path,
|
||||
(SELECT IFNULL(GROUP_CONCAT(t.name, ' '), '')
|
||||
FROM file_tags ft
|
||||
JOIN tags t ON ft.tag_id = t.id
|
||||
WHERE ft.file_id = NEW.id),
|
||||
(SELECT IFNULL(GROUP_CONCAT(a.key || '=' || a.value, ' '), '')
|
||||
FROM attributes a
|
||||
WHERE a.file_id = NEW.id);
|
||||
END;
|
@@ -50,6 +50,10 @@ const MIGRATIONS: &[(&str, &str)] = &[
|
||||
"0006_drop_tags_canonical_id.sql",
|
||||
include_str!("migrations/0006_drop_tags_canonical_id.sql"),
|
||||
),
|
||||
(
|
||||
"0007_fix_rename_trigger.sql",
|
||||
include_str!("migrations/0007_fix_rename_trigger.sql"),
|
||||
),
|
||||
];
|
||||
|
||||
/* ─── schema helpers ─────────────────────────────────────────────── */
|
||||
@@ -387,6 +391,39 @@ pub fn take_dirty(conn: &Connection) -> Result<Vec<i64>> {
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/* ─── rename helpers ────────────────────────────────────────────── */
|
||||
|
||||
pub fn update_file_path(conn: &Connection, old_path: &str, new_path: &str) -> Result<()> {
|
||||
let file_id: i64 = conn.query_row("SELECT id FROM files WHERE path = ?1", [old_path], |r| {
|
||||
r.get(0)
|
||||
})?;
|
||||
conn.execute(
|
||||
"UPDATE files SET path = ?1 WHERE id = ?2",
|
||||
params![new_path, file_id],
|
||||
)?;
|
||||
mark_dirty(conn, file_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn rename_directory(conn: &mut Connection, old_dir: &str, new_dir: &str) -> Result<()> {
|
||||
let like_pattern = format!("{}/%", old_dir.trim_end_matches('/'));
|
||||
let ids = {
|
||||
let mut stmt = conn.prepare("SELECT id FROM files WHERE path LIKE ?1")?;
|
||||
let rows = stmt.query_map([&like_pattern], |r| r.get::<_, i64>(0))?;
|
||||
rows.collect::<StdResult<Vec<_>, _>>()?
|
||||
};
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"UPDATE files SET path = REPLACE(path, ?1, ?2) WHERE path LIKE ?3",
|
||||
params![old_dir, new_dir, like_pattern],
|
||||
)?;
|
||||
for fid in ids {
|
||||
mark_dirty(&tx, fid)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/* ─── backup / restore helpers ────────────────────────────────────── */
|
||||
|
||||
pub fn backup<P: AsRef<Path>>(db_path: P) -> Result<PathBuf> {
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -7,14 +7,46 @@ mod tests {
|
||||
// These are still from the watcher module
|
||||
use crate::db::open as open_marlin_db;
|
||||
use crate::watcher::{FileWatcher, WatcherConfig, WatcherState}; // Use your project's DB open function
|
||||
use crate::Marlin;
|
||||
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
// No longer need: use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
use tempfile::tempdir;
|
||||
|
||||
/// Polls the DB until `query` returns `expected` or the timeout elapses.
|
||||
fn wait_for_row_count(
|
||||
marlin: &Marlin,
|
||||
path: &std::path::Path,
|
||||
expected: i64,
|
||||
timeout: Duration,
|
||||
) {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
let count: i64 = marlin
|
||||
.conn()
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM files WHERE path = ?1",
|
||||
[path.to_string_lossy()],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
if count == expected {
|
||||
break;
|
||||
}
|
||||
if start.elapsed() > timeout {
|
||||
panic!(
|
||||
"Timed out waiting for {} rows for {}",
|
||||
expected,
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_watcher_lifecycle() {
|
||||
// Create a temp directory for testing
|
||||
@@ -60,7 +92,7 @@ mod tests {
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
fs::remove_file(&new_file_path).expect("Failed to remove file");
|
||||
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
thread::sleep(Duration::from_millis(1500));
|
||||
watcher.stop().expect("Failed to stop watcher");
|
||||
|
||||
assert_eq!(watcher.status().unwrap().state, WatcherState::Stopped);
|
||||
@@ -144,4 +176,97 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_file_updates_db() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
let file = dir.join("a.txt");
|
||||
fs::write(&file, b"hi").unwrap();
|
||||
let db_path = dir.join("test.db");
|
||||
let mut marlin = Marlin::open_at(&db_path).unwrap();
|
||||
marlin.scan(&[dir]).unwrap();
|
||||
|
||||
let mut watcher = marlin
|
||||
.watch(
|
||||
dir,
|
||||
Some(WatcherConfig {
|
||||
debounce_ms: 50,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
let new_file = dir.join("b.txt");
|
||||
fs::rename(&file, &new_file).unwrap();
|
||||
wait_for_row_count(&marlin, &new_file, 1, Duration::from_secs(10));
|
||||
watcher.stop().unwrap();
|
||||
assert!(
|
||||
watcher.status().unwrap().events_processed > 0,
|
||||
"rename event should be processed"
|
||||
);
|
||||
|
||||
let count: i64 = marlin
|
||||
.conn()
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM files WHERE path = ?1",
|
||||
[new_file.to_string_lossy()],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_directory_updates_children() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
let sub = dir.join("old");
|
||||
fs::create_dir(&sub).unwrap();
|
||||
let f1 = sub.join("one.txt");
|
||||
fs::write(&f1, b"1").unwrap();
|
||||
let f2 = sub.join("two.txt");
|
||||
fs::write(&f2, b"2").unwrap();
|
||||
|
||||
let db_path = dir.join("test2.db");
|
||||
let mut marlin = Marlin::open_at(&db_path).unwrap();
|
||||
marlin.scan(&[dir]).unwrap();
|
||||
|
||||
let mut watcher = marlin
|
||||
.watch(
|
||||
dir,
|
||||
Some(WatcherConfig {
|
||||
debounce_ms: 50,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
let new = dir.join("newdir");
|
||||
fs::rename(&sub, &new).unwrap();
|
||||
for fname in ["one.txt", "two.txt"] {
|
||||
let p = new.join(fname);
|
||||
wait_for_row_count(&marlin, &p, 1, Duration::from_secs(10));
|
||||
}
|
||||
watcher.stop().unwrap();
|
||||
assert!(
|
||||
watcher.status().unwrap().events_processed > 0,
|
||||
"rename event should be processed"
|
||||
);
|
||||
|
||||
for fname in ["one.txt", "two.txt"] {
|
||||
let p = new.join(fname);
|
||||
let cnt: i64 = marlin
|
||||
.conn()
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM files WHERE path = ?1",
|
||||
[p.to_string_lossy()],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cnt, 1, "{} missing", p.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user