core: extract the store and sync engine into a shared crate (M12 step 1)
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 48s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m50s
Desktop (Tauri) / Update manifest (push) Skipped

Android becomes a native Kotlin client over this same code (Scribe note 2730), so
the local store and sync engine stop being modules of the desktop app and become
`thoughtsync-core`, a crate with no UI framework in it at all.

This is a move, not a rewrite, and the measurement is why: every file in local/
and sync/ already carried ZERO Tauri references — 4,980 of 6,372 lines. The
coupling was 473 lines of command shim, which stays behind in the desktop crate
as src/commands/. Kept as git renames so history follows the files.

The desktop imports them under their old names (`use thoughtsync_core::{local,
sync}`) so every call site reads exactly as before. What moved is where they
live, not what they are.

Two things a workspace changes that are easy to miss, both caught before pushing:

[profile.release] now lives at the workspace ROOT. Cargo silently ignores
profiles declared by a non-root member — leaving it in the desktop crate would
have dropped lto/strip/opt-level from every release build with only a warning.

And a workspace shares ONE target dir, so the bundles moved from
desktop/src-tauri/target to target/. Thirteen references across publish-release,
debundle-graphics, verify.sh, package-prebuilt and the workflow now point there.
Pinning target-dir back would have been the smaller diff, but the Android lane
also produces Rust artifacts and they do not belong under desktop/.

Also retires the Tauri Android lane in the same push rather than leaving a path
that is being replaced: gen/android, android.yml and docs/android-dev.md are
gone, the mobile_entry_point attribute with them, and the lib drops to rlib —
staticlib/cdylib existed for Tauri mobile, and the .so Android loads will be
built from the core crate instead. Rule 22, no parallel path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 23:12:26 -04:00
co-authored by Claude Opus 5
parent c28f2bc00e
commit 0a7480cf9b
75 changed files with 246 additions and 1346 deletions
+122
View File
@@ -0,0 +1,122 @@
//! 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<String> {
let bytes = body.as_bytes();
let mut out: Vec<String> = 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<String> {
let chars: Vec<char> = body.chars().collect();
let mut out: Vec<String> = 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<String>, 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::<String>::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());
}
}
+46
View File
@@ -0,0 +1,46 @@
//! The local-first store: on-device SQLite, and the source of truth for every
//! client. A client built on this is fully usable with no server and no account.
//!
//! Framework-free on purpose. The desktop reaches it through Tauri commands and
//! Android through uniffi, but neither of those concerns appears in here.
pub mod derive;
pub mod models;
pub mod retention;
pub mod schema;
pub mod store;
use std::path::Path;
use std::sync::Mutex;
use rusqlite::Connection;
/// The shared database handle. rusqlite connections aren't `Sync`, so a `Mutex`
/// serializes access — fine, since operations are quick and a client is single-user.
/// How it is held is the caller's business: Tauri manages it as state, Android holds
/// it in the uniffi object.
pub struct Db(pub Mutex<Connection>);
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
pub fn open(path: &Path) -> rusqlite::Result<Db> {
let conn = Connection::open(path)?;
schema::migrate(&conn)?;
Ok(Db(Mutex::new(conn)))
}
/// A one-line count summary of the store, for the startup log.
pub fn summary(db: &Db) -> String {
let conn = match db.0.lock() {
Ok(c) => c,
Err(_) => return "counts unavailable (lock poisoned)".to_string(),
};
let count = |sql: &str| {
conn.query_row(sql, [], |r| r.get::<_, i64>(0))
.unwrap_or(-1)
};
format!(
"{} notes, {} labels",
count("SELECT COUNT(*) FROM notes"),
count("SELECT COUNT(*) FROM labels"),
)
}
+182
View File
@@ -0,0 +1,182 @@
//! 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<String>,
/// 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,
/// When it was trashed (null unless trashed). Named for the server's field so the
/// shared frontend counts down the retention window identically either way.
pub deleted_at: Option<String>,
pub remind_at: Option<String>,
pub recurrence: Option<String>,
pub labels: Vec<NoteLabel>,
pub items: Vec<ChecklistItem>,
pub attachments: Vec<Attachment>,
pub previews: Vec<LinkPreview>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[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<String>,
pub mime: String,
pub size: Option<i64>,
pub sha256: Option<String>,
}
#[derive(Serialize)]
pub struct LinkPreview {
pub id: String,
pub url: String,
pub title: Option<String>,
pub description: Option<String>,
pub image_url: Option<String>,
pub site_name: Option<String>,
}
#[derive(Serialize)]
pub struct NoteRevision {
pub id: String,
pub title: Option<String>,
pub body: String,
pub created_at: Option<String>,
}
#[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<i64>,
}
#[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,
pub trash_retention_days: u32,
}
/// 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<String>,
#[serde(default)]
pub items: Option<Vec<String>>,
}
/// 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<String>,
#[serde(default)]
pub facets: Option<Facets>,
#[serde(default)]
pub sort: Option<String>,
}
#[derive(Deserialize)]
pub struct Facets {
#[serde(default)]
pub q: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub label: Option<Vec<String>>,
#[serde(default)]
pub has_reminder: Option<bool>,
#[serde(default)]
pub has_attachment: Option<bool>,
#[serde(default)]
pub created_after: Option<String>,
#[serde(default)]
pub created_before: Option<String>,
}
+228
View File
@@ -0,0 +1,228 @@
//! Trash retention for a device with no server (M11.3).
//!
//! The server owns this policy whenever there IS one: a linked client learns about
//! every permanent deletion from the delta feed, as a tombstone, and does exactly
//! what it's told. This module exists for the case the server can't cover — an
//! offline-only install, where trash would otherwise sit forever and the attachment
//! bytes with it.
//!
//! Which is why the sweep refuses to run while linked. If it didn't, a device could
//! decide on its own that a note had expired, destroy it, and then push that delete
//! upstream — overruling a server that was deliberately keeping it (retention off, or
//! a longer window than this constant). A client's local policy must never outrank
//! the server's.
use chrono::{DateTime, Duration, Utc};
use rusqlite::Connection;
use super::store;
use crate::sync::state;
/// The window an unlinked device uses. Matches the server's default so a device that
/// later links doesn't see its trash behave differently from one that always was.
pub const LOCAL_RETENTION_DAYS: i64 = 30;
/// Purge trash older than `retention_days`. Returns how many notes went.
///
/// `now` is a parameter so the window arithmetic is testable without waiting a month.
pub fn sweep_expired_trash(
conn: &Connection,
retention_days: i64,
now: DateTime<Utc>,
) -> rusqlite::Result<usize> {
if retention_days <= 0 {
return Ok(0);
}
let cutoff = now - Duration::days(retention_days);
let mut expired: Vec<String> = Vec::new();
{
let mut stmt = conn.prepare(
"SELECT id, trashed_at FROM notes WHERE trashed = 1 AND trashed_at IS NOT NULL",
)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let id: String = row.get(0)?;
let stamped: String = row.get(1)?;
// PARSED, not string-compared. The server writes `+00:00` offsets and this
// client writes `Z`, so two timestamps for the same instant don't sort
// against each other as text — and the failure would be silent.
//
// An unparseable stamp means "age unknown", and the only safe reading of
// that is to keep the note. Deleting on a guess is the one outcome nobody
// can undo.
let Ok(trashed_at) = DateTime::parse_from_rfc3339(&stamped) else {
continue;
};
if trashed_at.with_timezone(&Utc) < cutoff {
expired.push(id);
}
}
}
for id in &expired {
// Through delete_forever, so a `pending_deletes` tombstone is recorded. That's
// right even here: while unlinked this device holds the only copy, so if it
// links later the server should learn the note was deleted, not re-send it.
store::delete_forever(conn, id)?;
}
Ok(expired.len())
}
/// The startup sweep: runs only on an unlinked device (see the module note).
/// Returns `None` when it didn't run because the device is linked.
pub fn sweep_if_unlinked(conn: &Connection) -> rusqlite::Result<Option<usize>> {
if state::read(conn)?.server_url.is_some() {
return Ok(None);
}
sweep_expired_trash(conn, LOCAL_RETENTION_DAYS, Utc::now()).map(Some)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::local::schema;
fn db() -> Connection {
let conn = Connection::open_in_memory().expect("in-memory db");
schema::migrate(&conn).expect("migrate");
conn
}
/// A trashed note of a given age, stamped in the format the CLIENT writes
/// (`...Z`, millisecond precision — see `store::now`).
fn trashed_note_aged(conn: &Connection, id: &str, age: Duration) {
let when = Utc::now() - age;
let stamped = when.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
conn.execute(
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at)
VALUES (?1, 'T', 'B', ?2, ?2, 1, ?2)",
rusqlite::params![id, stamped],
)
.expect("insert");
}
fn trashed_note(conn: &Connection, id: &str, days_ago: i64) {
trashed_note_aged(conn, id, Duration::days(days_ago));
}
fn sweep(conn: &Connection, days: i64) -> usize {
sweep_expired_trash(conn, days, Utc::now()).expect("sweep")
}
fn note_count(conn: &Connection) -> i64 {
conn.query_row("SELECT COUNT(*) FROM notes", [], |r| r.get(0))
.expect("count")
}
#[test]
fn purges_trash_past_the_window_and_keeps_the_rest() {
let conn = db();
trashed_note(&conn, "old", 40);
trashed_note(&conn, "fresh", 3);
let purged = sweep(&conn, 30);
assert_eq!(purged, 1);
assert_eq!(note_count(&conn), 1, "only the expired note should go");
}
#[test]
fn a_note_just_inside_the_window_survives() {
// The comparison is STRICTLY older than the cutoff, so a note with a minute
// of its 30 days still to run is kept. An exact tie isn't testable against a
// wall clock — the sweep reads `now` microseconds after the row is stamped,
// which is precisely how the first version of this test failed.
let conn = db();
let almost = Duration::days(30) - Duration::minutes(1);
trashed_note_aged(&conn, "boundary", almost);
assert_eq!(sweep(&conn, 30), 0);
assert_eq!(note_count(&conn), 1);
}
#[test]
fn retention_off_purges_nothing() {
let conn = db();
trashed_note(&conn, "ancient", 4000);
assert_eq!(sweep(&conn, 0), 0);
assert_eq!(sweep(&conn, -1), 0);
assert_eq!(note_count(&conn), 1);
}
#[test]
fn an_untrashed_note_is_never_swept() {
let conn = db();
conn.execute(
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed)
VALUES ('live', 'T', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 0)",
[],
)
.expect("insert");
assert_eq!(sweep(&conn, 30), 0);
assert_eq!(note_count(&conn), 1);
}
#[test]
fn an_unparseable_timestamp_keeps_the_note() {
// "Age unknown" must never resolve to "delete it".
let conn = db();
conn.execute(
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at)
VALUES ('weird', 'T', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 1, 'not a date')",
[],
)
.expect("insert");
assert_eq!(sweep(&conn, 30), 0);
assert_eq!(note_count(&conn), 1);
}
#[test]
fn a_server_style_offset_timestamp_is_understood() {
// The server serializes with a `+00:00` offset, not `Z`. Comparing those as
// strings would quietly never match — this is the case that catches it.
let conn = db();
let stamped = (Utc::now() - Duration::days(40)).to_rfc3339();
conn.execute(
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at)
VALUES ('server', 'T', 'B', ?1, ?1, 1, ?1)",
rusqlite::params![stamped],
)
.expect("insert");
assert_eq!(sweep(&conn, 30), 1);
}
#[test]
fn a_purged_note_leaves_a_pending_delete_behind() {
// Without the tombstone, linking this device later would let the server
// re-send a note the user already destroyed here.
let conn = db();
trashed_note(&conn, "old", 40);
sweep(&conn, 30);
let pending: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pending_deletes WHERE entity = 'note' AND id = 'old'",
[],
|r| r.get(0),
)
.expect("count");
assert_eq!(pending, 1);
}
#[test]
fn a_linked_device_does_not_sweep() {
// The whole safety rule: with a server present, purging is the server's call.
let conn = db();
trashed_note(&conn, "old", 400);
state::set_link(&conn, "https://notes.example", "token").expect("link");
assert_eq!(sweep_if_unlinked(&conn).expect("sweep"), None);
assert_eq!(
note_count(&conn),
1,
"the note must survive on a linked device"
);
}
#[test]
fn an_unlinked_device_sweeps() {
let conn = db();
trashed_note(&conn, "old", 400);
assert_eq!(sweep_if_unlinked(&conn).expect("sweep"), Some(1));
assert_eq!(note_count(&conn), 0);
}
}
+187
View File
@@ -0,0 +1,187 @@
//! 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);
"#;
// v2 (M10.7c): local tombstones.
//
// A permanent delete previously just dropped the row, which left NO record that it
// ever existed. Offline, that means the delete can never be pushed — and the next
// pull would faithfully resurrect the note from the server. A deletion that undoes
// itself is about the worst outcome sync can produce, so deletes are now recorded
// here until they've been acknowledged by the server and cleared.
const SCHEMA_V2: &str = r#"
CREATE TABLE pending_deletes (
entity TEXT NOT NULL, -- 'note' | 'label'
id TEXT NOT NULL,
deleted_at TEXT NOT NULL,
PRIMARY KEY (entity, id)
);
"#;
// v3 (M10.7e): when the last successful sync finished.
//
// The cursor alone can't answer "is this up to date?" — it's a revision watermark,
// not a time, and it doesn't move at all when a sync legitimately finds nothing new.
// The UI needs a timestamp to say anything honest.
const SCHEMA_V3: &str = r#"
ALTER TABLE sync_state ADD COLUMN last_sync_at TEXT;
"#;
// v4 (M11.3): WHEN a note was trashed.
//
// The table only ever recorded THAT a note was trashed, which is enough to draw a
// Trash view and nothing else. Retention needs an age: without a timestamp there is
// no way to tell a note trashed this morning from one trashed last spring, so an
// offline device could never expire its own trash — and the UI couldn't warn anyone
// before it did.
// It also records the LINKED server's retention window, captured from /api/config.
// Once linked, the server's policy is the one that actually applies, so showing this
// device's offline default would put a countdown on screen that doesn't match what
// happens — a wrong deadline is worse than none.
const SCHEMA_V4: &str = r#"
ALTER TABLE notes ADD COLUMN trashed_at TEXT;
UPDATE notes SET trashed_at = updated_at WHERE trashed = 1;
ALTER TABLE sync_state ADD COLUMN server_retention_days INTEGER;
"#;
// v5 (M10.9): small key/value app preferences.
//
// The first entry is the update channel, which is neither note data nor part of the
// server link — so it belongs in neither `notes` nor `sync_state`. Generic on
// purpose: the next device-local preference shouldn't need another migration.
const SCHEMA_V5: &str = r#"
CREATE TABLE prefs (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"#;
/// 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;")?;
}
if version < 2 {
conn.execute_batch(SCHEMA_V2)?;
conn.execute_batch("PRAGMA user_version = 2;")?;
}
if version < 3 {
conn.execute_batch(SCHEMA_V3)?;
conn.execute_batch("PRAGMA user_version = 3;")?;
}
if version < 4 {
conn.execute_batch(SCHEMA_V4)?;
conn.execute_batch("PRAGMA user_version = 4;")?;
}
if version < 5 {
conn.execute_batch(SCHEMA_V5)?;
conn.execute_batch("PRAGMA user_version = 5;")?;
}
Ok(())
}
+891
View File
@@ -0,0 +1,891 @@
//! 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<String> {
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<Vec<NoteLabel>> {
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<Vec<ChecklistItem>> {
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<Vec<Attachment>> {
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<String> = 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<Vec<LinkPreview>> {
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<Note> {
let mut note = conn.query_row(
"SELECT id, title, body, color, kind, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
FROM notes WHERE id = ?1",
[id],
|r| {
let title: Option<String> = 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)?,
deleted_at: r.get(13)?,
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<String> {
let existing: Option<String> = 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<String> = Vec::with_capacity(tags.len());
for t in &tags {
desired.push(find_or_create_label(conn, t)?);
}
let current: Vec<String> = {
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::<rusqlite::Result<Vec<String>>>()?
};
for lid in &current {
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<Vec<Note>> {
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<String> = Vec::new();
// Label filters (sidebar label + facet labels) are ANDed: a note must carry all.
let mut label_ids: Vec<String> = 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<String> = {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params_from_iter(binds.iter()), |r| r.get::<_, String>(0))?;
rows.collect::<rusqlite::Result<Vec<String>>>()?
};
ids.iter().map(|id| load_note(conn, id)).collect()
}
pub fn get_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
load_note(conn, id)
}
pub fn reminders(conn: &Connection) -> rusqlite::Result<Vec<Note>> {
let ids: Vec<String> = {
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::<rusqlite::Result<Vec<String>>>()?
};
ids.iter().map(|id| load_note(conn, id)).collect()
}
pub fn titles(conn: &Connection) -> rusqlite::Result<Vec<TitleEntry>> {
let mut stmt = conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0")?;
let rows = stmt.query_map([], |r| {
let title: Option<String> = 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<Vec<Note>> {
let pat = format!("%{}%", escape_like(q));
let ids: Vec<String> = {
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",
)?;
let rows = stmt.query_map([&pat], |r| r.get::<_, String>(0))?;
rows.collect::<rusqlite::Result<Vec<String>>>()?
};
ids.iter().map(|id| load_note(conn, id)).collect()
}
pub fn backlinks(conn: &Connection, id: &str) -> rusqlite::Result<Vec<Backlink>> {
let target: String = {
let (t, b): (Option<String>, 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<String> = 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<Vec<TitleEntry>> {
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<String> = 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<Note> {
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<Note> {
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>, 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<Note> {
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<Note> {
// 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<Note> {
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<Note> {
// 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<Note> {
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<Note> {
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<Note> {
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<Note> {
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<Note> {
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<Note> {
// 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<Note> {
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<Option<String>> {
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<Vec<NoteRevision>> {
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<Note> {
let (title, body): (Option<String>, 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<Label> {
conn.query_row(
"SELECT l.id, l.name, l.color,
(SELECT COUNT(*) FROM note_labels nl JOIN notes n ON n.id = nl.note_id
WHERE nl.label_id = l.id AND n.trashed = 0)
FROM labels l WHERE l.id = ?1",
[id],
|r| {
Ok(Label {
id: r.get(0)?,
name: r.get(1)?,
color: r.get(2)?,
count: Some(r.get(3)?),
})
},
)
}
pub fn list_labels(conn: &Connection) -> rusqlite::Result<Vec<Label>> {
let mut stmt = conn.prepare(
"SELECT l.id, l.name, l.color,
(SELECT COUNT(*) FROM note_labels nl JOIN notes n ON n.id = nl.note_id
WHERE nl.label_id = l.id AND n.trashed = 0)
FROM labels l ORDER BY l.name COLLATE NOCASE",
)?;
let rows = stmt.query_map([], |r| {
Ok(Label {
id: r.get(0)?,
name: r.get(1)?,
color: r.get(2)?,
count: Some(r.get(3)?),
})
})?;
rows.collect()
}
pub fn create_label(conn: &Connection, name: &str) -> rusqlite::Result<Label> {
let id = find_or_create_label(conn, name)?;
load_label(conn, &id)
}
pub fn rename_label(conn: &Connection, id: &str, name: &str) -> rusqlite::Result<Label> {
conn.execute(
"UPDATE labels SET name = ?1, updated_at = ?2, dirty = 1 WHERE id = ?3",
params![name, now(), id],
)?;
load_label(conn, id)
}
pub fn set_label_color(conn: &Connection, id: &str, color: &str) -> rusqlite::Result<Label> {
conn.execute(
"UPDATE labels SET color = ?1, updated_at = ?2, dirty = 1 WHERE id = ?3",
params![color, now(), id],
)?;
load_label(conn, id)
}
pub fn remove_label(conn: &Connection, id: &str) -> rusqlite::Result<()> {
record_pending_delete(conn, "label", id)?;
conn.execute("DELETE FROM labels WHERE id = ?1", [id])?;
Ok(())
}
pub fn merge_labels(
conn: &Connection,
source_id: &str,
target_id: &str,
) -> rusqlite::Result<Label> {
conn.execute(
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag)
SELECT note_id, ?2, 0 FROM note_labels WHERE label_id = ?1",
params![source_id, target_id],
)?;
// The notes that carried the source now have a different label set, and that set
// only reaches the server via the note itself (push sends label_ids per note).
// Without this the merge would look done locally and never sync. Marked BEFORE
// the delete, which cascades the membership rows away.
conn.execute(
"UPDATE notes SET dirty = 1
WHERE id IN (SELECT note_id FROM note_labels WHERE label_id = ?1)",
[source_id],
)?;
record_pending_delete(conn, "label", source_id)?;
conn.execute("DELETE FROM labels WHERE id = ?1", [source_id])?;
load_label(conn, target_id)
}
// ---- saved filters ----------------------------------------------------------
pub fn list_saved_filters(conn: &Connection) -> rusqlite::Result<Vec<SavedFilter>> {
let mut stmt =
conn.prepare("SELECT id, name, params, position FROM saved_filters ORDER BY position ASC, name COLLATE NOCASE")?;
let rows = stmt.query_map([], |r| {
let params_str: String = r.get(2)?;
let params = serde_json::from_str(&params_str).unwrap_or_else(|_| serde_json::json!({}));
Ok(SavedFilter {
id: r.get(0)?,
name: r.get(1)?,
params,
position: r.get(3)?,
})
})?;
rows.collect()
}
pub fn create_saved_filter(
conn: &Connection,
name: &str,
params: &Value,
) -> rusqlite::Result<SavedFilter> {
let id = new_id();
let position: i64 = conn.query_row(
"SELECT COALESCE(MAX(position), 0) + 1 FROM saved_filters",
[],
|r| r.get(0),
)?;
let params_str = serde_json::to_string(params).unwrap_or_else(|_| "{}".to_string());
conn.execute(
"INSERT INTO saved_filters (id, name, params, position, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![id, name, params_str, position, now()],
)?;
Ok(SavedFilter {
id,
name: name.to_string(),
params: params.clone(),
position,
})
}
pub fn remove_saved_filter(conn: &Connection, id: &str) -> rusqlite::Result<()> {
conn.execute("DELETE FROM saved_filters WHERE id = ?1", [id])?;
Ok(())
}
pub fn rename_saved_filter(
conn: &Connection,
id: &str,
name: &str,
) -> rusqlite::Result<SavedFilter> {
conn.execute(
"UPDATE saved_filters SET name = ?1 WHERE id = ?2",
params![name, id],
)?;
conn.query_row(
"SELECT id, name, params, position FROM saved_filters WHERE id = ?1",
[id],
|r| {
let params_str: String = r.get(2)?;
let params =
serde_json::from_str(&params_str).unwrap_or_else(|_| serde_json::json!({}));
Ok(SavedFilter {
id: r.get(0)?,
name: r.get(1)?,
params,
position: r.get(3)?,
})
},
)
}