From 909abf5fcb0ff0f5a75741028396ebd7c5c1ab9a Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 21 Oct 2026 01:12:42 +0200 Subject: [PATCH] search: a file is found by what it says The text of every file in the tree that is text, by its type or by its first 8 KiB reading as UTF-8 without a NUL, now goes into `file_text` and a second FTS5 index beside the documents', up to its first 256 KiB, the same part the viewer shows. A search asks both, lists a node found in both once, and marks a file found by its text with `in_text`; the list shows the words around the match under its name. There is one way in, `file_text::catch_up`, which reads every unsealed file that has no row yet. An upload, an import, an SFTP save and a decrypt call it once their own transaction is done, and the server runs it at start for anything that slipped past. A file that is not text gets an empty row, so it is read once and not again. The triggers keep it honest the other way. New bytes drop the old text, so an SFTP save is read again, and `:encrypt` writes a file under a new media id, so its plaintext is gone in the same statement that seals it. The text of a sealed file is refused outright, whatever path tries to store it; otherwise it would sit in `diary.db` and its mirror beside the sealed bytes. PDFs and office documents are not read: their text needs a parser this binary does not carry. --- README.md | 18 +- migrations/0024_file_text.sql | 48 +++++ src/main.rs | 10 + src/routes/file_text.rs | 344 ++++++++++++++++++++++++++++++++++ src/routes/import.rs | 3 +- src/routes/media.rs | 3 +- src/routes/mod.rs | 1 + src/routes/nodes.rs | 39 +++- src/routes/seal.rs | 6 +- src/sftp/fs.rs | 3 +- web/src/lib/Help.svelte | 2 +- web/src/lib/Sidebar.svelte | 11 ++ web/src/lib/api.ts | 2 + 13 files changed, 476 insertions(+), 14 deletions(-) create mode 100644 migrations/0024_file_text.sql create mode 100644 src/routes/file_text.rs diff --git a/README.md b/README.md index 0169ed7..a69107a 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ one binary. It was called `narl-diary` until it grew everything but the diary. - **Rust + axum** backend, **SQLite** storage, **Svelte 5** frontend. - Spaces (`diary`, `work`, `uni`, …) with folders nested to any depth inside them. - Markdown documents with full-text search across a whole space (SQLite FTS5), + reaching into the text files in the tree too, and math in them set by KaTeX: `$x^2$` inline, `$$…$$` or a ```` ```math ```` block on its own. - Import a folder — or a zip of one — as a tree of documents, links and all. @@ -451,6 +452,21 @@ arrives, never held in memory, so the only ceiling is `WORKSPACE_MAX_UPLOAD_MB` (1024 by default) — which is per request, so a proxy in front needs a body limit at least that large too. +### Searching inside files + +`/` and `Ctrl-P` find a file by what it says as well as by its name: the text +of every file that is text — by its type, or by its first 8 KiB reading as +UTF-8 without a NUL — is indexed, up to its first 256 KiB, the same part the +viewer shows. A file found that way lists the words around the match under its +name. The text is read when a file arrives, by upload, import or the SFTP +mount, again when its bytes change, and at start for any file still without +it. PDFs, office documents and images are not read. + +A sealed file is never indexed. Sealing one drops its text in the same step, +a file in a sealed folder never gets any, and the database refuses it if +something tries; otherwise the plaintext would sit in `diary.db` and its +mirror beside the sealed bytes. Decrypting a file reads it again. + The SFTP mount shows files beside the documents, under their own names — a later file that shares one is told apart by its id, `slides (42).pdf` — and anything copied in that is not a `.md` becomes a file in the tree, streamed to @@ -783,7 +799,7 @@ sealed or not. | `GET` | `/api/sessions` | every signed-in browser, the one seen last first | | `DELETE` | `/api/sessions/{id}` | sign another browser out, and lock the vault | | `GET`/`POST` | `/api/spaces` | the spaces / create one | -| `GET`/`POST` | `/api/nodes` | children (`?parent=`) or search (`?q=`, `?space=`) / create, from a template with `template_id` | +| `GET`/`POST` | `/api/nodes` | children (`?parent=`) or search (`?q=`, `?space=`; a file matched by its text has `in_text` and the words around the match as `excerpt`) / create, from a template with `template_id` | | `GET`/`PUT`/`DELETE` | `/api/nodes/{id}` | read / update / delete into the trash, subtree and all — or for good, for something already in it | | `GET`/`DELETE` | `/api/trash` | what was deleted, the last first / empty it | | `DELETE` | `/api/trash/{id}` | delete one thing in the trash for good | diff --git a/migrations/0024_file_text.sql b/migrations/0024_file_text.sql new file mode 100644 index 0000000..47353d4 --- /dev/null +++ b/migrations/0024_file_text.sql @@ -0,0 +1,48 @@ +-- The text of the files in the tree, so a search finds a sentence in a `.txt` +-- or a `.csv` the way it finds one in a document. One row per file node, read +-- from the bytes `media_id` names; a file that is not text has a row with no +-- text, so it is read once and not again on every pass. +CREATE TABLE file_text ( + node_id INTEGER PRIMARY KEY REFERENCES nodes (id) ON DELETE CASCADE, + media_id TEXT NOT NULL, + text TEXT NOT NULL +); + +CREATE VIRTUAL TABLE files_fts USING fts5 ( + text, content = 'file_text', content_rowid = 'node_id', tokenize = 'unicode61' +); + +CREATE TRIGGER file_text_ai AFTER INSERT ON file_text BEGIN + INSERT INTO files_fts (rowid, text) VALUES (new.node_id, new.text); +END; +CREATE TRIGGER file_text_ad AFTER DELETE ON file_text BEGIN + INSERT INTO files_fts (files_fts, rowid, text) VALUES ('delete', old.node_id, old.text); +END; +CREATE TRIGGER file_text_au AFTER UPDATE ON file_text BEGIN + INSERT INTO files_fts (files_fts, rowid, text) VALUES ('delete', old.node_id, old.text); + INSERT INTO files_fts (rowid, text) VALUES (new.node_id, new.text); +END; + +-- New bytes make the old text wrong: an SFTP save, and `:encrypt`, which +-- re-writes a file under a new media id. Dropped in the same statement, so +-- the plaintext of a file being sealed is gone the moment it is sealed. +CREATE TRIGGER file_text_stale AFTER UPDATE OF media_id ON nodes +WHEN new.media_id IS NOT old.media_id +BEGIN + DELETE FROM file_text WHERE node_id = new.id; +END; + +CREATE TRIGGER file_text_sealed_media AFTER UPDATE OF encrypted ON media +WHEN new.encrypted <> 0 +BEGIN + DELETE FROM file_text WHERE media_id = new.id; +END; + +-- The last word, as in 0015: the text of a sealed file is never stored, +-- whatever path tries. Otherwise it would sit in `diary.db` and its mirror in +-- the clear while the bytes are sealed. +CREATE TRIGGER file_text_never_sealed BEFORE INSERT ON file_text +WHEN (SELECT encrypted FROM media WHERE id = new.media_id) IS NOT 0 +BEGIN + SELECT RAISE(ABORT, 'a sealed file has no text in the index'); +END; diff --git a/src/main.rs b/src/main.rs index f8da249..de7f14e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -151,6 +151,16 @@ async fn main() -> Result<()> { Ok(linking) => tracing::info!("indexed the links of {linking} documents"), Err(e) => tracing::warn!("could not index the links between documents: {e:?}"), } + // The text of files is read off the disk, which may take a while on a + // first start, so the server does not wait for it. + let (db, uploads) = (state.db.clone(), config.uploads_dir()); + tokio::spawn(async move { + match routes::file_text::catch_up(&db, &uploads).await { + Ok(0) => {} + Ok(read) => tracing::info!("read {read} files for search"), + Err(e) => tracing::warn!("could not index files for search: {e:?}"), + } + }); if let Some(sftp_bind) = config.sftp.bind { let sftp_state = state.clone(); diff --git a/src/routes/file_text.rs b/src/routes/file_text.rs new file mode 100644 index 0000000..d11546b --- /dev/null +++ b/src/routes/file_text.rs @@ -0,0 +1,344 @@ +//! The text of files in the tree, for search. +//! +//! A file's bytes live in `uploads/`, out of reach of SQLite's full-text +//! index, so the text of the ones that are text is copied into `file_text` +//! beside them. There is one way in, [`catch_up`]: it reads every file that +//! has no row yet. Whatever puts a file in the tree — an upload, an import, an +//! SFTP save, a decrypt — calls it once its own transaction is done, and the +//! server calls it at start for anything that slipped past. The triggers of +//! 0024 take care of the other direction: new bytes or sealing drop the row, +//! and a sealed file never gets one. +//! +//! PDF and office files are not read. Their text needs a parser this binary +//! does not carry, and it waits until it is missed. + +use std::path::Path; + +use sqlx::{Row, SqlitePool}; +use tokio::io::AsyncReadExt; + +use crate::error::AppResult; + +/// As much of a file as is indexed — the same as the viewer shows. +pub const LIMIT: usize = 256 * 1024; + +/// How much of a file of no telling type is looked at to decide it is text. +const SNIFF: usize = 8 * 1024; + +/// Types whose bytes are text whether or not they say `text/`. +const TEXT_TYPES: [&str; 8] = [ + "application/json", + "application/xml", + "application/javascript", + "application/x-sh", + "application/toml", + "application/yaml", + "application/x-yaml", + "application/sql", +]; + +/// The text of the start of a file, or `None` when it is not text: its type +/// says so, or its first bytes are valid UTF-8 without a NUL among them. A +/// cut at `LIMIT` that splits a character drops the half. +pub fn text_of(mime: &str, bytes: &[u8]) -> Option { + let bytes = &bytes[..bytes.len().min(LIMIT)]; + let start = &bytes[..bytes.len().min(SNIFF)]; + if start.contains(&0) { + return None; + } + let mime = mime.split(';').next().unwrap_or(mime).trim(); + let typed = mime.starts_with("text/") || TEXT_TYPES.contains(&mime) || mime.ends_with("+xml"); + if !typed && !utf8_but_for_a_cut(start) { + return None; + } + let text = match std::str::from_utf8(bytes) { + Ok(text) => text.to_string(), + Err(e) if e.error_len().is_none() => { + String::from_utf8_lossy(&bytes[..e.valid_up_to()]).into_owned() + } + // Text by its type but not UTF-8 — Latin-1, most likely. The words in + // ASCII are still worth finding. + Err(_) if typed => String::from_utf8_lossy(bytes).into_owned(), + Err(_) => return None, + }; + Some(text) +} + +/// Valid UTF-8, allowing for a character cut in half at the end. +fn utf8_but_for_a_cut(bytes: &[u8]) -> bool { + match std::str::from_utf8(bytes) { + Ok(_) => true, + Err(e) => e.error_len().is_none(), + } +} + +/// Read every unsealed file in the tree that has no row yet. Returns how many +/// were read. A file whose bytes are missing is skipped and asked about again +/// next time; one that is not text gets an empty row and is not. +pub async fn catch_up(db: &SqlitePool, uploads: &Path) -> AppResult { + let missing = sqlx::query( + "SELECT n.id, n.media_id, m.mime + FROM nodes n + JOIN media m ON m.id = n.media_id + LEFT JOIN file_text t ON t.node_id = n.id + WHERE n.kind = 'file' AND m.encrypted = 0 AND t.node_id IS NULL", + ) + .fetch_all(db) + .await?; + + let mut read = 0; + for row in &missing { + let (id, media_id, mime): (i64, String, String) = + (row.get("id"), row.get("media_id"), row.get("mime")); + let bytes = match head(&uploads.join(&media_id)).await { + Ok(bytes) => bytes, + Err(e) => { + tracing::warn!(node = id, "could not read a file for search: {e}"); + continue; + } + }; + let text = text_of(&mime, &bytes).unwrap_or_default(); + // Only while the node still has these bytes: an SFTP save that came + // in meanwhile has newer ones, and the next pass reads those. The + // trigger refuses a file sealed meanwhile; that is no reason to stop. + let stored = sqlx::query( + "INSERT OR IGNORE INTO file_text (node_id, media_id, text) + SELECT ?1, ?2, ?3 WHERE EXISTS (SELECT 1 FROM nodes WHERE id = ?1 AND media_id = ?2)", + ) + .bind(id) + .bind(&media_id) + .bind(&text) + .execute(db) + .await; + match stored { + Ok(done) if done.rows_affected() > 0 => read += 1, + Ok(_) => {} + Err(e) => tracing::warn!(node = id, "could not index a file for search: {e}"), + } + } + Ok(read) +} + +/// [`catch_up`] after a write, where a failure is worth a line in the log +/// but not the write's answer: the file is there either way, and the next +/// start reads it. +pub async fn refresh(db: &SqlitePool, uploads: &Path) { + if let Err(e) = catch_up(db, uploads).await { + tracing::warn!("could not index files for search: {e:?}"); + } +} + +async fn head(path: &Path) -> std::io::Result> { + let mut bytes = Vec::new(); + tokio::fs::File::open(path) + .await? + .take(LIMIT as u64) + .read_to_end(&mut bytes) + .await?; + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::{catch_up, text_of, LIMIT}; + + #[test] + fn text_is_told_by_its_type_or_its_bytes() { + assert_eq!(text_of("text/plain", b"hello").as_deref(), Some("hello")); + assert_eq!(text_of("application/json", b"{}").as_deref(), Some("{}")); + assert_eq!( + text_of("image/svg+xml", b"").as_deref(), + Some("") + ); + // No telling type, but it reads as UTF-8: a `.log`, a `Makefile`. + assert_eq!( + text_of("application/octet-stream", "grüße".as_bytes()).as_deref(), + Some("grüße") + ); + assert_eq!( + text_of("application/octet-stream", b"\x89PNG\r\n\x1a\n\0\0"), + None + ); + assert_eq!( + text_of("application/pdf", b"%PDF-1.7\n\xe2\xe3\xcf\xd3"), + None + ); + assert_eq!(text_of("text/plain", b"a\0b"), None); + // Latin-1 that says it is text keeps its ASCII words. + assert!(text_of("text/plain", b"caf\xe9 au lait") + .unwrap() + .contains("au lait")); + } + + #[test] + fn a_long_file_is_cut_at_the_limit_on_a_character() { + let mut bytes = vec![b'a'; LIMIT - 1]; + bytes.extend("ü and more".as_bytes()); + let text = text_of("text/plain", &bytes).unwrap(); + assert_eq!(text.len(), LIMIT - 1); + assert!(text.bytes().all(|b| b == b'a')); + } + + async fn file(state: &crate::state::AppState, name: &str, bytes: &[u8], sealed: bool) -> i64 { + let space: i64 = sqlx::query_scalar( + "SELECT id FROM nodes WHERE parent_id IS NULL AND role IS NULL ORDER BY id LIMIT 1", + ) + .fetch_one(&state.db) + .await + .unwrap(); + let media_id = uuid::Uuid::new_v4().to_string(); + std::fs::write(state.config.uploads_dir().join(&media_id), bytes).unwrap(); + let mime = crate::routes::media::mime_of(name, None); + crate::routes::media::insert_file( + &state.db, + space, + &media_id, + name, + &mime, + bytes.len() as u64, + sealed, + ) + .await + .unwrap() + } + + async fn found(state: &crate::state::AppState, q: &str) -> Vec<(String, bool, String)> { + use axum::extract::{Query, State}; + let query = serde_json::from_value(serde_json::json!({ "q": q })).unwrap(); + crate::routes::nodes::list( + crate::auth::Session { id: "test".into() }, + State(state.clone()), + Query(query), + ) + .await + .unwrap() + .0 + .into_iter() + .map(|node| (node.name, node.in_text, node.excerpt)) + .collect() + } + + /// A text file is found by what it says, marked as found in its text and + /// shown with the words around the match; a binary file is not read. + #[tokio::test] + async fn a_text_file_is_found_by_what_it_says() { + let dir = tempfile::tempdir().unwrap(); + let state = crate::routes::tests::state_in(dir.path().into()).await; + std::fs::create_dir_all(state.config.uploads_dir()).unwrap(); + let notes = file(&state, "notes.txt", b"the otter sleeps by the river", false).await; + file( + &state, + "photo.jpg", + b"\xff\xd8\xff\xe0\0\x10JFIF otter", + false, + ) + .await; + + assert_eq!( + catch_up(&state.db, &state.config.uploads_dir()) + .await + .unwrap(), + 2 + ); + assert_eq!( + catch_up(&state.db, &state.config.uploads_dir()) + .await + .unwrap(), + 0 + ); + let hits = found(&state, "otter").await; + assert_eq!(hits.len(), 1); + let (name, in_text, excerpt) = &hits[0]; + assert_eq!((name.as_str(), *in_text), ("notes.txt", true)); + assert!(excerpt.contains("otter sleeps"), "{excerpt}"); + // Found by its name, it is not marked. + assert!(!found(&state, "notes").await[0].1); + + // Deleted, it is gone from the index with it. + sqlx::query("DELETE FROM nodes WHERE id = ?1") + .bind(notes) + .execute(&state.db) + .await + .unwrap(); + assert!(found(&state, "otter").await.is_empty()); + } + + /// A sealed file is never read, a file that gets sealed drops out at + /// once, and the trigger refuses its text whatever tries to store it. + #[tokio::test] + async fn a_sealed_file_is_never_found() { + let dir = tempfile::tempdir().unwrap(); + let state = crate::routes::tests::state_in(dir.path().into()).await; + std::fs::create_dir_all(state.config.uploads_dir()).unwrap(); + file(&state, "secret.txt", b"badger plans", true).await; + let open = file(&state, "open.txt", b"heron plans", false).await; + catch_up(&state.db, &state.config.uploads_dir()) + .await + .unwrap(); + assert!(found(&state, "badger").await.is_empty()); + assert_eq!(found(&state, "heron").await.len(), 1); + + // What `:encrypt` does to the row: the node gets new, sealed bytes. + let old: String = sqlx::query_scalar("SELECT media_id FROM nodes WHERE id = ?1") + .bind(open) + .fetch_one(&state.db) + .await + .unwrap(); + sqlx::query( + "INSERT INTO media (id, filename, mime, size, created_at, encrypted) + SELECT 'sealed-heron', filename, mime, size, created_at, 1 FROM media WHERE id = ?1", + ) + .bind(&old) + .execute(&state.db) + .await + .unwrap(); + sqlx::query("UPDATE nodes SET media_id = 'sealed-heron' WHERE id = ?1") + .bind(open) + .execute(&state.db) + .await + .unwrap(); + assert!(found(&state, "heron").await.is_empty()); + assert_eq!( + catch_up(&state.db, &state.config.uploads_dir()) + .await + .unwrap(), + 0 + ); + + let refused = sqlx::query( + "INSERT INTO file_text (node_id, media_id, text) VALUES (?1, 'sealed-heron', 'heron')", + ) + .bind(open) + .execute(&state.db) + .await; + assert!(refused.is_err()); + } + + /// New bytes over SFTP: the old text goes with the old bytes, and the + /// next pass reads the new ones. + #[tokio::test] + async fn new_bytes_are_read_again() { + let dir = tempfile::tempdir().unwrap(); + let state = crate::routes::tests::state_in(dir.path().into()).await; + let uploads = state.config.uploads_dir(); + std::fs::create_dir_all(&uploads).unwrap(); + let id = file(&state, "todo.md", b"buy lentils", false).await; + catch_up(&state.db, &uploads).await.unwrap(); + + std::fs::write(uploads.join("v2"), b"buy chickpeas").unwrap(); + sqlx::query( + "INSERT INTO media (id, filename, mime, size, created_at) VALUES ('v2', 'todo.md', 'text/markdown', 13, 0)", + ) + .execute(&state.db) + .await + .unwrap(); + sqlx::query("UPDATE nodes SET media_id = 'v2' WHERE id = ?1") + .bind(id) + .execute(&state.db) + .await + .unwrap(); + assert!(found(&state, "lentils").await.is_empty()); + catch_up(&state.db, &uploads).await.unwrap(); + assert_eq!(found(&state, "chickpeas").await.len(), 1); + } +} diff --git a/src/routes/import.rs b/src/routes/import.rs index 3079ddb..e0bef9a 100644 --- a/src/routes/import.rs +++ b/src/routes/import.rs @@ -23,7 +23,7 @@ use crate::{ auth::Session, error::{AppError, AppResult}, routes::{ - boards, links, + boards, file_text, links, manifest::{self, Manifest, NodeMeta}, media, nodes::{self, next_slug, slugify, DOCUMENT, FILE, FOLDER, SPACE}, @@ -559,6 +559,7 @@ async fn run(state: &AppState, into: Into, multipart: Multipart) -> AppResult, + /// Search results only: a file found by what it says rather than by its + /// name, with the words around the match as its excerpt. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub in_text: bool, } #[derive(Serialize)] @@ -374,6 +378,7 @@ fn row_to_summary(row: &sqlx::sqlite::SqliteRow, head: &str) -> NodeSummary { sealed: row.get("sealed"), file, location: None, + in_text: false, } } @@ -509,7 +514,11 @@ pub async fn list( let rows = match search.and_then(fts_query) { // A search reaches through the whole tree, optionally confined to one // space: the recursive term collects that space, or every space, and - // everything under it — which leaves the trash out. + // everything under it — which leaves the trash out. It asks both + // indexes, names and documents in one and the text of files in the + // other, and a node found in both is listed once, at its better rank. + // The two ranks are BM25 over different tables, so their order is + // approximate, which is good enough for a short list. Some(query) => { sqlx::query(&format!( "WITH RECURSIVE sub (id) AS ( @@ -517,14 +526,25 @@ pub async fn list( WHERE id = ?4 OR (?4 IS NULL AND parent_id IS NULL AND kind = 'space') UNION ALL SELECT n.id FROM nodes n JOIN sub ON n.parent_id = sub.id + ), + hits (id, rank, snippet) AS ( + SELECT rowid, rank, NULL FROM nodes_fts WHERE nodes_fts MATCH ?1 + UNION ALL + SELECT rowid, rank, snippet(files_fts, 0, '', '', '…', 16) + FROM files_fts WHERE files_fts MATCH ?1 + ), + best AS ( + SELECT id, MIN(rank) AS rank, MAX(snippet) AS snippet + FROM hits GROUP BY id ) - SELECT {SUMMARY_COLUMNS}, substr(n.body, 1, {HEAD_CHARS}) AS head - FROM nodes_fts - JOIN nodes n ON n.id = nodes_fts.rowid - WHERE nodes_fts MATCH ?1 - AND n.kind <> 'space' + SELECT {SUMMARY_COLUMNS}, + COALESCE(best.snippet, substr(n.body, 1, {HEAD_CHARS})) AS head, + best.snippet IS NOT NULL AS in_text + FROM best + JOIN nodes n ON n.id = best.id + WHERE n.kind <> 'space' AND n.id IN (SELECT id FROM sub) - ORDER BY rank + ORDER BY best.rank LIMIT ?2 OFFSET ?3" )) .bind(query) @@ -557,7 +577,10 @@ pub async fn list( .iter() .map(|row| { let head: String = row.try_get("head").unwrap_or_default(); - row_to_summary(row, &head) + NodeSummary { + in_text: row.try_get("in_text").unwrap_or(false), + ..row_to_summary(row, &head) + } }) .collect(); if searching { diff --git a/src/routes/seal.rs b/src/routes/seal.rs index 0110d80..e556d2a 100644 --- a/src/routes/seal.rs +++ b/src/routes/seal.rs @@ -34,7 +34,7 @@ use uuid::Uuid; use crate::{ auth::Session, error::{AppError, AppResult}, - routes::{media::MEDIA_REF, nodes}, + routes::{file_text, media::MEDIA_REF, nodes}, state::AppState, vault, }; @@ -248,6 +248,10 @@ async fn reseal(state: &AppState, id: i64, seal: bool) -> AppResult { } if files > 0 { + // Sealing dropped the text with the old bytes; opened, it is read again. + if !seal { + file_text::refresh(&state.db, &state.config.uploads_dir()).await; + } state.backup.signal(); } // Asked about one file, the reason it failed is the answer. diff --git a/src/sftp/fs.rs b/src/sftp/fs.rs index 154a1ad..b83244c 100644 --- a/src/sftp/fs.rs +++ b/src/sftp/fs.rs @@ -35,7 +35,7 @@ use crate::{ Backup, }, error::{AppError, AppResult}, - routes::{links, media, nodes, revisions, seal, trash}, + routes::{file_text, links, media, nodes, revisions, seal, trash}, state::AppState, vault, }; @@ -985,6 +985,7 @@ impl SpaceFs { let _ = tokio::fs::remove_file(self.uploads.join(&media_id)).await; return Err(StatusCode::Failure); } + file_text::refresh(&self.db, &self.uploads).await; self.changed(); Ok(()) } diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index d0f523c..21b449b 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -39,7 +39,7 @@ ['b', "open this space's board"], ['c', "capture a card into this space's inbox, without leaving here"], ['Esc · q', 'close the document, and its tile when there are several'], - ['/', 'full-text search, inside the current space — Tab or Ctrl-N/P walk the results, Enter opens one'], + ['/', 'full-text search, inside the current space and its text files — Tab or Ctrl-N/P walk the results, Enter opens one'], ['n / N', 'next / previous search result, opened beside the results — :noh clears the search'], ['s', 'toggle sharing for the document — asks before taking it back'], ['y', 'mint a new share link and copy it — asks, the old one stops working'], diff --git a/web/src/lib/Sidebar.svelte b/web/src/lib/Sidebar.svelte index 45db900..fb9127a 100644 --- a/web/src/lib/Sidebar.svelte +++ b/web/src/lib/Sidebar.svelte @@ -130,6 +130,7 @@ {label(node)}{#if isContainer(node)}/{/if} {#if workspace.query && node.location}{node.location}{/if} + {#if workspace.query && node.in_text}{node.excerpt}{/if} {#if workspace.isMarked(node.id)}m{/if} {#if node.shared}{/if} @@ -259,6 +260,16 @@ /* Where a search match sits, so two of the same name can be told apart. */ .where { margin-left: 8px; font-family: var(--font-mono); font-size: 11px; color: var(--muted); } .row.active .where { color: var(--ink); } + /* A file found by its text, not its name: the words it was found by, below. */ + .hit { + display: block; + overflow: hidden; + text-overflow: ellipsis; + font-family: var(--font-mono); + font-size: 11px; + color: var(--muted); + } + .row.active .hit { color: var(--ink); } .aside { flex: none; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; } .badge { flex: none; font-size: 11px; font-weight: 700; color: var(--light-dim); } .shared { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 13e52e4..0b10444 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -36,6 +36,8 @@ export interface NodeSummary { file: FileInfo | null /** Search results only: where the match sits, `work/notes`. */ location?: string + /** Search results only: a file found by what it says; `excerpt` is the words around the match. */ + in_text?: boolean } /** One step of a node's path, the space first and the node itself last. */