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. */