Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m35s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m46s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 5m37s
The three surfaces did not agree on what renaming a tag onto a name another
one already holds should do, and none of the three answers was good.
I described this wrongly first time and the correction matters. The local
store does NOT silently create a duplicate: `idx_labels_name` is unique on
`lower(name)`, so the bare UPDATE in `rename_label` failed, and the user got
a raw SQLite "UNIQUE constraint failed" as their error message. The server
meanwhile answered 409 "a tag with that name already exists" — and only on
an EXACT match, because its constraint is on the raw name while every
client's index is on `lower(name)`.
That last part is the sharper bug. The server would happily hold "Groceries"
beside "groceries"; no synced client can store both. Creating that pair on
the web armed a pull that fails later, on a phone, in a path with no UI.
Operator's call: a rename onto an existing name means merge — typing an
existing tag's name onto this one says they are the same thing.
* `store::rename_label` and the server's PATCH now implement one rule.
THE OLDER ROW SURVIVES and takes the new spelling. Age rather than "the
one that already held the name", so that renaming A→B and B→A land on
the same survivor; otherwise the outcome depends on which way round
someone typed it, and two devices tidying the same pair disagree about
which id still exists. Ties go to the incumbent, so it stays
deterministic.
* The core reuses `merge_labels` rather than reimplementing the move. That
is the only place that knows to mark every affected NOTE dirty before
the delete cascades the membership rows away, which is what makes a
merge reach the server at all.
* The server's rename and its `/merge` route now share one `_merge_into`
helper, for the same reason.
* Both server lookups became case-INSENSITIVE, matching every client. The
create path is included: it was the one actually minting the unstorable
pair, so fixing only the rename would have left the door open.
* The web asks before merging, naming both note counts. A merge cannot be
undone by repeating it and is now reachable by a typo in a text field —
the same reasoning as the delete confirmation in #2116. The confirmation
lives in the shared store, so the desktop gets it too; the FFI does not
ask, because that belongs to the surface with a person in front of it.
* The web store detects the merge from the LIST, not the response: the
survivor may be the row we asked to rename, so an unchanged id proves
nothing.
Tests: three integration tests over a real database (both rename directions
land on the older row; a case-varied create returns the existing tag) and
two through the Android FFI, which is the binding the phone will use.
Also fixes a straggler from 8c7553d — the delete confirmation still said
"the label".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
991 lines
36 KiB
Rust
991 lines
36 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::{json, Value};
|
||
use uuid::Uuid;
|
||
|
||
use crate::local::derive;
|
||
use crate::local::models::*;
|
||
use crate::local::recur;
|
||
|
||
fn now() -> String {
|
||
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
|
||
}
|
||
|
||
fn new_id() -> String {
|
||
Uuid::new_v4().to_string()
|
||
}
|
||
|
||
/// The note's NAME: the first line of its body that says anything.
|
||
///
|
||
/// Mirrors `derive_display_title` in the server's notes/helpers.py — one rule written
|
||
/// twice, and they have to agree or a synced note is called different things on either
|
||
/// side of the wire.
|
||
///
|
||
/// It no longer needs the items, because the items ARE lines of the body now (M304).
|
||
/// What it needs instead is to strip the task marker off: a list-only note is still
|
||
/// named by its first item, and calling that note "- [ ] milk" would be showing
|
||
/// someone the storage rather than the note. An empty item is skipped rather than
|
||
/// naming the note "", which is what a half-typed list would otherwise do.
|
||
fn display_title(body: &str) -> String {
|
||
for line in body.lines() {
|
||
let text = derive::strip_marker(line.trim()).trim();
|
||
if !text.is_empty() {
|
||
return text.to_string();
|
||
}
|
||
}
|
||
String::new()
|
||
}
|
||
|
||
fn escape_like(s: &str) -> String {
|
||
s.replace('\\', "\\\\")
|
||
.replace('%', "\\%")
|
||
.replace('_', "\\_")
|
||
}
|
||
|
||
// ---- note assembly ----------------------------------------------------------
|
||
|
||
fn load_labels(conn: &Connection, note_id: &str) -> rusqlite::Result<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()
|
||
}
|
||
|
||
/// The note's checklist, read out of its body. No query, because there is no table.
|
||
///
|
||
/// A `- [ ] milk` line IS the item (M304). The id is the item's ORDINAL rather than a
|
||
/// uuid — which is all it ever amounted to anyway, since `push.rs` sent text and
|
||
/// checked and never an id, and both sides replaced the whole list on every sync. It
|
||
/// is also exactly what the rewriters in `derive` take, so a UI holding an id can act
|
||
/// on it directly.
|
||
fn items_of(body: &str) -> Vec<ChecklistItem> {
|
||
derive::extract_items(body)
|
||
.into_iter()
|
||
.enumerate()
|
||
.map(|(i, item)| ChecklistItem {
|
||
id: i.to_string(),
|
||
text: item.text,
|
||
checked: item.checked,
|
||
position: i as i64,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn load_attachments(conn: &Connection, note_id: &str) -> rusqlite::Result<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, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
|
||
FROM notes WHERE id = ?1",
|
||
[id],
|
||
|r| {
|
||
let body: String = r.get(1)?;
|
||
Ok(Note {
|
||
id: r.get(0)?,
|
||
display_title: String::new(), // filled below — it may need a query
|
||
body,
|
||
position: r.get(2)?,
|
||
pinned: r.get(3)?,
|
||
archived: r.get(4)?,
|
||
trashed: r.get(5)?,
|
||
deleted_at: r.get(10)?,
|
||
remind_at: r.get(6)?,
|
||
recurrence: r.get(7)?,
|
||
labels: Vec::new(),
|
||
items: Vec::new(),
|
||
attachments: Vec::new(),
|
||
previews: Vec::new(),
|
||
created_at: r.get(9)?,
|
||
updated_at: r.get(10)?,
|
||
})
|
||
},
|
||
)?;
|
||
note.labels = load_labels(conn, id)?;
|
||
note.items = items_of(¬e.body);
|
||
note.attachments = load_attachments(conn, id)?;
|
||
note.previews = load_previews(conn, id)?;
|
||
note.display_title = display_title(¬e.body);
|
||
Ok(note)
|
||
}
|
||
|
||
fn touch(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||
conn.execute(
|
||
"UPDATE notes SET updated_at = ?1, dirty = 1 WHERE id = ?2",
|
||
params![now(), id],
|
||
)?;
|
||
Ok(())
|
||
}
|
||
|
||
// ---- #tag -> label derivation ----------------------------------------------
|
||
|
||
fn find_or_create_label(conn: &Connection, name: &str) -> rusqlite::Result<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)
|
||
}
|
||
|
||
/// Attach the note's tag labels, LIFT its standalone tags out of the body, and write
|
||
/// the shortened body back.
|
||
///
|
||
/// NAMED FOR THE MUTATION. It used to be `sync_tags` and only touched label rows; it
|
||
/// now rewrites `notes.body`, and every caller writes the body just before calling —
|
||
/// so this overwrites what they wrote, on purpose.
|
||
///
|
||
/// `display_title` needs no attention here, unlike on the server: the core derives it
|
||
/// on READ (see `display_title` above, called from `load_note`) rather than storing
|
||
/// it, so there is no persisted copy to go stale.
|
||
///
|
||
/// The two kinds of tag are handled differently, and that difference IS what `via_tag`
|
||
/// means from here on — backed by text still in the body:
|
||
///
|
||
/// standalone lifted out, attached as an ORDINARY label. Nothing derives it any
|
||
/// more, and the way to remove it becomes the chip's ×.
|
||
/// inline left in place, attached via_tag = 1, still detached when its text
|
||
/// goes. Unchanged from before.
|
||
///
|
||
/// Mirrors `_lift_and_reconcile_tags` in the server's `notes/tags.py`.
|
||
fn lift_and_sync_tags(conn: &Connection, note_id: &str, body: &str) -> rusqlite::Result<()> {
|
||
let (standalone, inline, lifted) = derive::lift_standalone_tags(body);
|
||
|
||
let mut standalone_ids: Vec<String> = Vec::with_capacity(standalone.len());
|
||
for name in &standalone {
|
||
standalone_ids.push(find_or_create_label(conn, name)?);
|
||
}
|
||
let mut inline_ids: Vec<String> = Vec::with_capacity(inline.len());
|
||
for name in &inline {
|
||
inline_ids.push(find_or_create_label(conn, name)?);
|
||
}
|
||
|
||
let current: Vec<(String, bool)> = {
|
||
let mut stmt =
|
||
conn.prepare("SELECT label_id, via_tag FROM note_labels WHERE note_id = ?1")?;
|
||
let rows = stmt.query_map([note_id], |r| {
|
||
Ok((r.get::<_, String>(0)?, r.get::<_, bool>(1)?))
|
||
})?;
|
||
rows.collect::<rusqlite::Result<Vec<(String, bool)>>>()?
|
||
};
|
||
|
||
for (lid, via_tag) in ¤t {
|
||
if !*via_tag {
|
||
continue; // manual already: a #tag of the same name changes nothing
|
||
}
|
||
if standalone_ids.contains(lid) {
|
||
// It GRADUATED. The text backing it is about to go, so the row has to
|
||
// become the record instead — and BEFORE the delete below, or the same row
|
||
// is dropped for no longer being in the body. That is the bug a naive lift
|
||
// has, and it silently loses the tag.
|
||
conn.execute(
|
||
"UPDATE note_labels SET via_tag = 0 WHERE note_id = ?1 AND label_id = ?2",
|
||
params![note_id, lid],
|
||
)?;
|
||
} else if !inline_ids.contains(lid) {
|
||
conn.execute(
|
||
"DELETE FROM note_labels WHERE note_id = ?1 AND label_id = ?2 AND via_tag = 1",
|
||
params![note_id, lid],
|
||
)?;
|
||
}
|
||
}
|
||
|
||
// OR IGNORE leaves a label already attached in ANY form alone, which is what keeps
|
||
// a manually-added label of the same name manual.
|
||
for lid in &standalone_ids {
|
||
conn.execute(
|
||
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 0)",
|
||
params![note_id, lid],
|
||
)?;
|
||
}
|
||
for lid in &inline_ids {
|
||
conn.execute(
|
||
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 1)",
|
||
params![note_id, lid],
|
||
)?;
|
||
}
|
||
|
||
if lifted != body {
|
||
conn.execute(
|
||
"UPDATE notes SET body = ?1 WHERE id = ?2",
|
||
params![lifted, note_id],
|
||
)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
// ---- notes: read ------------------------------------------------------------
|
||
|
||
pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<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 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),
|
||
)?;
|
||
// Items fold into the body rather than into rows of their own. Callers still hand
|
||
// them over separately — the importer has a list, not a blob — but where they end
|
||
// up is one place.
|
||
let mut body = input.body.clone();
|
||
if let Some(items) = &input.items {
|
||
for text in items {
|
||
body = derive::append_item(&body, text, false);
|
||
}
|
||
}
|
||
conn.execute(
|
||
"INSERT INTO notes (id, body, position, created_at, updated_at, dirty)
|
||
VALUES (?1, ?2, ?3, ?4, ?4, 1)",
|
||
params![id, body, position, ts],
|
||
)?;
|
||
// The FOLDED body, not the input one: an item can carry a #tag too.
|
||
lift_and_sync_tags(conn, &id, &body)?;
|
||
load_note(conn, &id)
|
||
}
|
||
|
||
/// How long one editing session is assumed to last.
|
||
///
|
||
/// Inside this window a note's body may be written any number of times and only the
|
||
/// FIRST write snapshots. That is what makes an idle-debounced autosave affordable:
|
||
/// a write costs a write, not a write plus a revision.
|
||
const REVISION_WINDOW_MINUTES: i64 = 10;
|
||
|
||
/// Whether a body change earns a snapshot of the pre-edit body.
|
||
///
|
||
/// Two conditions. The body must actually differ — re-saving identical text is not a
|
||
/// version of anything. And the note must not already carry a revision from this
|
||
/// editing session.
|
||
///
|
||
/// The session rule is what keeps version history worth reading. Because
|
||
/// [`snapshot_revision`] stores the body as it was BEFORE the edit, the first write
|
||
/// of a session captures the note as you found it, and every write after it inside
|
||
/// the window adds nothing. One revision per sitting falls out of the window on its
|
||
/// own — no "commit" the client has to declare, and no protocol surface to carry it,
|
||
/// which matters because sync-apply takes this same path.
|
||
fn should_snapshot(conn: &Connection, id: &str, new_body: &str) -> rusqlite::Result<bool> {
|
||
let current: String =
|
||
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?;
|
||
if current == new_body {
|
||
return Ok(false);
|
||
}
|
||
// String comparison, not date maths: timestamps are RFC3339 UTC with a fixed
|
||
// millisecond field (see the module header), so lexical order IS chronological.
|
||
let cutoff = (Utc::now() - Duration::minutes(REVISION_WINDOW_MINUTES))
|
||
.to_rfc3339_opts(SecondsFormat::Millis, true);
|
||
let recent: i64 = conn.query_row(
|
||
"SELECT COUNT(*) FROM note_revisions WHERE note_id = ?1 AND created_at >= ?2",
|
||
params![id, cutoff],
|
||
|r| r.get(0),
|
||
)?;
|
||
Ok(recent == 0)
|
||
}
|
||
|
||
fn snapshot_revision(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||
let body: String =
|
||
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?;
|
||
conn.execute(
|
||
"INSERT INTO note_revisions (id, note_id, body, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||
params![new_id(), id, body, now()],
|
||
)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// PATCH semantics: apply exactly the fields present in `changes`.
|
||
pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Result<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) — but only
|
||
// once per editing session, and only if it actually changed. See should_snapshot.
|
||
if let Some(body) = obj.get("body").and_then(|v| v.as_str()) {
|
||
if should_snapshot(conn, id, body)? {
|
||
snapshot_revision(conn, id)?;
|
||
}
|
||
}
|
||
|
||
for (k, v) in obj {
|
||
match k.as_str() {
|
||
"body" => {
|
||
let body = v.as_str().unwrap_or("");
|
||
conn.execute(
|
||
"UPDATE notes SET body = ?1 WHERE id = ?2",
|
||
params![body, id],
|
||
)?;
|
||
lift_and_sync_tags(conn, id, body)?;
|
||
}
|
||
"pinned" => {
|
||
if let Some(b) = v.as_bool() {
|
||
conn.execute("UPDATE notes SET pinned = ?1 WHERE id = ?2", params![b, id])?;
|
||
}
|
||
}
|
||
"archived" => {
|
||
if let Some(b) = v.as_bool() {
|
||
conn.execute(
|
||
"UPDATE notes SET archived = ?1 WHERE id = ?2",
|
||
params![b, id],
|
||
)?;
|
||
}
|
||
}
|
||
"remind_at" => {
|
||
let val = v.as_str().map(|s| s.to_string());
|
||
conn.execute(
|
||
"UPDATE notes SET remind_at = ?1 WHERE id = ?2",
|
||
params![val, id],
|
||
)?;
|
||
}
|
||
"recurrence" => {
|
||
let val = v.as_str().map(|s| s.to_string());
|
||
conn.execute(
|
||
"UPDATE notes SET recurrence = ?1 WHERE id = ?2",
|
||
params![val, id],
|
||
)?;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
touch(conn, id)?;
|
||
load_note(conn, id)
|
||
}
|
||
|
||
/// Mark a reminder handled.
|
||
///
|
||
/// A recurring reminder advances to its next occurrence; a one-off clears both
|
||
/// `remind_at` AND `recurrence`. Clearing the rule as well matters: without it a
|
||
/// note whose recurrence is a value we do not recognise would keep that value
|
||
/// forever, invisible in every UI (they only render known rules) and waiting to
|
||
/// mean something the day the vocabulary grows.
|
||
///
|
||
/// Same behaviour as the server's `POST /<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)
|
||
}
|
||
|
||
// ---- checklist items: every one of these is a body edit ---------------------
|
||
//
|
||
// They keep their own names and signatures because the FFI, the Tauri commands and
|
||
// the REST shape all speak in items, and a checklist is still a thing a note HAS.
|
||
// What changed is where it is kept. Routing all three through `update_note` rather
|
||
// than writing the body directly is what gives them revision snapshotting, `#tag`
|
||
// re-derivation and the dirty/updated_at bookkeeping without any of it being
|
||
// written a second time here.
|
||
|
||
fn note_body(conn: &Connection, id: &str) -> rusqlite::Result<String> {
|
||
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))
|
||
}
|
||
|
||
/// An item's id is its ordinal (see [items_of]). Anything else is a stale id from a
|
||
/// UI that has not reloaded, and the right answer to those is to do nothing.
|
||
fn item_index(item_id: &str) -> Option<usize> {
|
||
item_id.parse::<usize>().ok()
|
||
}
|
||
|
||
fn set_body(conn: &Connection, id: &str, body: String) -> rusqlite::Result<Note> {
|
||
update_note(conn, id, &json!({ "body": body }))
|
||
}
|
||
|
||
pub fn add_item(conn: &Connection, id: &str, text: &str) -> rusqlite::Result<Note> {
|
||
let body = note_body(conn, id)?;
|
||
set_body(conn, id, derive::append_item(&body, text, false))
|
||
}
|
||
|
||
pub fn update_item(
|
||
conn: &Connection,
|
||
id: &str,
|
||
item_id: &str,
|
||
changes: &Value,
|
||
) -> rusqlite::Result<Note> {
|
||
let index = match item_index(item_id) {
|
||
Some(i) => i,
|
||
None => return load_note(conn, id),
|
||
};
|
||
let mut body = note_body(conn, id)?;
|
||
if let Some(text) = changes.get("text").and_then(Value::as_str) {
|
||
body = derive::set_item_text(&body, index, text);
|
||
}
|
||
if let Some(checked) = changes.get("checked").and_then(Value::as_bool) {
|
||
body = derive::set_item_checked(&body, index, checked);
|
||
}
|
||
set_body(conn, id, body)
|
||
}
|
||
|
||
pub fn delete_item(conn: &Connection, id: &str, item_id: &str) -> rusqlite::Result<Note> {
|
||
let index = match item_index(item_id) {
|
||
Some(i) => i,
|
||
None => return load_note(conn, id),
|
||
};
|
||
let body = note_body(conn, id)?;
|
||
set_body(conn, id, derive::remove_item(&body, index))
|
||
}
|
||
|
||
pub fn delete_attachment(conn: &Connection, id: &str, att_id: &str) -> rusqlite::Result<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],
|
||
)?;
|
||
lift_and_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)
|
||
}
|
||
|
||
/// Rename a label. Renaming ONTO a name another label already holds MERGES the two.
|
||
///
|
||
/// It cannot simply be an UPDATE: `idx_labels_name` is unique on `lower(name)`, so
|
||
/// the bare statement failed with a raw SQLite "UNIQUE constraint failed" that
|
||
/// reached the user as database internals. Merging is the operator's call, and it
|
||
/// is the reading that matches what a person means — typing an existing tag's name
|
||
/// onto this one says "these are the same thing."
|
||
///
|
||
/// THE OLDER ROW SURVIVES, and takes the new spelling. Older rather than "the one
|
||
/// that already held the name" because age is the property neither participant's
|
||
/// role can change: rename A→B and rename B→A must land on the same survivor, or
|
||
/// the result depends on which way round someone happened to type it. Ties (two
|
||
/// labels minted in the same millisecond) go to the incumbent, so the outcome is
|
||
/// still deterministic.
|
||
///
|
||
/// Matching is case-insensitive, agreeing with `find_or_create_label` — "Groceries"
|
||
/// finds "groceries", and the survivor ends up spelled the way the caller asked.
|
||
pub fn rename_label(conn: &Connection, id: &str, name: &str) -> rusqlite::Result<Label> {
|
||
let clash: Option<(String, String)> = conn
|
||
.query_row(
|
||
"SELECT id, created_at FROM labels WHERE lower(name) = lower(?1) AND id <> ?2",
|
||
params![name, id],
|
||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||
)
|
||
.optional()?;
|
||
|
||
if let Some((other_id, other_created)) = clash {
|
||
let mine_created: String =
|
||
conn.query_row("SELECT created_at FROM labels WHERE id = ?1", [id], |r| {
|
||
r.get(0)
|
||
})?;
|
||
// `created_at` is RFC3339 to the millisecond with a `Z`, so it is fixed-width
|
||
// and lexicographic order IS chronological order — no parsing needed.
|
||
let (survivor, doomed) = if other_created <= mine_created {
|
||
(other_id, id.to_string())
|
||
} else {
|
||
(id.to_string(), other_id)
|
||
};
|
||
// Reuse the merge rather than re-implement it: it is the only place that
|
||
// knows to mark every affected NOTE dirty before the delete cascades the
|
||
// membership rows away, which is what makes the merge reach the server.
|
||
merge_labels(conn, &doomed, &survivor)?;
|
||
// The survivor may still carry the old spelling — it is the one that keeps
|
||
// existing, so it is the one that has to end up named what was asked for.
|
||
conn.execute(
|
||
"UPDATE labels SET name = ?1, updated_at = ?2, dirty = 1 WHERE id = ?3",
|
||
params![name, now(), survivor],
|
||
)?;
|
||
return load_label(conn, &survivor);
|
||
}
|
||
|
||
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(¶ms_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(¶ms_str).unwrap_or_else(|_| serde_json::json!({}));
|
||
Ok(SavedFilter {
|
||
id: r.get(0)?,
|
||
name: r.get(1)?,
|
||
params,
|
||
position: r.get(3)?,
|
||
})
|
||
},
|
||
)
|
||
}
|