//! The local SQLite store: every operation the repository seam needs, as plain //! functions over a `&Connection`. The Tauri commands (commands.rs) lock the shared //! connection and call these; keeping the SQL here (off the command layer) makes it //! unit-testable against an in-memory database. //! //! Timestamps are emitted exactly like JS `Date.toISOString()` //! ("YYYY-MM-DDTHH:MM:SS.sssZ") so string ordering and date-range comparisons line //! up with the values the frontend sends. use chrono::{DateTime, Duration, SecondsFormat, Utc}; use rusqlite::{params, params_from_iter, Connection, OptionalExtension}; use serde_json::{json, Value}; use uuid::Uuid; use crate::local::derive; use crate::local::models::*; use crate::local::recur; fn now() -> String { Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true) } fn new_id() -> String { Uuid::new_v4().to_string() } /// The note's NAME: the first line of its body that says anything. /// /// Mirrors `derive_display_title` in the server's notes/helpers.py — one rule written /// twice, and they have to agree or a synced note is called different things on either /// side of the wire. /// /// It no longer needs the items, because the items ARE lines of the body now (M304). /// What it needs instead is to strip the task marker off: a list-only note is still /// named by its first item, and calling that note "- [ ] milk" would be showing /// someone the storage rather than the note. An empty item is skipped rather than /// naming the note "", which is what a half-typed list would otherwise do. fn display_title(body: &str) -> String { for line in body.lines() { let text = derive::strip_marker(line.trim()).trim(); if !text.is_empty() { return text.to_string(); } } String::new() } fn escape_like(s: &str) -> String { s.replace('\\', "\\\\") .replace('%', "\\%") .replace('_', "\\_") } // ---- note assembly ---------------------------------------------------------- fn load_labels(conn: &Connection, note_id: &str) -> rusqlite::Result> { let mut stmt = conn.prepare( "SELECT l.id, l.name, l.color, nl.via_tag FROM note_labels nl JOIN labels l ON l.id = nl.label_id WHERE nl.note_id = ?1 ORDER BY l.name COLLATE NOCASE", )?; let rows = stmt.query_map([note_id], |r| { Ok(NoteLabel { id: r.get(0)?, name: r.get(1)?, color: r.get(2)?, via_tag: r.get(3)?, }) })?; rows.collect() } /// The note's checklist, read out of its body. No query, because there is no table. /// /// A `- [ ] milk` line IS the item (M304). The id is the item's ORDINAL rather than a /// uuid — which is all it ever amounted to anyway, since `push.rs` sent text and /// checked and never an id, and both sides replaced the whole list on every sync. It /// is also exactly what the rewriters in `derive` take, so a UI holding an id can act /// on it directly. fn items_of(body: &str) -> Vec { derive::extract_items(body) .into_iter() .enumerate() .map(|(i, item)| ChecklistItem { id: i.to_string(), text: item.text, checked: item.checked, position: i as i64, }) .collect() } fn load_attachments(conn: &Connection, note_id: &str) -> rusqlite::Result> { let mut stmt = conn.prepare( "SELECT id, url, filename, mime, size, sha256 FROM attachments WHERE note_id = ?1 ORDER BY position ASC", )?; let rows = stmt.query_map([note_id], |r| { let server_url: String = r.get(1)?; let mime: String = r.get(3)?; let sha256: Option = r.get(5)?; Ok(Attachment { id: r.get(0)?, // Point at the LOCAL bytes, not the server's route. The stored url is the // server's relative path, which resolves against the app origin in the // webview and 404s — and even absolute it would need a bearer token the // webview never sends. Rewriting here rather than at each render site // means NoteCard and NoteEditor stay untouched and can't drift. // // Without a hash there's nothing to address the blob by (an older server // that predates the sha256 column), so the original url is left alone: // still broken, but no more broken than it already was. url: match sha256.as_deref() { Some(hash) if !hash.is_empty() => crate::sync::blobs::url_for(hash, &mime), _ => server_url, }, filename: r.get(2)?, mime, size: r.get(4)?, sha256, }) })?; rows.collect() } fn load_previews(conn: &Connection, note_id: &str) -> rusqlite::Result> { let mut stmt = conn.prepare( "SELECT id, url, title, description, image_url, site_name FROM link_previews WHERE note_id = ?1 ORDER BY position ASC", )?; let rows = stmt.query_map([note_id], |r| { Ok(LinkPreview { id: r.get(0)?, url: r.get(1)?, title: r.get(2)?, description: r.get(3)?, image_url: r.get(4)?, site_name: r.get(5)?, }) })?; rows.collect() } fn load_note(conn: &Connection, id: &str) -> rusqlite::Result { let mut note = conn.query_row( "SELECT id, body, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at FROM notes WHERE id = ?1", [id], |r| { let body: String = r.get(1)?; Ok(Note { id: r.get(0)?, display_title: String::new(), // filled below — it may need a query body, position: r.get(2)?, pinned: r.get(3)?, archived: r.get(4)?, trashed: r.get(5)?, deleted_at: r.get(10)?, remind_at: r.get(6)?, recurrence: r.get(7)?, labels: Vec::new(), items: Vec::new(), attachments: Vec::new(), previews: Vec::new(), created_at: r.get(9)?, updated_at: r.get(10)?, }) }, )?; note.labels = load_labels(conn, id)?; note.items = items_of(¬e.body); note.attachments = load_attachments(conn, id)?; note.previews = load_previews(conn, id)?; note.display_title = display_title(¬e.body); Ok(note) } fn touch(conn: &Connection, id: &str) -> rusqlite::Result<()> { conn.execute( "UPDATE notes SET updated_at = ?1, dirty = 1 WHERE id = ?2", params![now(), id], )?; Ok(()) } // ---- #tag -> label derivation ---------------------------------------------- fn find_or_create_label(conn: &Connection, name: &str) -> rusqlite::Result { let existing: Option = conn .query_row( "SELECT id FROM labels WHERE lower(name) = lower(?1)", [name], |r| r.get(0), ) .optional()?; if let Some(id) = existing { return Ok(id); } let id = new_id(); let ts = now(); conn.execute( "INSERT INTO labels (id, name, color, created_at, updated_at, dirty) VALUES (?1, ?2, 'default', ?3, ?3, 1)", params![id, name, ts], )?; Ok(id) } /// Attach the note's tag labels, LIFT its standalone tags out of the body, and write /// the shortened body back. /// /// NAMED FOR THE MUTATION. It used to be `sync_tags` and only touched label rows; it /// now rewrites `notes.body`, and every caller writes the body just before calling — /// so this overwrites what they wrote, on purpose. /// /// `display_title` needs no attention here, unlike on the server: the core derives it /// on READ (see `display_title` above, called from `load_note`) rather than storing /// it, so there is no persisted copy to go stale. /// /// The two kinds of tag are handled differently, and that difference IS what `via_tag` /// means from here on — backed by text still in the body: /// /// standalone lifted out, attached as an ORDINARY label. Nothing derives it any /// more, and the way to remove it becomes the chip's ×. /// inline left in place, attached via_tag = 1, still detached when its text /// goes. Unchanged from before. /// /// Mirrors `_lift_and_reconcile_tags` in the server's `notes/tags.py`. fn lift_and_sync_tags(conn: &Connection, note_id: &str, body: &str) -> rusqlite::Result<()> { let (standalone, inline, lifted) = derive::lift_standalone_tags(body); let mut standalone_ids: Vec = Vec::with_capacity(standalone.len()); for name in &standalone { standalone_ids.push(find_or_create_label(conn, name)?); } let mut inline_ids: Vec = Vec::with_capacity(inline.len()); for name in &inline { inline_ids.push(find_or_create_label(conn, name)?); } let current: Vec<(String, bool)> = { let mut stmt = conn.prepare("SELECT label_id, via_tag FROM note_labels WHERE note_id = ?1")?; let rows = stmt.query_map([note_id], |r| { Ok((r.get::<_, String>(0)?, r.get::<_, bool>(1)?)) })?; rows.collect::>>()? }; for (lid, via_tag) in ¤t { if !*via_tag { continue; // manual already: a #tag of the same name changes nothing } if standalone_ids.contains(lid) { // It GRADUATED. The text backing it is about to go, so the row has to // become the record instead — and BEFORE the delete below, or the same row // is dropped for no longer being in the body. That is the bug a naive lift // has, and it silently loses the tag. conn.execute( "UPDATE note_labels SET via_tag = 0 WHERE note_id = ?1 AND label_id = ?2", params![note_id, lid], )?; } else if !inline_ids.contains(lid) { conn.execute( "DELETE FROM note_labels WHERE note_id = ?1 AND label_id = ?2 AND via_tag = 1", params![note_id, lid], )?; } } // OR IGNORE leaves a label already attached in ANY form alone, which is what keeps // a manually-added label of the same name manual. for lid in &standalone_ids { conn.execute( "INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 0)", params![note_id, lid], )?; } for lid in &inline_ids { conn.execute( "INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 1)", params![note_id, lid], )?; } if lifted != body { conn.execute( "UPDATE notes SET body = ?1 WHERE id = ?2", params![lifted, note_id], )?; } Ok(()) } // ---- notes: read ------------------------------------------------------------ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result> { let mut sql = String::from("SELECT id FROM notes WHERE "); sql.push_str(match q.view.as_str() { "trash" => "trashed = 1", "archived" => "trashed = 0 AND archived = 1", _ => "trashed = 0 AND archived = 0", }); let mut binds: Vec = Vec::new(); // Label filters (sidebar label + facet labels) are ANDed: a note must carry all. let mut label_ids: Vec = Vec::new(); if let Some(l) = q.label_id.as_deref().filter(|s| !s.is_empty()) { label_ids.push(l.to_string()); } if let Some(f) = &q.facets { if let Some(ls) = &f.label { for l in ls.iter().filter(|s| !s.is_empty()) { label_ids.push(l.clone()); } } } for lid in &label_ids { sql.push_str(" AND EXISTS (SELECT 1 FROM note_labels nl WHERE nl.note_id = notes.id AND nl.label_id = ?)"); binds.push(lid.clone()); } if let Some(f) = &q.facets { if let Some(text) = f.q.as_deref().filter(|s| !s.is_empty()) { sql.push_str(" AND body LIKE ? ESCAPE '\\'"); let pat = format!("%{}%", escape_like(text)); binds.push(pat.clone()); binds.push(pat); } if f.has_reminder == Some(true) { sql.push_str(" AND remind_at IS NOT NULL"); } if f.has_attachment == Some(true) { sql.push_str(" AND EXISTS (SELECT 1 FROM attachments a WHERE a.note_id = notes.id)"); } if let Some(a) = f.created_after.as_deref().filter(|s| !s.is_empty()) { sql.push_str(" AND created_at >= ?"); binds.push(a.to_string()); } if let Some(b) = f.created_before.as_deref().filter(|s| !s.is_empty()) { sql.push_str(" AND created_at < ?"); binds.push(b.to_string()); } } sql.push_str(if q.sort.as_deref() == Some("created") { " ORDER BY created_at DESC" } else { " ORDER BY pinned DESC, position DESC, updated_at DESC" }); let ids: Vec = { let mut stmt = conn.prepare(&sql)?; let rows = stmt.query_map(params_from_iter(binds.iter()), |r| r.get::<_, String>(0))?; rows.collect::>>()? }; ids.iter().map(|id| load_note(conn, id)).collect() } pub fn get_note(conn: &Connection, id: &str) -> rusqlite::Result { load_note(conn, id) } pub fn reminders(conn: &Connection) -> rusqlite::Result> { let ids: Vec = { let mut stmt = conn.prepare("SELECT id FROM notes WHERE trashed = 0 AND remind_at IS NOT NULL ORDER BY remind_at ASC")?; let rows = stmt.query_map([], |r| r.get::<_, String>(0))?; rows.collect::>>()? }; ids.iter().map(|id| load_note(conn, id)).collect() } pub fn titles(conn: &Connection) -> rusqlite::Result> { // Names come from `load_note` rather than from a bare row, because a note whose // body is empty is named by its first checklist item — which a row here doesn't // have. The command palette reads this; correctness beats one query per note at // personal scale. let ids: Vec = { let mut stmt = conn.prepare("SELECT id FROM notes WHERE trashed = 0")?; let rows = stmt.query_map([], |r| r.get(0))?; rows.collect::>>()? }; ids.iter() .map(|id| { let note = load_note(conn, id)?; Ok(TitleEntry { id: note.id, title: note.display_title, }) }) .collect() } pub fn search(conn: &Connection, q: &str) -> rusqlite::Result> { let pat = format!("%{}%", escape_like(q)); let ids: Vec = { let mut stmt = conn.prepare( "SELECT id FROM notes WHERE trashed = 0 AND body LIKE ?1 ESCAPE '\\' ORDER BY updated_at DESC", )?; let rows = stmt.query_map([&pat], |r| r.get::<_, String>(0))?; rows.collect::>>()? }; ids.iter().map(|id| load_note(conn, id)).collect() } // ---- notes: write ----------------------------------------------------------- pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Result { let id = new_id(); let ts = now(); let position: i64 = conn.query_row( "SELECT COALESCE(MAX(position), 0) + 1 FROM notes", [], |r| r.get(0), )?; // Items fold into the body rather than into rows of their own. Callers still hand // them over separately — the importer has a list, not a blob — but where they end // up is one place. let mut body = input.body.clone(); if let Some(items) = &input.items { for text in items { body = derive::append_item(&body, text, false); } } conn.execute( "INSERT INTO notes (id, body, position, created_at, updated_at, dirty) VALUES (?1, ?2, ?3, ?4, ?4, 1)", params![id, body, position, ts], )?; // The FOLDED body, not the input one: an item can carry a #tag too. lift_and_sync_tags(conn, &id, &body)?; load_note(conn, &id) } /// How long one editing session is assumed to last. /// /// Inside this window a note's body may be written any number of times and only the /// FIRST write snapshots. That is what makes an idle-debounced autosave affordable: /// a write costs a write, not a write plus a revision. const REVISION_WINDOW_MINUTES: i64 = 10; /// Whether a body change earns a snapshot of the pre-edit body. /// /// Two conditions. The body must actually differ — re-saving identical text is not a /// version of anything. And the note must not already carry a revision from this /// editing session. /// /// The session rule is what keeps version history worth reading. Because /// [`snapshot_revision`] stores the body as it was BEFORE the edit, the first write /// of a session captures the note as you found it, and every write after it inside /// the window adds nothing. One revision per sitting falls out of the window on its /// own — no "commit" the client has to declare, and no protocol surface to carry it, /// which matters because sync-apply takes this same path. fn should_snapshot(conn: &Connection, id: &str, new_body: &str) -> rusqlite::Result { let current: String = conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?; if current == new_body { return Ok(false); } // String comparison, not date maths: timestamps are RFC3339 UTC with a fixed // millisecond field (see the module header), so lexical order IS chronological. let cutoff = (Utc::now() - Duration::minutes(REVISION_WINDOW_MINUTES)) .to_rfc3339_opts(SecondsFormat::Millis, true); let recent: i64 = conn.query_row( "SELECT COUNT(*) FROM note_revisions WHERE note_id = ?1 AND created_at >= ?2", params![id, cutoff], |r| r.get(0), )?; Ok(recent == 0) } fn snapshot_revision(conn: &Connection, id: &str) -> rusqlite::Result<()> { let body: String = conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?; conn.execute( "INSERT INTO note_revisions (id, note_id, body, created_at) VALUES (?1, ?2, ?3, ?4)", params![new_id(), id, body, now()], )?; Ok(()) } /// PATCH semantics: apply exactly the fields present in `changes`. pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Result { let obj = changes .as_object() .ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?; // Snapshot the pre-edit body before changing it (version history) — but only // once per editing session, and only if it actually changed. See should_snapshot. if let Some(body) = obj.get("body").and_then(|v| v.as_str()) { if should_snapshot(conn, id, body)? { snapshot_revision(conn, id)?; } } for (k, v) in obj { match k.as_str() { "body" => { let body = v.as_str().unwrap_or(""); conn.execute( "UPDATE notes SET body = ?1 WHERE id = ?2", params![body, id], )?; lift_and_sync_tags(conn, id, body)?; } "pinned" => { if let Some(b) = v.as_bool() { conn.execute("UPDATE notes SET pinned = ?1 WHERE id = ?2", params![b, id])?; } } "archived" => { if let Some(b) = v.as_bool() { conn.execute( "UPDATE notes SET archived = ?1 WHERE id = ?2", params![b, id], )?; } } "remind_at" => { let val = v.as_str().map(|s| s.to_string()); conn.execute( "UPDATE notes SET remind_at = ?1 WHERE id = ?2", params![val, id], )?; } "recurrence" => { let val = v.as_str().map(|s| s.to_string()); conn.execute( "UPDATE notes SET recurrence = ?1 WHERE id = ?2", params![val, id], )?; } _ => {} } } touch(conn, id)?; load_note(conn, id) } /// Mark a reminder handled. /// /// A recurring reminder advances to its next occurrence; a one-off clears both /// `remind_at` AND `recurrence`. Clearing the rule as well matters: without it a /// note whose recurrence is a value we do not recognise would keep that value /// forever, invisible in every UI (they only render known rules) and waiting to /// mean something the day the vocabulary grows. /// /// Same behaviour as the server's `POST //reminder/complete`, deliberately — /// the same note can be completed from a browser or from a client, and a /// disagreement here would move a reminder depending on which one you used. pub fn complete_reminder(conn: &Connection, id: &str) -> rusqlite::Result { let note = load_note(conn, id)?; let next = note .remind_at .as_deref() .and_then(|at| DateTime::parse_from_rfc3339(at).ok()) .and_then(|at| { let rule = recur::normalize(note.recurrence.as_deref())?; recur::next_occurrence(at.with_timezone(&Utc), rule, Utc::now()) }); match next { Some(at) => conn.execute( "UPDATE notes SET remind_at = ?1 WHERE id = ?2", params![at.to_rfc3339_opts(SecondsFormat::Millis, true), id], )?, None => conn.execute( "UPDATE notes SET remind_at = NULL, recurrence = NULL WHERE id = ?1", [id], )?, }; touch(conn, id)?; load_note(conn, id) } pub fn snooze_reminder(conn: &Connection, id: &str, minutes: i64) -> rusqlite::Result { let t = (Utc::now() + Duration::minutes(minutes)).to_rfc3339_opts(SecondsFormat::Millis, true); conn.execute( "UPDATE notes SET remind_at = ?1 WHERE id = ?2", params![t, id], )?; touch(conn, id)?; load_note(conn, id) } pub fn set_labels(conn: &Connection, id: &str, label_ids: &[String]) -> rusqlite::Result { // Manual labels are replaced wholesale; #tag (via_tag) labels are managed by text. conn.execute( "DELETE FROM note_labels WHERE note_id = ?1 AND via_tag = 0", [id], )?; for lid in label_ids { conn.execute( "INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 0)", params![id, lid], )?; } touch(conn, id)?; load_note(conn, id) } // ---- checklist items: every one of these is a body edit --------------------- // // They keep their own names and signatures because the FFI, the Tauri commands and // the REST shape all speak in items, and a checklist is still a thing a note HAS. // What changed is where it is kept. Routing all three through `update_note` rather // than writing the body directly is what gives them revision snapshotting, `#tag` // re-derivation and the dirty/updated_at bookkeeping without any of it being // written a second time here. fn note_body(conn: &Connection, id: &str) -> rusqlite::Result { conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0)) } /// An item's id is its ordinal (see [items_of]). Anything else is a stale id from a /// UI that has not reloaded, and the right answer to those is to do nothing. fn item_index(item_id: &str) -> Option { item_id.parse::().ok() } fn set_body(conn: &Connection, id: &str, body: String) -> rusqlite::Result { update_note(conn, id, &json!({ "body": body })) } pub fn add_item(conn: &Connection, id: &str, text: &str) -> rusqlite::Result { let body = note_body(conn, id)?; set_body(conn, id, derive::append_item(&body, text, false)) } pub fn update_item( conn: &Connection, id: &str, item_id: &str, changes: &Value, ) -> rusqlite::Result { let index = match item_index(item_id) { Some(i) => i, None => return load_note(conn, id), }; let mut body = note_body(conn, id)?; if let Some(text) = changes.get("text").and_then(Value::as_str) { body = derive::set_item_text(&body, index, text); } if let Some(checked) = changes.get("checked").and_then(Value::as_bool) { body = derive::set_item_checked(&body, index, checked); } set_body(conn, id, body) } pub fn delete_item(conn: &Connection, id: &str, item_id: &str) -> rusqlite::Result { let index = match item_index(item_id) { Some(i) => i, None => return load_note(conn, id), }; let body = note_body(conn, id)?; set_body(conn, id, derive::remove_item(&body, index)) } pub fn delete_attachment(conn: &Connection, id: &str, att_id: &str) -> rusqlite::Result { conn.execute( "DELETE FROM attachments WHERE id = ?1 AND note_id = ?2", params![att_id, id], )?; touch(conn, id)?; load_note(conn, id) } pub fn delete_preview(conn: &Connection, id: &str, preview_id: &str) -> rusqlite::Result { conn.execute( "DELETE FROM link_previews WHERE id = ?1 AND note_id = ?2", params![preview_id, id], )?; touch(conn, id)?; load_note(conn, id) } pub fn reorder(conn: &Connection, ordered_ids: &[String]) -> rusqlite::Result<()> { let total = ordered_ids.len() as i64; for (i, id) in ordered_ids.iter().enumerate() { conn.execute( "UPDATE notes SET position = ?1, dirty = 1 WHERE id = ?2", params![total - i as i64, id], )?; } Ok(()) } pub fn trash(conn: &Connection, id: &str) -> rusqlite::Result { // COALESCE, so trashing an already-trashed note doesn't restart its retention // clock. The server keeps its `deleted_at` the same way — a note shouldn't earn // another 30 days because something touched it twice. conn.execute( "UPDATE notes SET trashed = 1, trashed_at = COALESCE(trashed_at, ?1) WHERE id = ?2", params![now(), id], )?; touch(conn, id)?; load_note(conn, id) } pub fn restore(conn: &Connection, id: &str) -> rusqlite::Result { conn.execute( "UPDATE notes SET trashed = 0, trashed_at = NULL WHERE id = ?1", [id], )?; touch(conn, id)?; load_note(conn, id) } pub fn delete_forever(conn: &Connection, id: &str) -> rusqlite::Result<()> { record_pending_delete(conn, "note", id)?; conn.execute("DELETE FROM notes WHERE id = ?1", [id])?; Ok(()) } /// Remember that a row was permanently deleted, so the sync engine can tell the /// server. Without this the deleted row leaves no trace at all, and the next pull /// would resurrect it — a delete that quietly undoes itself. /// /// Harmless when the app is unlinked: the row is simply never read, and a later push /// gets a `noop` for an id the server never had. pub fn record_pending_delete(conn: &Connection, entity: &str, id: &str) -> rusqlite::Result<()> { conn.execute( "INSERT OR REPLACE INTO pending_deletes (entity, id, deleted_at) VALUES (?1, ?2, ?3)", params![entity, id, now()], )?; Ok(()) } // ---- device-local preferences (schema v5) ----------------------------------- /// A stored preference, or `None` if it was never set. Callers supply their own /// default rather than one being invented here — the meaning of "unset" belongs /// with the setting, not with the storage. pub fn pref(conn: &Connection, key: &str) -> rusqlite::Result> { conn.query_row("SELECT value FROM prefs WHERE key = ?1", [key], |r| { r.get(0) }) .optional() } pub fn set_pref(conn: &Connection, key: &str, value: &str) -> rusqlite::Result<()> { conn.execute( "INSERT INTO prefs (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value", params![key, value], )?; Ok(()) } pub fn revisions(conn: &Connection, id: &str) -> rusqlite::Result> { let mut stmt = conn .prepare("SELECT id, body, created_at FROM note_revisions WHERE note_id = ?1 ORDER BY created_at DESC")?; let rows = stmt.query_map([id], |r| { Ok(NoteRevision { id: r.get(0)?, body: r.get(1)?, created_at: r.get(2)?, }) })?; rows.collect() } pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite::Result { let body: String = conn.query_row( "SELECT body FROM note_revisions WHERE id = ?1 AND note_id = ?2", params![rev_id, id], |r| r.get(0), )?; snapshot_revision(conn, id)?; conn.execute( "UPDATE notes SET body = ?1 WHERE id = ?2", params![body, id], )?; lift_and_sync_tags(conn, id, &body)?; touch(conn, id)?; load_note(conn, id) } // ---- labels ----------------------------------------------------------------- fn load_label(conn: &Connection, id: &str) -> rusqlite::Result