Merge pull request #77 from PR0M3TH3AN/beta

Beta
This commit is contained in:
thePR0M3TH3AN
2025-05-24 21:54:52 -04:00
committed by GitHub
11 changed files with 788 additions and 405 deletions

1
.gitignore vendored
View File

@@ -1,7 +1,6 @@
# === Rust build artifacts ===
/target/
/Cargo.lock
# === IDE & Editor settings ===

50
Cargo.lock generated
View File

@@ -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",

View File

@@ -22,3 +22,6 @@
| `event add` | — |
| `event timeline` | — |
| `backup run` | --dir, --prune, --verify, --file |
| `watch start` | --debounce-ms |
| `watch status` | — |
| `watch stop` | — |

View File

@@ -8,14 +8,14 @@
Weve 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

View File

@@ -46,7 +46,7 @@
| Tarpaulin coverage gate | S0 | | |
| Watch mode (FS events) | Epic1 | `marlin watch .` | DP002 |
| Backup autoprune | Epic1 | `backup --prune N` | |
| Rename/move tracking | Epic2 | automatic path update | SpecRMH |
| ~~Rename/move tracking~~ | Epic2 | automatic path update | SpecRMH |
| Dirtyscan | Epic2 | `scan --dirty` | DP002 |
| Grep snippets | Phase3 | `search -C3 …` | DP004 |
| Hash / dedupe | Phase4 | `scan --rehash` | DP005 |
@@ -75,7 +75,7 @@ Before a milestone is declared “shipped”:
| - | ------------------------------ | ------ | ------------- |
| ~~1~~ | ~~Crate split + CI baseline~~ | @alice | ~~26May 25~~ |
| ~~2~~ | ~~Tarpaulin + Hyperfine jobs~~ | @bob | ~~26May 25~~ |
| 3 | **DP001 Schema v1.1** draft | @carol | **30May 25** |
| ~~3~~ | ~~DP001 Schema v1.1 draft~~ | @carol | ~~30May 25~~ |
| ~~4~~ | ~~backup prune CLI + nightly job~~ | @dave | ~~05Jun 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.*

View File

@@ -8,7 +8,7 @@
| Feature Area | Capabilities |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Tagging System** | • Unlimited, hierarchical or flat tags.<br>• Alias/synonym support with precedence rules (admindefined canonical name).<br>**Bulk tag editing** via multiselect context menu.<br>• FoldertoTag import with optional *watch & sync* mode so new subfolders inherit tags automatically. |
| **Tagging System** | • Unlimited, hierarchical or flat tags.<br>• Alias/synonym support via admindefined mappings (canonical names resolved at query time).<br>**Bulk tag editing** via multiselect context menu.<br>• FoldertoTag import with optional *watch & sync* mode so new subfolders inherit tags automatically. |
| **Custom Metadata Attributes** | • Userdefined fields (text, number, date, enum, boolean).<br>• Pertemplate **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 rollback.<br>• Sidebyside diff viewer and *blame* panel showing *who/when/what*.<br>• Offline edits stored locally and merged (Gitstyle 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)

View File

@@ -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 }

View 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;

View File

@@ -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

View File

@@ -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());
}
}
}