Files
inkwell/core/src/local/store.rs
T
bvandeusen 95aa10c2c3
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 7s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 31s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m45s
Remove the title field — a note is named by its first line
Operator (note 2897): "notes shouldn't have a title field." The concept of a NAME
stays — search results, export filenames and the command palette all need one —
but nothing is typed into it any more. `display_title` is now the first non-empty
line of the body, falling back to the first checklist item.

That fallback is what step 2 bought, and the reason this could not go first: a
checklist had no body to be named from, so the title was its only name. Now every
note has a body, and a note that is only a checklist is named by its first item.

Gone everywhere: the column and note_revisions.title (0026), the field on the
core's Note/NoteCreateInput/NoteRevision and its SQLite columns (user_version 7),
`normalize_title`, the wire field, the FFI record and `NoteEdit::Title` /
`ClearTitle`, the web editor's "Title (optional)" input and the card's <h3>, and
the Android title field in both the compose sheet and the editor.

**The search vector had to be rebuilt, not just left alone.** `notes.search_vector`
is a STORED GENERATED column whose expression names `title` — Postgres refuses to
drop a column another generated column depends on. It is dropped and recreated over
`display_title` at weight A, which keeps the original intent: a note's NAME ranks
above the rest of its body.

**An imported title becomes the note's first body line.** Keep notes carry one, and
so does any ThoughtSync export taken before this. Dropping it would silently lose
text someone wrote; folding it in puts it exactly where a name now lives, so the
note arrives named as it was. Skipped when the body already opens with that line,
so re-importing an export this code produced doesn't stack duplicates.

Two smaller things fell out. The Android editor loses its bold first field — one
weight throughout, because the first line is the note's name but not a different
KIND of text, which is most of step 4 arriving early. And `ClearTitle`'s
justification comment moved to `ClearRemindAt`, which is now the surviving example
of why NoteEdit is a list rather than a struct of options.

Protocol note corrected to say what actually shipped: v2 is "no kind, no title",
one bump for the pair.

Verified with the local Rust gate this time, not by CI: fmt, clippy and 116 tests
all green before pushing. It caught four things — orphaned serde attributes where
fields were removed, a `wire::Preview.title` I deleted by mistake (a link preview
still has one), nine retention fixtures inserting a dropped column, and four
rustfmt diffs.
2026-08-22 19:33:57 -04:00

837 lines
29 KiB
Rust

//! The local SQLite store: every operation the repository seam needs, as plain
//! functions over a `&Connection`. The Tauri commands (commands.rs) lock the shared
//! connection and call these; keeping the SQL here (off the command layer) makes it
//! unit-testable against an in-memory database.
//!
//! Timestamps are emitted exactly like JS `Date.toISOString()`
//! ("YYYY-MM-DDTHH:MM:SS.sssZ") so string ordering and date-range comparisons line
//! up with the values the frontend sends.
use chrono::{DateTime, Duration, SecondsFormat, Utc};
use rusqlite::{params, params_from_iter, Connection, OptionalExtension};
use serde_json::Value;
use uuid::Uuid;
use crate::local::derive;
use crate::local::models::*;
use crate::local::recur;
fn now() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
}
fn new_id() -> String {
Uuid::new_v4().to_string()
}
/// The note's NAME: its first non-blank body line, else its first checklist item.
///
/// Mirrors `derive_display_title` in the server's notes/helpers.py — one rule written
/// twice, and they have to agree or a synced note is called different things on either
/// side of the wire.
///
/// Pure, and given the items rather than fetching them: every caller has already
/// loaded them, so a query here would be a second trip for something already in hand.
fn display_title(body: &str, items: &[ChecklistItem]) -> String {
if let Some(line) = body.lines().map(str::trim).find(|l| !l.is_empty()) {
return line.to_string();
}
items
.iter()
.map(|i| i.text.trim())
.find(|t| !t.is_empty())
.unwrap_or("")
.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, body, color, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
FROM notes WHERE id = ?1",
[id],
|r| {
let body: String = r.get(1)?;
Ok(Note {
id: r.get(0)?,
display_title: String::new(), // filled below — it may need a query
body,
color: r.get(2)?,
position: r.get(3)?,
pinned: r.get(4)?,
archived: r.get(5)?,
trashed: r.get(6)?,
deleted_at: r.get(11)?,
remind_at: r.get(7)?,
recurrence: r.get(8)?,
labels: Vec::new(),
items: Vec::new(),
attachments: Vec::new(),
previews: Vec::new(),
created_at: r.get(9)?,
updated_at: r.get(10)?,
})
},
)?;
note.labels = load_labels(conn, id)?;
note.items = load_items(conn, id)?;
note.attachments = load_attachments(conn, id)?;
note.previews = load_previews(conn, id)?;
// After the items, because a body-only-empty note is named by its first one.
note.display_title = display_title(&note.body, &note.items);
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 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 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>> {
// Names come from `load_note` rather than from a bare row, because a note whose
// body is empty is named by its first checklist item — which a row here doesn't
// have. The command palette reads this; correctness beats one query per note at
// personal scale.
let ids: Vec<String> = {
let mut stmt = conn.prepare("SELECT id FROM notes WHERE trashed = 0")?;
let rows = stmt.query_map([], |r| r.get(0))?;
rows.collect::<rusqlite::Result<Vec<String>>>()?
};
ids.iter()
.map(|id| {
let note = load_note(conn, id)?;
Ok(TitleEntry {
id: note.id,
title: note.display_title,
})
})
.collect()
}
pub fn search(conn: &Connection, q: &str) -> rusqlite::Result<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 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()
}
// ---- notes: write -----------------------------------------------------------
pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Result<Note> {
let id = new_id();
let ts = now();
let position: i64 = conn.query_row(
"SELECT COALESCE(MAX(position), 0) + 1 FROM notes",
[],
|r| r.get(0),
)?;
conn.execute(
"INSERT INTO notes (id, body, color, position, created_at, updated_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?5, ?5, 1)",
params![id, input.body, input.color, 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)
}
fn snapshot_revision(conn: &Connection, id: &str) -> rusqlite::Result<()> {
let body: String =
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?;
conn.execute(
"INSERT INTO note_revisions (id, note_id, body, created_at) VALUES (?1, ?2, ?3, ?4)",
params![new_id(), id, body, now()],
)?;
Ok(())
}
/// PATCH semantics: apply exactly the fields present in `changes`.
pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Result<Note> {
let obj = changes
.as_object()
.ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?;
// Snapshot the pre-edit body before changing it (version history).
if obj.contains_key("body") {
snapshot_revision(conn, id)?;
}
for (k, v) in obj {
match k.as_str() {
"body" => {
let body = v.as_str().unwrap_or("");
conn.execute(
"UPDATE notes SET body = ?1 WHERE id = ?2",
params![body, id],
)?;
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])?;
}
}
"pinned" => {
if let Some(b) = v.as_bool() {
conn.execute("UPDATE notes SET pinned = ?1 WHERE id = ?2", params![b, id])?;
}
}
"archived" => {
if let Some(b) = v.as_bool() {
conn.execute(
"UPDATE notes SET archived = ?1 WHERE id = ?2",
params![b, id],
)?;
}
}
"remind_at" => {
let val = v.as_str().map(|s| s.to_string());
conn.execute(
"UPDATE notes SET remind_at = ?1 WHERE id = ?2",
params![val, id],
)?;
}
"recurrence" => {
let val = v.as_str().map(|s| s.to_string());
conn.execute(
"UPDATE notes SET recurrence = ?1 WHERE id = ?2",
params![val, id],
)?;
}
_ => {}
}
}
touch(conn, id)?;
load_note(conn, id)
}
/// Mark a reminder handled.
///
/// A recurring reminder advances to its next occurrence; a one-off clears both
/// `remind_at` AND `recurrence`. Clearing the rule as well matters: without it a
/// note whose recurrence is a value we do not recognise would keep that value
/// forever, invisible in every UI (they only render known rules) and waiting to
/// mean something the day the vocabulary grows.
///
/// Same behaviour as the server's `POST /<id>/reminder/complete`, deliberately —
/// the same note can be completed from a browser or from a client, and a
/// disagreement here would move a reminder depending on which one you used.
pub fn complete_reminder(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
let note = load_note(conn, id)?;
let next = note
.remind_at
.as_deref()
.and_then(|at| DateTime::parse_from_rfc3339(at).ok())
.and_then(|at| {
let rule = recur::normalize(note.recurrence.as_deref())?;
recur::next_occurrence(at.with_timezone(&Utc), rule, Utc::now())
});
match next {
Some(at) => conn.execute(
"UPDATE notes SET remind_at = ?1 WHERE id = ?2",
params![at.to_rfc3339_opts(SecondsFormat::Millis, true), id],
)?,
None => conn.execute(
"UPDATE notes SET remind_at = NULL, recurrence = NULL WHERE id = ?1",
[id],
)?,
};
touch(conn, id)?;
load_note(conn, id)
}
pub fn snooze_reminder(conn: &Connection, id: &str, minutes: i64) -> rusqlite::Result<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, body, created_at FROM note_revisions WHERE note_id = ?1 ORDER BY created_at DESC")?;
let rows = stmt.query_map([id], |r| {
Ok(NoteRevision {
id: r.get(0)?,
body: r.get(1)?,
created_at: r.get(2)?,
})
})?;
rows.collect()
}
pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite::Result<Note> {
let body: String = conn.query_row(
"SELECT body FROM note_revisions WHERE id = ?1 AND note_id = ?2",
params![rev_id, id],
|r| r.get(0),
)?;
snapshot_revision(conn, id)?;
conn.execute(
"UPDATE notes SET body = ?1 WHERE id = ?2",
params![body, id],
)?;
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)?,
})
},
)
}