diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 13016ae..cfb7de3 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -18,6 +18,12 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = [] } serde = { version = "1", features = ["derive"] } serde_json = "1" +# Local-first store (M10.4): bundled = compile SQLite in, so there's no system +# libsqlite dependency to vary across the AppImage / native / Windows builds. +rusqlite = { version = "0.32", features = ["bundled"] } +uuid = { version = "1", features = ["v4"] } +# RFC3339 timestamps for created_at/updated_at/remind_at (Date.parse-able on the JS side). +chrono = { version = "0.4", default-features = false, features = ["clock"] } # Tauri's default release profile: smaller, faster shipped binaries. [profile.release] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d186010..8af6499 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ //! AppImage) and boots the UI. mod integration; +mod local; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -14,10 +15,56 @@ pub fn run() { harden_linux_webkit_rendering(); tauri::Builder::default() + .setup(|app| { + use tauri::Manager; + // The on-device store lives in the platform app-data dir (e.g. Linux + // ~/.local/share/com.fabledsword.thoughtsync/thoughtsync.db), created on + // first launch. This is what makes the app work with no server or login. + let dir = app.path().app_data_dir()?; + std::fs::create_dir_all(&dir)?; + app.manage(local::open(&dir.join("thoughtsync.db"))?); + Ok(()) + }) .invoke_handler(tauri::generate_handler![ integration::integration_status, integration::integrate_desktop, integration::unintegrate_desktop, + local::commands::config_get, + local::commands::auth_me, + local::commands::notes_list, + local::commands::notes_get, + local::commands::notes_create, + local::commands::notes_create_titled, + local::commands::notes_update, + local::commands::notes_complete_reminder, + local::commands::notes_snooze_reminder, + local::commands::notes_set_labels, + local::commands::notes_add_item, + local::commands::notes_update_item, + local::commands::notes_delete_item, + local::commands::notes_delete_attachment, + local::commands::notes_delete_preview, + local::commands::notes_reorder, + local::commands::notes_trash, + local::commands::notes_restore, + local::commands::notes_delete_forever, + local::commands::notes_revisions, + local::commands::notes_restore_revision, + local::commands::notes_reminders, + local::commands::notes_titles, + local::commands::notes_search, + local::commands::notes_backlinks, + local::commands::notes_link_search, + local::commands::labels_list, + local::commands::labels_create, + local::commands::labels_rename, + local::commands::labels_set_color, + local::commands::labels_remove, + local::commands::labels_merge, + local::commands::saved_filters_list, + local::commands::saved_filters_create, + local::commands::saved_filters_remove, + local::commands::saved_filters_rename, ]) .run(tauri::generate_context!()) .expect("error while running the ThoughtSync desktop app"); diff --git a/desktop/src-tauri/src/local/commands.rs b/desktop/src-tauri/src/local/commands.rs new file mode 100644 index 0000000..e000a5d --- /dev/null +++ b/desktop/src-tauri/src/local/commands.rs @@ -0,0 +1,241 @@ +//! Tauri command surface for the local store — the offline mirror of the REST API +//! the frontend's repository seam calls. Each command locks the shared connection and +//! delegates to `store`; errors are stringified for the JS boundary. adapters/local.ts +//! (M10.5) invokes these. + +use serde_json::Value; +use tauri::State; + +use crate::local::models::*; +use crate::local::store; +use crate::local::Db; + +// A macro would hide the (very regular) locking; kept explicit so each command reads +// as an obvious lock -> delegate -> stringify. + +#[tauri::command] +pub fn config_get() -> PublicConfig { + // Offline defaults: no signups, no server-side URL unfurling (needs network). + PublicConfig { + site_name: "ThoughtSync".to_string(), + allow_registration: false, + version: env!("CARGO_PKG_VERSION").to_string(), + enable_url_unfurl: false, + } +} + +#[tauri::command] +pub fn auth_me() -> User { + // The single synthetic local user, so the auth-gated router resolves with no login. + User { + id: "local".to_string(), + email: "local@thoughtsync.app".to_string(), + display_name: "You".to_string(), + email_verified: true, + is_admin: false, + } +} + +#[tauri::command] +pub fn notes_list(query: ListQuery, db: State<'_, Db>) -> Result, String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::list_notes(&conn, &query).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn notes_get(id: String, db: State<'_, Db>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::get_note(&conn, &id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn notes_create(input: NoteCreateInput, db: State<'_, Db>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::create_note(&conn, &input).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn notes_create_titled(title: String, db: State<'_, Db>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::create_titled(&conn, &title).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn notes_update(id: String, changes: Value, db: State<'_, Db>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::update_note(&conn, &id, &changes).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn notes_complete_reminder(id: String, db: State<'_, Db>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::complete_reminder(&conn, &id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn notes_snooze_reminder(id: String, minutes: i64, db: State<'_, Db>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::snooze_reminder(&conn, &id, minutes).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn notes_set_labels(id: String, label_ids: Vec, db: State<'_, Db>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::set_labels(&conn, &id, &label_ids).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn notes_add_item(id: String, text: String, db: State<'_, Db>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::add_item(&conn, &id, &text).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn notes_update_item(id: String, item_id: String, changes: Value, db: State<'_, Db>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::update_item(&conn, &id, &item_id, &changes).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn notes_delete_item(id: String, item_id: String, db: State<'_, Db>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::delete_item(&conn, &id, &item_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn notes_delete_attachment(id: String, att_id: String, db: State<'_, Db>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::delete_attachment(&conn, &id, &att_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn notes_delete_preview(id: String, preview_id: String, db: State<'_, Db>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::delete_preview(&conn, &id, &preview_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn notes_reorder(ordered_ids: Vec, db: State<'_, Db>) -> Result<(), String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::reorder(&conn, &ordered_ids).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn notes_trash(id: String, db: State<'_, Db>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::trash(&conn, &id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn notes_restore(id: String, db: State<'_, Db>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::restore(&conn, &id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn notes_delete_forever(id: String, db: State<'_, Db>) -> Result<(), String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::delete_forever(&conn, &id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn notes_revisions(id: String, db: State<'_, Db>) -> Result, String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::revisions(&conn, &id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn notes_restore_revision(id: String, rev_id: String, db: State<'_, Db>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::restore_revision(&conn, &id, &rev_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn notes_reminders(db: State<'_, Db>) -> Result, String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::reminders(&conn).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn notes_titles(db: State<'_, Db>) -> Result, String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::titles(&conn).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn notes_search(q: String, db: State<'_, Db>) -> Result, String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::search(&conn, &q).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn notes_backlinks(id: String, db: State<'_, Db>) -> Result, String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::backlinks(&conn, &id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn notes_link_search(q: String, db: State<'_, Db>) -> Result, String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::link_search(&conn, &q).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn labels_list(db: State<'_, Db>) -> Result, String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::list_labels(&conn).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn labels_create(name: String, db: State<'_, Db>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::create_label(&conn, &name).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn labels_rename(id: String, name: String, db: State<'_, Db>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::rename_label(&conn, &id, &name).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn labels_set_color(id: String, color: String, db: State<'_, Db>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::set_label_color(&conn, &id, &color).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn labels_remove(id: String, db: State<'_, Db>) -> Result<(), String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::remove_label(&conn, &id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn labels_merge(source_id: String, into: String, db: State<'_, Db>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::merge_labels(&conn, &source_id, &into).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn saved_filters_list(db: State<'_, Db>) -> Result, String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::list_saved_filters(&conn).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn saved_filters_create(name: String, params: Value, db: State<'_, Db>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::create_saved_filter(&conn, &name, ¶ms).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn saved_filters_remove(id: String, db: State<'_, Db>) -> Result<(), String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::remove_saved_filter(&conn, &id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn saved_filters_rename(id: String, name: String, db: State<'_, Db>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::rename_saved_filter(&conn, &id, &name).map_err(|e| e.to_string()) +} diff --git a/desktop/src-tauri/src/local/derive.rs b/desktop/src-tauri/src/local/derive.rs new file mode 100644 index 0000000..db46107 --- /dev/null +++ b/desktop/src-tauri/src/local/derive.rs @@ -0,0 +1,113 @@ +//! Deriving `[[wiki-links]]` and `#tags` from a note's body — the local mirror of +//! what the server computes on save. Pure string scanning (no regex dependency), +//! kept in lockstep with the frontend's inline rules (see frontend notes/markdown.ts): +//! +//! - `[[link]]`: `[[` … `]]` with no brackets inside, inner text trimmed. Used to +//! compute backlinks at query time (links are derived, never stored/synced). +//! - `#tag`: `#` at a word boundary followed by tag characters (letter first). +//! On save these become labels attached with `via_tag = true`. +//! +//! Both dedupe case-insensitively, preserving first-seen order. + +/// Extract the trimmed inner text of every `[[wiki-link]]` in `body`. +pub fn extract_links(body: &str) -> Vec { + let bytes = body.as_bytes(); + let mut out: Vec = Vec::new(); + let mut i = 0; + while i + 1 < bytes.len() { + if bytes[i] == b'[' && bytes[i + 1] == b'[' { + if let Some(rel) = body[i + 2..].find("]]") { + let inner = &body[i + 2..i + 2 + rel]; + // Mirror the frontend's `[^[\]]+`: no stray brackets inside. + if !inner.contains('[') && !inner.contains(']') { + let t = inner.trim(); + if !t.is_empty() { + push_unique(&mut out, t); + } + } + i += 2 + rel + 2; + continue; + } + } + i += 1; + } + out +} + +/// Extract every `#tag` name (without the leading `#`) from `body`. +pub fn extract_tags(body: &str) -> Vec { + let chars: Vec = body.chars().collect(); + let mut out: Vec = Vec::new(); + let mut i = 0; + while i < chars.len() { + if chars[i] == '#' { + let boundary = i == 0 || (!is_tag_char(chars[i - 1]) && chars[i - 1] != '#'); + // A tag must start with a letter (so "#1" or a bare "#" is not a tag). + if boundary && i + 1 < chars.len() && chars[i + 1].is_alphabetic() { + let mut j = i + 1; + while j < chars.len() && is_tag_char(chars[j]) { + j += 1; + } + let tag: String = chars[i + 1..j].iter().collect(); + push_unique(&mut out, &tag); + i = j; + continue; + } + } + i += 1; + } + out +} + +fn is_tag_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' || c == '-' +} + +fn push_unique(out: &mut Vec, candidate: &str) { + if !out.iter().any(|x| x.eq_ignore_ascii_case(candidate)) { + out.push(candidate.to_string()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn links_basic_and_trim() { + assert_eq!(extract_links("see [[ Alpha ]] and [[Beta]]"), vec!["Alpha", "Beta"]); + } + + #[test] + fn links_dedupe_case_insensitive_first_seen() { + assert_eq!(extract_links("[[Note]] then [[note]] again"), vec!["Note"]); + } + + #[test] + fn links_ignore_malformed_and_nested_brackets() { + assert_eq!(extract_links("[[a[b]] [[]] [ [x] ] plain"), Vec::::new()); + assert_eq!(extract_links("[[ok]] [[a]b]]"), vec!["ok"]); + } + + #[test] + fn tags_basic() { + assert_eq!(extract_tags("a #todo and #Work-item_2 here"), vec!["todo", "Work-item_2"]); + } + + #[test] + fn tags_require_letter_start_and_boundary() { + // "#1" (digit) and an in-word "#" (email-ish) are not tags. + assert_eq!(extract_tags("#1 nope a#b no but #Yes"), vec!["Yes"]); + } + + #[test] + fn tags_dedupe_case_insensitive() { + assert_eq!(extract_tags("#Home #home #HOME"), vec!["Home"]); + } + + #[test] + fn empty_body() { + assert!(extract_links("").is_empty()); + assert!(extract_tags("").is_empty()); + } +} diff --git a/desktop/src-tauri/src/local/mod.rs b/desktop/src-tauri/src/local/mod.rs new file mode 100644 index 0000000..b4232a6 --- /dev/null +++ b/desktop/src-tauri/src/local/mod.rs @@ -0,0 +1,26 @@ +//! The local-first core: an on-device SQLite store + Tauri commands implementing the +//! frontend's repository seam, so the desktop app is fully usable with no server and +//! no login. adapters/local.ts (M10.5) calls into `commands`. + +pub mod commands; +pub mod derive; +pub mod models; +pub mod schema; +pub mod store; + +use std::path::Path; +use std::sync::Mutex; + +use rusqlite::Connection; + +/// The shared database handle, held in Tauri's managed state. rusqlite connections +/// aren't `Sync`, so a `Mutex` serializes access (fine — operations are quick and the +/// UI is single-user). +pub struct Db(pub Mutex); + +/// Open (creating if needed) the database at `path` and bring it to the latest schema. +pub fn open(path: &Path) -> rusqlite::Result { + let conn = Connection::open(path)?; + schema::migrate(&conn)?; + Ok(Db(Mutex::new(conn))) +} diff --git a/desktop/src-tauri/src/local/models.rs b/desktop/src-tauri/src/local/models.rs new file mode 100644 index 0000000..c571ad5 --- /dev/null +++ b/desktop/src-tauri/src/local/models.rs @@ -0,0 +1,178 @@ +//! Serde shapes for the local store. Output structs mirror the JSON the frontend +//! already consumes (frontend/src/stores/*.ts) so the same components render whether +//! the data comes from REST or from these Tauri commands. Field names/optionality +//! match the TypeScript interfaces exactly. + +use serde::{Deserialize, Serialize}; + +#[derive(Serialize)] +pub struct Note { + pub id: String, + pub title: Option, + /// title if set, else the note's first body line — always present, so body-only + /// notes are still nameable and `[[link]]`-able. Derived, never stored. + pub display_title: String, + pub body: String, + pub color: String, + pub kind: String, + pub position: i64, + pub pinned: bool, + pub archived: bool, + pub trashed: bool, + pub remind_at: Option, + pub recurrence: Option, + pub labels: Vec, + pub items: Vec, + pub attachments: Vec, + pub previews: Vec, + pub created_at: Option, + pub updated_at: Option, +} + +#[derive(Serialize)] +pub struct NoteLabel { + pub id: String, + pub name: String, + pub color: String, + /// True when attached because of a `#tag` in the body (kept in sync with the text). + pub via_tag: bool, +} + +#[derive(Serialize)] +pub struct ChecklistItem { + pub id: String, + pub text: String, + pub checked: bool, + pub position: i64, +} + +#[derive(Serialize)] +pub struct Attachment { + pub id: String, + pub url: String, + pub filename: Option, + pub mime: String, + pub size: Option, + pub sha256: Option, +} + +#[derive(Serialize)] +pub struct LinkPreview { + pub id: String, + pub url: String, + pub title: Option, + pub description: Option, + pub image_url: Option, + pub site_name: Option, +} + +#[derive(Serialize)] +pub struct NoteRevision { + pub id: String, + pub title: Option, + pub body: String, + pub created_at: Option, +} + +#[derive(Serialize)] +pub struct Label { + pub id: String, + pub name: String, + pub color: String, + // Note count is only meaningful in listings; omitted elsewhere so the frontend + // preserves the count it already holds (matches the REST single-label responses). + #[serde(skip_serializing_if = "Option::is_none")] + pub count: Option, +} + +#[derive(Serialize)] +pub struct TitleEntry { + pub id: String, + pub title: String, +} + +#[derive(Serialize)] +pub struct Backlink { + pub id: String, + pub title: String, +} + +#[derive(Serialize)] +pub struct SavedFilter { + pub id: String, + pub name: String, + /// Mirrors NoteFacets — stored as a JSON blob, round-tripped opaquely. + pub params: serde_json::Value, + pub position: i64, +} + +#[derive(Serialize)] +pub struct PublicConfig { + pub site_name: String, + pub allow_registration: bool, + pub version: String, + pub enable_url_unfurl: bool, +} + +/// The synthetic single user the offline core reports, so the app's auth-gated +/// router resolves without a login. There is no real account offline. +#[derive(Serialize)] +pub struct User { + pub id: String, + pub email: String, + pub display_name: String, + pub email_verified: bool, + pub is_admin: bool, +} + +fn default_color() -> String { + "default".to_string() +} + +#[derive(Deserialize)] +pub struct NoteCreateInput { + #[serde(default)] + pub title: String, + #[serde(default)] + pub body: String, + #[serde(default = "default_color")] + pub color: String, + #[serde(default)] + pub kind: Option, + #[serde(default)] + pub items: Option>, +} + +/// The board query (mirrors adapters/repo.ts NoteListQuery). `labelId` is camelCase +/// on the wire; the facet fields are snake_case, matching NoteFacets. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListQuery { + pub view: String, + #[serde(default)] + pub label_id: Option, + #[serde(default)] + pub facets: Option, + #[serde(default)] + pub sort: Option, +} + +#[derive(Deserialize)] +pub struct Facets { + #[serde(default)] + pub q: Option, + #[serde(default)] + pub color: Option, + #[serde(default)] + pub kind: Option, + #[serde(default)] + pub label: Option>, + #[serde(default)] + pub has_reminder: Option, + #[serde(default)] + pub has_attachment: Option, + #[serde(default)] + pub created_after: Option, + #[serde(default)] + pub created_before: Option, +} diff --git a/desktop/src-tauri/src/local/schema.rs b/desktop/src-tauri/src/local/schema.rs new file mode 100644 index 0000000..63eb7d6 --- /dev/null +++ b/desktop/src-tauri/src/local/schema.rs @@ -0,0 +1,117 @@ +//! Local SQLite schema + migrations. The schema mirrors the note/label model so an +//! offline note can later sync 1:1 with the server. Each syncable row carries local +//! `sync_revision` + `dirty` bookkeeping (consumed by the sync engine in M10.7); +//! `[[links]]` are NOT stored (derived at query time), matching docs/sync.md. +//! +//! Migrations are gated on `PRAGMA user_version`; bump it and add a block per change. + +use rusqlite::Connection; + +const SCHEMA_V1: &str = r#" +CREATE TABLE notes ( + id TEXT PRIMARY KEY, + title TEXT, + body TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT 'default', + kind TEXT NOT NULL DEFAULT 'text', -- 'text' | 'list' + position INTEGER NOT NULL DEFAULT 0, + pinned INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + trashed INTEGER NOT NULL DEFAULT 0, + remind_at TEXT, + recurrence TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + sync_revision INTEGER NOT NULL DEFAULT 0, + dirty INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE labels ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT 'default', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + sync_revision INTEGER NOT NULL DEFAULT 0, + dirty INTEGER NOT NULL DEFAULT 1 +); +CREATE UNIQUE INDEX idx_labels_name ON labels (lower(name)); + +CREATE TABLE note_labels ( + note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE, + via_tag INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (note_id, label_id) +); +CREATE INDEX idx_note_labels_label ON note_labels (label_id); + +CREATE TABLE checklist_items ( + id TEXT PRIMARY KEY, + note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + text TEXT NOT NULL DEFAULT '', + checked INTEGER NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX idx_items_note ON checklist_items (note_id); + +CREATE TABLE attachments ( + id TEXT PRIMARY KEY, + note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + url TEXT NOT NULL, + filename TEXT, + mime TEXT NOT NULL DEFAULT 'application/octet-stream', + size INTEGER, + sha256 TEXT, + position INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX idx_attachments_note ON attachments (note_id); + +CREATE TABLE link_previews ( + id TEXT PRIMARY KEY, + note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + url TEXT NOT NULL, + title TEXT, + description TEXT, + image_url TEXT, + site_name TEXT, + position INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX idx_previews_note ON link_previews (note_id); + +CREATE TABLE note_revisions ( + id TEXT PRIMARY KEY, + note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + title TEXT, + body TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX idx_revisions_note ON note_revisions (note_id, created_at); + +CREATE TABLE saved_filters ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + params TEXT NOT NULL DEFAULT '{}', -- NoteFacets JSON + position INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +-- Single-row sync bookkeeping (server URL / device token / last-consumed cursor). +CREATE TABLE sync_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + server_url TEXT, + device_token TEXT, + last_cursor TEXT +); +INSERT INTO sync_state (id) VALUES (1); +"#; + +/// Bring the database up to the latest schema. Idempotent. +pub fn migrate(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch("PRAGMA foreign_keys = ON;")?; + let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?; + if version < 1 { + conn.execute_batch(SCHEMA_V1)?; + conn.execute_batch("PRAGMA user_version = 1;")?; + } + Ok(()) +} diff --git a/desktop/src-tauri/src/local/store.rs b/desktop/src-tauri/src/local/store.rs new file mode 100644 index 0000000..b2dce92 --- /dev/null +++ b/desktop/src-tauri/src/local/store.rs @@ -0,0 +1,719 @@ +//! 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::{Duration, SecondsFormat, Utc}; +use rusqlite::{params, params_from_iter, Connection, OptionalExtension}; +use serde_json::Value; +use uuid::Uuid; + +use crate::local::derive; +use crate::local::models::*; + +fn now() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true) +} + +fn new_id() -> String { + Uuid::new_v4().to_string() +} + +/// title if non-empty, else the first non-blank body line — always a string. +fn display_title(title: Option<&str>, body: &str) -> String { + if let Some(t) = title { + let t = t.trim(); + if !t.is_empty() { + return t.to_string(); + } + } + body.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("").to_string() +} + +fn normalize_title(raw: &str) -> Option { + let t = raw.trim(); + if t.is_empty() { + None + } else { + Some(t.to_string()) + } +} + +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() +} + +fn load_items(conn: &Connection, note_id: &str) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT id, text, checked, position FROM checklist_items WHERE note_id = ?1 ORDER BY position ASC", + )?; + let rows = stmt.query_map([note_id], |r| { + Ok(ChecklistItem { + id: r.get(0)?, + text: r.get(1)?, + checked: r.get(2)?, + position: r.get(3)?, + }) + })?; + rows.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| { + Ok(Attachment { + id: r.get(0)?, + url: r.get(1)?, + filename: r.get(2)?, + mime: r.get(3)?, + size: r.get(4)?, + sha256: r.get(5)?, + }) + })?; + 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, title, body, color, kind, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at + FROM notes WHERE id = ?1", + [id], + |r| { + let title: Option = r.get(1)?; + let body: String = r.get(2)?; + let dt = display_title(title.as_deref(), &body); + Ok(Note { + id: r.get(0)?, + title, + display_title: dt, + body, + color: r.get(3)?, + kind: r.get(4)?, + position: r.get(5)?, + pinned: r.get(6)?, + archived: r.get(7)?, + trashed: r.get(8)?, + remind_at: r.get(9)?, + recurrence: r.get(10)?, + labels: Vec::new(), + items: Vec::new(), + attachments: Vec::new(), + previews: Vec::new(), + created_at: r.get(11)?, + updated_at: r.get(12)?, + }) + }, + )?; + note.labels = load_labels(conn, id)?; + note.items = load_items(conn, id)?; + note.attachments = load_attachments(conn, id)?; + note.previews = load_previews(conn, id)?; + 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) +} + +/// Re-sync the note's `via_tag` labels to exactly the `#tags` in its body. +fn sync_tags(conn: &Connection, note_id: &str, body: &str) -> rusqlite::Result<()> { + let tags = derive::extract_tags(body); + let mut desired: Vec = Vec::with_capacity(tags.len()); + for t in &tags { + desired.push(find_or_create_label(conn, t)?); + } + + let current: Vec = { + let mut stmt = conn.prepare("SELECT label_id FROM note_labels WHERE note_id = ?1 AND via_tag = 1")?; + let rows = stmt.query_map([note_id], |r| r.get::<_, String>(0))?; + rows.collect::>>()? + }; + for lid in ¤t { + if !desired.contains(lid) { + conn.execute( + "DELETE FROM note_labels WHERE note_id = ?1 AND label_id = ?2 AND via_tag = 1", + params![note_id, lid], + )?; + } + } + for lid in &desired { + conn.execute( + "INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 1)", + params![note_id, lid], + )?; + } + 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 (title LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')"); + let pat = format!("%{}%", escape_like(text)); + binds.push(pat.clone()); + binds.push(pat); + } + if let Some(c) = f.color.as_deref().filter(|s| !s.is_empty()) { + sql.push_str(" AND color = ?"); + binds.push(c.to_string()); + } + if let Some(k) = f.kind.as_deref().filter(|s| !s.is_empty()) { + sql.push_str(" AND kind = ?"); + binds.push(k.to_string()); + } + 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")?; + stmt.query_map([], |r| r.get::<_, String>(0))?.collect::>>()? + }; + ids.iter().map(|id| load_note(conn, id)).collect() +} + +pub fn titles(conn: &Connection) -> rusqlite::Result> { + let mut stmt = conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0")?; + let rows = stmt.query_map([], |r| { + let title: Option = r.get(1)?; + let body: String = r.get(2)?; + Ok(TitleEntry { + id: r.get(0)?, + title: display_title(title.as_deref(), &body), + }) + })?; + rows.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 (title LIKE ?1 ESCAPE '\\' OR body LIKE ?1 ESCAPE '\\') ORDER BY updated_at DESC", + )?; + stmt.query_map([&pat], |r| r.get::<_, String>(0))?.collect::>>()? + }; + ids.iter().map(|id| load_note(conn, id)).collect() +} + +pub fn backlinks(conn: &Connection, id: &str) -> rusqlite::Result> { + let target: String = { + let (t, b): (Option, String) = + conn.query_row("SELECT title, body FROM notes WHERE id = ?1", [id], |r| Ok((r.get(0)?, r.get(1)?)))?; + display_title(t.as_deref(), &b) + }; + if target.is_empty() { + return Ok(Vec::new()); + } + let mut stmt = conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0 AND id != ?1")?; + let rows = stmt.query_map([id], |r| { + let nid: String = r.get(0)?; + let t: Option = r.get(1)?; + let b: String = r.get(2)?; + Ok((nid, t, b)) + })?; + let mut out = Vec::new(); + for row in rows { + let (nid, t, b) = row?; + if derive::extract_links(&b).iter().any(|l| l.eq_ignore_ascii_case(&target)) { + out.push(Backlink { + id: nid, + title: display_title(t.as_deref(), &b), + }); + } + } + Ok(out) +} + +pub fn link_search(conn: &Connection, q: &str) -> rusqlite::Result> { + let ql = q.trim().to_lowercase(); + let mut stmt = conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0")?; + let rows = stmt.query_map([], |r| { + let id: String = r.get(0)?; + let t: Option = r.get(1)?; + let b: String = r.get(2)?; + Ok((id, t, b)) + })?; + let mut out = Vec::new(); + for row in rows { + let (id, t, b) = row?; + let dt = display_title(t.as_deref(), &b); + if ql.is_empty() || dt.to_lowercase().contains(&ql) { + out.push(TitleEntry { id, title: dt }); + } + } + Ok(out) +} + +// ---- notes: write ----------------------------------------------------------- + +pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Result { + let id = new_id(); + let ts = now(); + let title = normalize_title(&input.title); + let kind = input.kind.clone().unwrap_or_else(|| "text".to_string()); + let position: i64 = conn.query_row("SELECT COALESCE(MAX(position), 0) + 1 FROM notes", [], |r| r.get(0))?; + conn.execute( + "INSERT INTO notes (id, title, body, color, kind, position, created_at, updated_at, dirty) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, 1)", + params![id, title, input.body, input.color, kind, position, ts], + )?; + if let Some(items) = &input.items { + for (i, text) in items.iter().enumerate() { + conn.execute( + "INSERT INTO checklist_items (id, note_id, text, position) VALUES (?1, ?2, ?3, ?4)", + params![new_id(), id, text, i as i64], + )?; + } + } + sync_tags(conn, &id, &input.body)?; + load_note(conn, &id) +} + +pub fn create_titled(conn: &Connection, title: &str) -> rusqlite::Result { + let input = NoteCreateInput { + title: title.to_string(), + body: String::new(), + color: "default".to_string(), + kind: None, + items: None, + }; + create_note(conn, &input) +} + +fn snapshot_revision(conn: &Connection, id: &str) -> rusqlite::Result<()> { + let (title, body): (Option, String) = + conn.query_row("SELECT title, body FROM notes WHERE id = ?1", [id], |r| Ok((r.get(0)?, r.get(1)?)))?; + conn.execute( + "INSERT INTO note_revisions (id, note_id, title, body, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", + params![new_id(), id, title, 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 title/body once if either is being changed (version history). + if obj.contains_key("title") || obj.contains_key("body") { + snapshot_revision(conn, id)?; + } + + for (k, v) in obj { + match k.as_str() { + "title" => { + let norm = v.as_str().and_then(normalize_title); + conn.execute("UPDATE notes SET title = ?1 WHERE id = ?2", params![norm, id])?; + } + "body" => { + let body = v.as_str().unwrap_or(""); + conn.execute("UPDATE notes SET body = ?1 WHERE id = ?2", params![body, id])?; + sync_tags(conn, id, body)?; + } + "color" => { + if let Some(s) = v.as_str() { + conn.execute("UPDATE notes SET color = ?1 WHERE id = ?2", params![s, id])?; + } + } + "kind" => { + if let Some(s) = v.as_str() { + conn.execute("UPDATE notes SET kind = ?1 WHERE id = ?2", params![s, id])?; + } + } + "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) +} + +pub fn complete_reminder(conn: &Connection, id: &str) -> rusqlite::Result { + // Clear the reminder. (Recurrence advancement is a later refinement.) + conn.execute("UPDATE notes SET remind_at = 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) +} + +pub fn add_item(conn: &Connection, id: &str, text: &str) -> rusqlite::Result { + let pos: i64 = + conn.query_row("SELECT COALESCE(MAX(position), -1) + 1 FROM checklist_items WHERE note_id = ?1", [id], |r| { + r.get(0) + })?; + conn.execute( + "INSERT INTO checklist_items (id, note_id, text, position) VALUES (?1, ?2, ?3, ?4)", + params![new_id(), id, text, pos], + )?; + touch(conn, id)?; + load_note(conn, id) +} + +pub fn update_item(conn: &Connection, id: &str, item_id: &str, changes: &Value) -> rusqlite::Result { + if let Some(text) = changes.get("text").and_then(Value::as_str) { + conn.execute( + "UPDATE checklist_items SET text = ?1 WHERE id = ?2 AND note_id = ?3", + params![text, item_id, id], + )?; + } + if let Some(checked) = changes.get("checked").and_then(Value::as_bool) { + conn.execute( + "UPDATE checklist_items SET checked = ?1 WHERE id = ?2 AND note_id = ?3", + params![checked, item_id, id], + )?; + } + touch(conn, id)?; + load_note(conn, id) +} + +pub fn delete_item(conn: &Connection, id: &str, item_id: &str) -> rusqlite::Result { + conn.execute("DELETE FROM checklist_items WHERE id = ?1 AND note_id = ?2", params![item_id, id])?; + touch(conn, id)?; + load_note(conn, id) +} + +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 { + conn.execute("UPDATE notes SET trashed = 1 WHERE id = ?1", [id])?; + touch(conn, id)?; + load_note(conn, id) +} + +pub fn restore(conn: &Connection, id: &str) -> rusqlite::Result { + conn.execute("UPDATE notes SET trashed = 0 WHERE id = ?1", [id])?; + touch(conn, id)?; + load_note(conn, id) +} + +pub fn delete_forever(conn: &Connection, id: &str) -> rusqlite::Result<()> { + conn.execute("DELETE FROM notes WHERE id = ?1", [id])?; + Ok(()) +} + +pub fn revisions(conn: &Connection, id: &str) -> rusqlite::Result> { + let mut stmt = conn + .prepare("SELECT id, title, 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)?, + title: r.get(1)?, + body: r.get(2)?, + created_at: r.get(3)?, + }) + })?; + rows.collect() +} + +pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite::Result { + let (title, body): (Option, String) = conn.query_row( + "SELECT title, body FROM note_revisions WHERE id = ?1 AND note_id = ?2", + params![rev_id, id], + |r| Ok((r.get(0)?, r.get(1)?)), + )?; + snapshot_revision(conn, id)?; + conn.execute("UPDATE notes SET title = ?1, body = ?2 WHERE id = ?3", params![title, body, id])?; + sync_tags(conn, id, &body)?; + touch(conn, id)?; + load_note(conn, id) +} + +// ---- labels ----------------------------------------------------------------- + +fn load_label(conn: &Connection, id: &str) -> rusqlite::Result