CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m28s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m1s
Step 3 of M315, and the destructive half. Steps 1 and 2 stopped every read of this field: a card is one neutral surface per theme, and the only coloured thing on a board is a tag. What was left was a column written by a picker and read by nothing. Rule 22 — the old path comes out completely. No flag, no fallback, no "override if set". Server: the column, the `?color=` facet, the create/update/serialise paths, the sync assignment, the front-matter line, and Keep's colour map. Alembic 0029 drops it and sweeps `"color"` out of stored saved-filter params — a view that silently filtered on a field the app no longer has would return nothing and never say why. That sweep is Python, not `params::jsonb - 'color'`, because Postgres has no try-cast and one malformed blob would abort a migration that is running over somebody's saved views. `NOTE_COLORS` moves from `models/note.py` to `colors.py`. A palette defined on the model that lost one is an invitation to put the column back; labels still name a colour, so the vocabulary belongs where the normalizer already is. Core: the field, the facet, the `NoteCreateInput`, and every read and write in store/push/pull. Local schema v9 drops the column and does the same saved-filter sweep, guarded on `json_valid` so a corrupt blob loses a key rather than becoming NULL. The uniffi layer drops `NoteEdit::Color` and `NoteDraft.color` with it. Web: `ColorPicker.vue`, the per-card swatch popover and its stylesheet rule, the FilterBar colour row, the facet in the query round-trip, and the colour half of the editor's baseline-and-save. Android: the `ColorSheet`, the `Picker.COLOR` case, the toolbar's swatch dot, `EditorAction.SetColor`. ## The protocol: v4, and the floor deliberately stays at 3 Checked against `compat.rs` and the push handler rather than trusting the `#[serde(default)]` annotation, because the v2 precedent points the other way: v2 dropped `kind` and `title` and DID raise both floors, on the rule that dropping a field a client sends and expects back is breaking. `color` fails the second half of that test. A v3 client reading a v4 note gets `"default"` from its own serde default and draws the colour it derives locally — the board it drew yesterday. A v3 client pushing `color` has the key ignored, since `_assign_note_fields` reads its payload key by key and never validates the shape. Neither direction errors and neither shows anything wrong. `title` was the note's NAME; this is a field that no longer renders. So `SYNC_PROTOCOL_VERSION` and `CLIENT_PROTOCOL_VERSION` go to 4, and both floors stay at 3. `docs/sync.md` carries the reasoning and the per-version history, and its push example is brought back in line — it still listed `title`, `kind` and `items`, all gone before this. Import stays tolerant: a pre-M315 export or a Keep takeout carrying `color:` imports fine, the key simply read past. Old exports must still import. #3041 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
941 lines
34 KiB
Rust
941 lines
34 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)
|
||
}
|
||
|
||
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(¶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)?,
|
||
})
|
||
},
|
||
)
|
||
}
|