Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ The scaffold is functional with image display, async OCR pipeline, drag-select o
- Full Viewer window (headerbar, arrow key navigation)
- File info in the headerbar (filename, dimensions, file size)
- Async OCR (Tesseract TSV → word bounding boxes)
- OCR settings via `~/.config/quickview/config.toml` (`quickview-core`
`config.rs`): lang (precedence `--lang` > `QUICKVIEW_LANG` > config >
`eng`) and `tessdata_dir` (`--tessdata-dir` > config); both live in
`OcrOptions` and join the cache key
- On-disk OCR cache (`~/.cache/quickview/ocr/`, keyed by path+lang+mtime+size;
no eviction in v1 — see ADR-0009 implementation notes)
- Drag-select overlay with word highlighting
Expand Down
62 changes: 57 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions adrs/ADR-0009-Caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,11 @@ The implementation went **straight to on-disk**, revising the decision above:
edited file then correctly misses) rather than the new one. The path is
derived from the lowercased app name, so the app-ID rename (done:
io.github.Green2Grey2.QuickView) did not move it on Linux.
- Tesseract is currently invoked with no psm/oem flags, so `lang` is the only
setting and it is in the key. **When OCR settings become configurable
(Phase 7 hardening: psm/oem, tessdata_fast/best), they must join the key.**
- Tesseract is invoked with no psm/oem flags, so the OCR settings in the key
are `lang` and (since Phase 7's config work) the optional `tessdata_dir`,
hashed with a presence marker so `None` and empty stay distinct. **The rule
stands: any newly configurable OCR setting (psm/oem, the downscale target)
must join the key.**
- Writes are atomic (temp file + rename in the same directory): concurrent
QuickView processes are a designed use case.
- Entries are created `0600` in `0700` directories — they hold recognized
Expand Down
1 change: 1 addition & 0 deletions crates/quickview-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
directories = "5"
blake3 = "1"
toml = "0.8"

[dev-dependencies]
tempfile = "3"
Expand Down
100 changes: 75 additions & 25 deletions crates/quickview-core/src/cache.rs
Original file line number Diff line number Diff line change
@@ -1,30 +1,26 @@
//! On-disk OCR result cache.
//!
//! Entries are JSON files under `<cache_root>/ocr/`, keyed by a blake3 hash of
//! the image path, OCR language, and file mtime+size — so an edited file is
//! simply a cache miss (no invalidation logic needed). There is no eviction in
//! the image path, the OCR settings (language, tessdata dir), and file
//! mtime+size — so an edited file is simply a cache miss (no invalidation
//! logic needed). There is no eviction in
//! v1: entries are a few KB each, and users can clear the directory manually.
//! Phase 8's persistent SQLite cache is the planned successor (ADR-0009).

use std::path::{Path, PathBuf};

use directories::ProjectDirs;

use crate::ocr::models::OcrResult;
use crate::ocr::{models::OcrResult, tesseract::OcrOptions};

/// Return the XDG cache directory for QuickView.
///
/// On Linux this is typically: `~/.cache/quickview/`.
/// On Linux this is typically: `~/.cache/quickview/` (derived from the
/// lowercased app name, so the app-ID rename did not move it and pre-rename
/// entries remain valid).
pub fn cache_dir() -> Option<PathBuf> {
// qualifier, org, app — must stay in sync with the application ID
// io.github.Green2Grey2.QuickView. On Linux the path only uses the
// lowercased app name (~/.cache/quickview/), so renaming the ID did not
// move the cache and pre-rename entries remain valid.
let proj = ProjectDirs::from("io.github", "Green2Grey2", "QuickView")?;
Some(proj.cache_dir().to_path_buf())
Some(crate::config::project_dirs()?.cache_dir().to_path_buf())
}

pub fn ocr_cache_path(cache_root: &Path, file: &Path, lang: &str) -> PathBuf {
pub fn ocr_cache_path(cache_root: &Path, file: &Path, opts: &OcrOptions) -> PathBuf {
// Include file metadata to avoid stale caches. Full nanosecond mtime:
// whole seconds would alias a same-second rewrite of the same path with
// an unchanged byte length (rapid screenshot/editor saves).
Expand All @@ -40,7 +36,20 @@ pub fn ocr_cache_path(cache_root: &Path, file: &Path, lang: &str) -> PathBuf {
let mut hasher = blake3::Hasher::new();
hasher.update(file.as_os_str().as_encoded_bytes());
hasher.update(b"\0");
hasher.update(lang.as_bytes());
hasher.update(opts.lang.as_bytes());
hasher.update(b"\0");
// Every OcrOptions field joins the key (ADR-0009): a different tessdata
// set produces different text. The presence marker keeps `None` distinct
// from `Some("")`.
match &opts.tessdata_dir {
Some(dir) => {
hasher.update(&[1]);
hasher.update(dir.as_os_str().as_encoded_bytes());
}
None => {
hasher.update(&[0]);
}
}
hasher.update(b"\0");
hasher.update(&mtime.to_le_bytes());
hasher.update(&size.to_le_bytes());
Expand Down Expand Up @@ -134,57 +143,98 @@ mod tests {
}
}

fn eng() -> OcrOptions {
OcrOptions {
lang: "eng".into(),
tessdata_dir: None,
}
}

#[test]
fn key_changes_with_lang_path_and_metadata() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();

let img = dir.path().join("a.png");
std::fs::write(&img, b"xx").unwrap();
let base = ocr_cache_path(root, &img, "eng");
let base = ocr_cache_path(root, &img, &eng());

// Same inputs -> same key.
assert_eq!(base, ocr_cache_path(root, &img, "eng"));
assert_eq!(base, ocr_cache_path(root, &img, &eng()));

// Different language -> different key.
assert_ne!(base, ocr_cache_path(root, &img, "deu"));
let deu = OcrOptions {
lang: "deu".into(),
..eng()
};
assert_ne!(base, ocr_cache_path(root, &img, &deu));

// Different path -> different key.
let img2 = dir.path().join("b.png");
std::fs::write(&img2, b"xx").unwrap();
assert_ne!(base, ocr_cache_path(root, &img2, "eng"));
assert_ne!(base, ocr_cache_path(root, &img2, &eng()));

// Different size -> different key.
std::fs::write(&img, b"xxxx").unwrap();
assert_ne!(base, ocr_cache_path(root, &img, "eng"));
assert_ne!(base, ocr_cache_path(root, &img, &eng()));

// Different mtime (same size) -> different key.
std::fs::write(&img, b"xx").unwrap();
let before = ocr_cache_path(root, &img, "eng");
let before = ocr_cache_path(root, &img, &eng());
let old = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000);
std::fs::File::open(&img)
.unwrap()
.set_modified(old)
.unwrap();
assert_ne!(before, ocr_cache_path(root, &img, "eng"));
assert_ne!(before, ocr_cache_path(root, &img, &eng()));

// Subsecond mtime change (same second, same size) -> different key.
let with_key = |t| {
std::fs::File::open(&img).unwrap().set_modified(t).unwrap();
ocr_cache_path(root, &img, "eng")
ocr_cache_path(root, &img, &eng())
};
assert_ne!(
with_key(old + std::time::Duration::from_nanos(1)),
with_key(old)
);
}

#[test]
fn key_changes_with_tessdata_dir() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let img = dir.path().join("a.png");
std::fs::write(&img, b"xx").unwrap();

let with_dir = |d: Option<&str>| {
let opts = OcrOptions {
lang: "eng".into(),
tessdata_dir: d.map(PathBuf::from),
};
ocr_cache_path(root, &img, &opts)
};

// Some(dir) differs from None, and dirs differ from each other.
assert_ne!(with_dir(None), with_dir(Some("/opt/tessdata_fast")));
assert_ne!(
with_dir(Some("/opt/tessdata_fast")),
with_dir(Some("/opt/tessdata_best"))
);
// The presence marker keeps None distinct from Some("").
assert_ne!(with_dir(None), with_dir(Some("")));
// Same dir -> same key.
assert_eq!(
with_dir(Some("/opt/tessdata_fast")),
with_dir(Some("/opt/tessdata_fast"))
);
}

#[test]
fn store_then_load_round_trips() {
let dir = tempfile::tempdir().unwrap();
let img = dir.path().join("a.png");
std::fs::write(&img, b"xx").unwrap();
let entry = ocr_cache_path(dir.path(), &img, "eng");
let entry = ocr_cache_path(dir.path(), &img, &eng());

let result = sample_result();
store_ocr(&entry, &result).unwrap();
Expand Down Expand Up @@ -217,7 +267,7 @@ mod tests {
let img = dir.path().join("a.png");
std::fs::write(&img, b"xx").unwrap();

let entry = ocr_cache_path(dir.path(), &img, "eng");
let entry = ocr_cache_path(dir.path(), &img, &eng());
assert!(load_ocr(&entry).is_none());
}

Expand All @@ -227,7 +277,7 @@ mod tests {
let img = dir.path().join("a.png");
std::fs::write(&img, b"xx").unwrap();

let entry = ocr_cache_path(dir.path(), &img, "eng");
let entry = ocr_cache_path(dir.path(), &img, &eng());
std::fs::create_dir_all(entry.parent().unwrap()).unwrap();
std::fs::write(&entry, b"{not json").unwrap();

Expand Down
Loading
Loading