Files
inkwell/core/src/local/store.rs
T
bvandeusenandClaude Opus 5.5 89cd1c76bb core, web: pin, archive and reorder a note someone shared with you
Schema v12 adds notes.state_at: a recipient's own pin, archive and order are
stamped there instead of on updated_at, which stays the text's time. Push sends
them only to a server advertising `shared_state` (push::Accepts), a view share
included; the first pull at that level starts the feed over once so held copies
drop their owner's pins. The client speaks protocol 7 and lists `shares` and
`shared_state` among the features a server may lack.

The web card and editor offer pin, archive and drag on shared notes; share and
trash stay the owner's, and the board's trash key skips notes you don't own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:13:58 -04:00

2054 lines
74 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, upload_error 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,
upload_error: r.get(6)?,
})
})?;
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,
permission, shared, shared_by_id, shared_by_name
FROM notes WHERE id = ?1",
[id],
// By NAME, not by position. Dropping `color` shifted every column after it
// and the two timestamps were missed (fa89da1): created_at read updated_at
// and updated_at read trashed_at, with nothing failing. A name either
// resolves or errors.
|r| {
Ok(Note {
id: r.get("id")?,
display_title: String::new(), // derived below, from the body
body: r.get("body")?,
position: r.get("position")?,
pinned: r.get("pinned")?,
archived: r.get("archived")?,
trashed: r.get("trashed")?,
deleted_at: r.get("trashed_at")?,
remind_at: r.get("remind_at")?,
recurrence: r.get("recurrence")?,
labels: Vec::new(),
items: Vec::new(),
attachments: Vec::new(),
previews: Vec::new(),
created_at: r.get("created_at")?,
updated_at: r.get("updated_at")?,
permission: r.get("permission")?,
shared: r.get("shared")?,
shared_by: match r.get::<_, Option<String>>("shared_by_id")? {
Some(id) => Some(SharedBy {
id,
display_name: r
.get::<_, Option<String>>("shared_by_name")?
.unwrap_or_default(),
}),
None => None,
},
})
},
)?;
note.labels = load_labels(conn, id)?;
note.items = items_of(&note.body);
note.attachments = load_attachments(conn, id)?;
note.previews = load_previews(conn, id)?;
note.display_title = display_title(&note.body);
Ok(note)
}
// ---- notes shared with this account (#5175) ---------------------------------
//
// A note someone else owns arrives with `permission` `edit` (its text may change
// here) or `view` (it may not). Its pin, archive and place on this board are this
// account's own either way (#5176). Everything else about it — trash, reminders,
// labels, files — stays its owner's. The server enforces the same split;
// refusing here as well tells the person at once, instead of letting an edit be made,
// queued, and then thrown away by the next sync.
pub const VIEW_ONLY: &str =
"This note was shared with you to view, so its text can't be changed here.";
pub const OWNER_ONLY: &str = "Only the note's owner can change that.";
/// A refusal carrying words to show. Wrapped so it travels as a `rusqlite::Error`
/// like every other store failure, and prints as exactly the message.
#[derive(Debug)]
struct Refused(&'static str);
impl std::fmt::Display for Refused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
impl std::error::Error for Refused {}
fn refuse(message: &'static str) -> rusqlite::Error {
rusqlite::Error::ToSqlConversionFailure(Box::new(Refused(message)))
}
fn permission_of(conn: &Connection, id: &str) -> rusqlite::Result<String> {
conn.query_row("SELECT permission FROM notes WHERE id = ?1", [id], |r| {
r.get(0)
})
}
/// The note's text may change here: it is ours, or shared with us to edit.
fn require_text(conn: &Connection, id: &str) -> rusqlite::Result<String> {
let permission = permission_of(conn, id)?;
if permission == "view" {
return Err(refuse(VIEW_ONLY));
}
Ok(permission)
}
/// Anything but the text and this account's own pin, archive and place: only the
/// owner.
fn require_owner(conn: &Connection, id: &str) -> rusqlite::Result<()> {
match permission_of(conn, id)?.as_str() {
"owner" => Ok(()),
"view" => Err(refuse(VIEW_ONLY)),
_ => Err(refuse(OWNER_ONLY)),
}
}
/// This account's own pin, archive or place changed on a note someone else owns.
/// Stamped apart from `updated_at`, which is the time of the note's text; see
/// SCHEMA_V12 for why the two must not share a clock.
fn touch_own_state(conn: &Connection, id: &str) -> rusqlite::Result<()> {
conn.execute(
"UPDATE notes SET state_at = ?1, dirty = 1 WHERE id = ?2",
params![now(), id],
)?;
Ok(())
}
/// The fields of a note anyone it is shared with may set: their own.
const OWN_FIELDS: [&str; 2] = ["pinned", "archived"];
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 &current {
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 is the owner's: a note its owner trashed leaves a recipient's board
// without turning up in their Trash, where they could do nothing with it.
"trash" => "trashed = 1 AND permission = 'owner'",
"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()) {
// One placeholder, one bind. This pushed the pattern twice after the
// title column went (95aa10c), and rusqlite refuses a count mismatch,
// so every desktop search failed.
sql.push_str(" AND body LIKE ? ESCAPE '\\'");
binds.push(format!("%{}%", escape_like(text)));
}
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 f.shared.as_deref() == Some("with_me") {
sql.push_str(" AND permission <> 'owner'");
}
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 =
// The owner's reminders: a note shared with us neither alerts us nor is ours
// to clear, the same as on the server.
conn.prepare("SELECT id FROM notes WHERE trashed = 0 AND remind_at IS NOT NULL AND permission = 'owner' 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()
}
/// Reminders due at or before `now_ms`, soonest first, trash excluded.
///
/// Read on a timer by the desktop's reminder worker, so only the notes that are
/// actually due are loaded in full (for their title). A `remind_at` that doesn't
/// parse is left out: it can never come due, and announcing it would be a guess.
pub fn due_reminders(conn: &Connection, now_ms: i64) -> rusqlite::Result<Vec<DueReminder>> {
let set: Vec<(String, String)> = {
let mut stmt = conn.prepare(
"SELECT id, remind_at FROM notes
WHERE trashed = 0 AND remind_at IS NOT NULL AND permission = 'owner'
ORDER BY remind_at ASC",
)?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
rows.collect::<rusqlite::Result<_>>()?
};
let mut due = Vec::new();
for (id, remind_at) in set {
let Ok(at) = DateTime::parse_from_rfc3339(&remind_at) else {
continue;
};
let due_ms = at.timestamp_millis();
if due_ms > now_ms {
continue;
}
let title = load_note(conn, &id)?.display_title;
due.push(DueReminder {
id,
title,
remind_at,
due_ms,
});
}
// By instant, not by string: a synced `+00:00` and a local `Z` sort apart.
due.sort_by_key(|d| d.due_ms);
Ok(due)
}
pub fn titles(conn: &Connection) -> rusqlite::Result<Vec<TitleEntry>> {
// Names come from `load_note` so the palette and the card can never disagree
// about what a note is called. 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()))?;
let text = obj.contains_key("body");
let permission = if text {
require_text(conn, id)?
} else {
permission_of(conn, id)?
};
let owned = permission == "owner";
if !owned
&& obj
.keys()
.any(|k| k != "body" && !OWN_FIELDS.contains(&k.as_str()))
{
return Err(refuse(OWNER_ONLY));
}
// 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],
)?;
// A #tag typed into someone else's note files it under THEIR labels,
// which the server does when the text arrives. Lifting it here would
// file it under ours.
if owned {
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],
)?;
}
_ => {}
}
}
if owned || text {
touch(conn, id)?;
}
if !owned && OWN_FIELDS.iter().any(|k| obj.contains_key(*k)) {
touch_own_state(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> {
require_owner(conn, id)?;
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> {
require_owner(conn, id)?;
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> {
require_owner(conn, id)?;
// 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))
}
/// The stored form of a declared content type: the bare media type, lowercase.
/// Matches the server's `normalize_mime`, so both sides file a type the same way.
fn normalize_mime(raw: &str) -> String {
let bare = raw
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
if bare.is_empty() {
"application/octet-stream".to_string()
} else {
bare
}
}
/// A file's name reduced to its last path component, as the server's
/// `_safe_filename` does — the name is for display and download, never a path.
pub(crate) fn safe_filename(raw: &str) -> String {
let base = raw.trim().replace('\\', "/");
let base = base.rsplit('/').next().unwrap_or("").trim();
let capped: String = base.chars().take(255).collect();
if capped.is_empty() {
"file".to_string()
} else {
capped
}
}
/// Attach a file on this device, linked or not.
///
/// The bytes go into the blob store under their hash, and the row waits with
/// `uploaded = 0` until push sends it — after the note itself has landed, since the
/// server files an attachment under its note. The note is touched, so it is dirty
/// too: that is what a background sync keys on to send it promptly.
pub fn add_attachment(
conn: &Connection,
blobs: &crate::sync::blobs::BlobStore,
note_id: &str,
filename: &str,
mime: &str,
bytes: &[u8],
) -> Result<Note, String> {
let exists: Option<i64> = conn
.query_row("SELECT 1 FROM notes WHERE id = ?1", [note_id], |r| r.get(0))
.optional()
.map_err(|e| e.to_string())?;
if exists.is_none() {
return Err("That note no longer exists.".to_string());
}
require_owner(conn, note_id).map_err(|e| e.to_string())?;
let sha256 = blobs.put(bytes)?;
let id = new_id();
conn.execute(
"INSERT INTO attachments (id, note_id, url, filename, mime, size, sha256, position, uploaded)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7,
(SELECT COALESCE(MAX(position) + 1, 0) FROM attachments WHERE note_id = ?2),
0)",
params![
id,
note_id,
// The server's route for it, which is what the row holds once it has
// synced too. Nothing renders it: `load_attachments` serves the local bytes.
format!("/api/notes/{note_id}/attachments/{id}"),
safe_filename(filename),
normalize_mime(mime),
bytes.len() as i64,
sha256,
],
)
.map_err(|e| e.to_string())?;
touch(conn, note_id).map_err(|e| e.to_string())?;
load_note(conn, note_id).map_err(|e| e.to_string())
}
pub fn delete_attachment(conn: &Connection, id: &str, att_id: &str) -> rusqlite::Result<Note> {
require_owner(conn, id)?;
let uploaded: Option<bool> = conn
.query_row(
"SELECT uploaded FROM attachments WHERE id = ?1 AND note_id = ?2",
params![att_id, id],
|r| r.get(0),
)
.optional()?;
conn.execute(
"DELETE FROM attachments WHERE id = ?1 AND note_id = ?2",
params![att_id, id],
)?;
// Only a file the server holds needs a tombstone; without one the next pull would
// bring it straight back. One still waiting to upload leaves the queue with its row.
if uploaded == Some(true) {
record_pending_delete(conn, "attachment", att_id)?;
}
touch(conn, id)?;
load_note(conn, id)
}
pub fn delete_preview(conn: &Connection, id: &str, preview_id: &str) -> rusqlite::Result<Note> {
require_owner(conn, id)?;
let removed = conn.execute(
"DELETE FROM link_previews WHERE id = ?1 AND note_id = ?2",
params![preview_id, id],
)?;
// Previews are made by the server, so every one it has is one it would send back.
if removed > 0 {
record_pending_delete(conn, "preview", preview_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;
let at = now();
for (i, id) in ordered_ids.iter().enumerate() {
// A note someone shared with us moves on this account's board only (#5176),
// stamped as its own state rather than as an edit.
conn.execute(
"UPDATE notes
SET position = ?1, dirty = 1,
state_at = CASE WHEN permission = 'owner' THEN state_at ELSE ?3 END
WHERE id = ?2",
params![total - i as i64, id, at],
)?;
}
Ok(())
}
pub fn trash(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
require_owner(conn, id)?;
// 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> {
require_owner(conn, id)?;
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<()> {
// A note that is already gone is still fine to delete, as it always was; one that
// is someone else's is not ours to delete.
if permission_of(conn, id)
.optional()?
.is_some_and(|p| p != "owner")
{
return Err(refuse(OWNER_ONLY));
}
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 owned = require_text(conn, id)? == "owner";
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],
)?;
if owned {
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(&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)?,
})
},
)
}
#[cfg(test)]
mod tests {
//! The store had no tests of its own until two bugs shipped in it unnoticed:
//! `load_note` reading its timestamps from the wrong columns after one was
//! dropped (fa89da1), and the board's text facet binding its pattern twice for
//! one placeholder (95aa10c). Both are the kind of thing only a real query
//! against a real schema catches, which is what every test here runs.
use super::*;
use crate::local::schema;
fn db() -> Connection {
let conn = Connection::open_in_memory().expect("in-memory db");
schema::migrate(&conn).expect("migrate");
conn
}
fn note(conn: &Connection, body: &str) -> Note {
create_note(
conn,
&NoteCreateInput {
body: body.to_string(),
items: None,
},
)
.expect("create")
}
/// A note written straight into the table, so its timestamps are exactly
/// what the test says rather than whatever `now()` returned.
fn stamped(conn: &Connection, id: &str, body: &str, created: &str, updated: &str) {
conn.execute(
"INSERT INTO notes (id, body, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
params![id, body, created, updated],
)
.expect("insert");
}
fn list(conn: &Connection, query: Value) -> Vec<Note> {
let query: ListQuery = serde_json::from_value(query).expect("query shape");
list_notes(conn, &query).expect("list")
}
fn ids(notes: &[Note]) -> Vec<&str> {
notes.iter().map(|n| n.id.as_str()).collect()
}
// ---- notes shared with this account (#5175) --------------------------------
/// A note someone else owns, as the feed leaves it: clean, and held at `permission`.
fn shared(conn: &Connection, id: &str, permission: &str) {
stamped(
conn,
id,
"their words",
"2026-01-01T00:00:00.000Z",
"2026-01-01T00:00:00.000Z",
);
conn.execute(
"UPDATE notes SET permission = ?2, shared = 1, shared_by_id = 'u-robin',
shared_by_name = 'Robin', dirty = 0
WHERE id = ?1",
params![id, permission],
)
.expect("share");
}
fn dirty(conn: &Connection, id: &str) -> bool {
conn.query_row("SELECT dirty FROM notes WHERE id = ?1", [id], |r| r.get(0))
.expect("dirty")
}
#[test]
fn a_shared_note_says_who_shared_it_and_how() {
let conn = db();
shared(&conn, "n", "view");
let n = get_note(&conn, "n").expect("get");
assert_eq!(n.permission, "view");
assert!(n.shared);
let by = n.shared_by.expect("shared_by");
assert_eq!(
(by.id.as_str(), by.display_name.as_str()),
("u-robin", "Robin")
);
let own = note(&conn, "mine");
assert_eq!((own.permission.as_str(), own.shared), ("owner", false));
assert!(own.shared_by.is_none());
}
#[test]
fn a_view_share_changes_nothing_here() {
let conn = db();
shared(&conn, "n", "view");
let refused = update_note(&conn, "n", &json!({ "body": "mine now" }))
.err()
.expect("refused");
// The words the person sees, exactly: the refusal prints as its message.
assert_eq!(refused.to_string(), VIEW_ONLY);
assert!(add_item(&conn, "n", "eggs").is_err());
assert!(trash(&conn, "n").is_err());
assert!(snooze_reminder(&conn, "n", 10).is_err());
assert!(set_labels(&conn, "n", &[]).is_err());
assert!(delete_forever(&conn, "n").is_err());
assert_eq!(get_note(&conn, "n").expect("get").body, "their words");
assert!(!dirty(&conn, "n"));
}
#[test]
fn an_edit_share_changes_the_text_and_nothing_else() {
let conn = db();
shared(&conn, "n", "edit");
let edited = update_note(&conn, "n", &json!({ "body": "their words, edited\n#mine" }))
.expect("body");
// The tag is the server's to file, under the owner's labels; not ours.
assert!(edited.labels.is_empty());
assert_eq!(edited.body, "their words, edited\n#mine");
assert!(dirty(&conn, "n"));
add_item(&conn, "n", "eggs").expect("an item is text");
let reminded = update_note(
&conn,
"n",
&json!({ "remind_at": "2026-02-01T00:00:00.000Z" }),
)
.err()
.expect("refused");
assert_eq!(reminded.to_string(), OWNER_ONLY);
assert!(update_note(&conn, "n", &json!({ "body": "x", "recurrence": "daily" })).is_err());
assert!(trash(&conn, "n").is_err());
assert!(complete_reminder(&conn, "n").is_err());
}
#[test]
fn shared_notes_stay_out_of_trash_and_reminders() {
let conn = db();
shared(&conn, "theirs", "edit");
conn.execute(
"UPDATE notes SET trashed = 1, trashed_at = '2026-01-02T00:00:00.000Z',
remind_at = '2026-01-03T00:00:00.000Z'
WHERE id = 'theirs'",
[],
)
.expect("owner trashed it");
assert!(ids(&list(&conn, json!({ "view": "trash" }))).is_empty());
assert!(ids(&list(&conn, json!({ "view": "notes" }))).is_empty());
conn.execute("UPDATE notes SET trashed = 0 WHERE id = 'theirs'", [])
.expect("restored");
assert!(reminders(&conn).expect("reminders").is_empty());
assert!(due_reminders(&conn, i64::MAX).expect("due").is_empty());
}
#[test]
fn a_recipient_pins_archives_and_orders_their_own_copy() {
let conn = db();
shared(&conn, "theirs", "view");
let state_at = |conn: &Connection| -> Option<String> {
conn.query_row("SELECT state_at FROM notes WHERE id = 'theirs'", [], |r| {
r.get(0)
})
.expect("state_at")
};
let pinned = update_note(
&conn,
"theirs",
&json!({ "pinned": true, "archived": true }),
)
.expect("a view share may still be pinned");
assert!(pinned.pinned && pinned.archived);
assert!(dirty(&conn, "theirs"));
// Stamped as this account's own state, never as an edit to the owner's text.
assert_eq!(
pinned.updated_at.as_deref(),
Some("2026-01-01T00:00:00.000Z")
);
let first = state_at(&conn).expect("stamped");
conn.execute("UPDATE notes SET dirty = 0, state_at = NULL", [])
.expect("synced");
reorder(&conn, &["theirs".to_string()]).expect("reorder");
assert!(dirty(&conn, "theirs"));
assert!(state_at(&conn).is_some_and(|at| at >= first));
assert_eq!(get_note(&conn, "theirs").expect("get").position, 1);
// An own note's order is still its own edit, with no separate stamp.
let mine = note(&conn, "mine");
reorder(&conn, &[mine.id.clone()]).expect("reorder mine");
let own: Option<String> = conn
.query_row(
"SELECT state_at FROM notes WHERE id = ?1",
[&mine.id],
|r| r.get(0),
)
.expect("own state_at");
assert!(own.is_none());
}
#[test]
fn shared_with_me_narrows_the_board_to_other_peoples_notes() {
let conn = db();
shared(&conn, "theirs", "view");
note(&conn, "mine");
let narrowed = list(
&conn,
json!({ "view": "notes", "facets": { "shared": "with_me" } }),
);
assert_eq!(ids(&narrowed), ["theirs"]);
assert_eq!(list(&conn, json!({ "view": "notes" })).len(), 2);
}
// ---- reading a note --------------------------------------------------------
#[test]
fn each_timestamp_comes_from_its_own_column() {
let conn = db();
stamped(
&conn,
"n",
"body",
"2026-01-01T00:00:00.000Z",
"2026-02-02T00:00:00.000Z",
);
conn.execute(
"UPDATE notes SET trashed = 1, trashed_at = '2026-03-03T00:00:00.000Z' WHERE id = 'n'",
[],
)
.expect("trash");
let n = get_note(&conn, "n").expect("get");
assert_eq!(n.created_at.as_deref(), Some("2026-01-01T00:00:00.000Z"));
assert_eq!(n.updated_at.as_deref(), Some("2026-02-02T00:00:00.000Z"));
assert_eq!(n.deleted_at.as_deref(), Some("2026-03-03T00:00:00.000Z"));
}
#[test]
fn a_new_note_carries_both_timestamps_and_no_trash_time() {
let conn = db();
let n = note(&conn, "hello");
assert!(n.created_at.is_some(), "created_at missing");
assert!(n.updated_at.is_some(), "updated_at missing");
assert!(n.deleted_at.is_none());
}
#[test]
fn a_list_only_note_is_named_by_its_first_item() {
let conn = db();
let n = note(&conn, "- [ ] milk\n- [ ] eggs");
assert_eq!(n.display_title, "milk");
assert_eq!(n.items.len(), 2);
let gone = note(&conn, "trashed one");
trash(&conn, &gone.id).expect("trash");
let names: Vec<String> = titles(&conn)
.expect("titles")
.into_iter()
.map(|t| t.title)
.collect();
assert_eq!(
names,
vec!["milk".to_string()],
"trash has no place in the palette"
);
}
// ---- the board query -------------------------------------------------------
#[test]
fn text_search_on_the_board_matches_the_body() {
let conn = db();
let milk = note(&conn, "buy milk");
note(&conn, "call mom");
let found = list(&conn, json!({"view": "notes", "facets": {"q": "milk"}}));
assert_eq!(ids(&found), vec![milk.id.as_str()]);
}
#[test]
fn text_search_combines_with_a_date_range() {
// The facets bind positionally, so a text facet that binds the wrong number
// of values shifts every bound after it — this pairs it with one.
let conn = db();
stamped(
&conn,
"old",
"milk",
"2026-01-01T00:00:00.000Z",
"2026-01-01T00:00:00.000Z",
);
stamped(
&conn,
"new",
"milk",
"2026-07-01T00:00:00.000Z",
"2026-07-01T00:00:00.000Z",
);
stamped(
&conn,
"other",
"eggs",
"2026-07-01T00:00:00.000Z",
"2026-07-01T00:00:00.000Z",
);
let found = list(
&conn,
json!({"view": "notes", "facets": {"q": "milk", "created_after": "2026-06-01T00:00:00.000Z"}}),
);
assert_eq!(ids(&found), vec!["new"]);
}
#[test]
fn text_search_takes_like_wildcards_literally() {
let conn = db();
let sure = note(&conn, "100% sure");
note(&conn, "1000 things");
let found = list(&conn, json!({"view": "notes", "facets": {"q": "100%"}}));
assert_eq!(ids(&found), vec![sure.id.as_str()]);
}
#[test]
fn label_filters_are_anded() {
let conn = db();
let home = create_label(&conn, "home").expect("label");
let work = create_label(&conn, "work").expect("label");
let both = note(&conn, "both");
let one = note(&conn, "one");
set_labels(&conn, &both.id, &[home.id.clone(), work.id.clone()]).expect("labels");
set_labels(&conn, &one.id, std::slice::from_ref(&home.id)).expect("labels");
let found = list(
&conn,
json!({"view": "notes", "labelId": home.id, "facets": {"label": [work.id]}}),
);
assert_eq!(ids(&found), vec![both.id.as_str()]);
}
#[test]
fn reminder_and_attachment_facets() {
let conn = db();
let reminded = note(&conn, "reminded");
let attached = note(&conn, "attached");
note(&conn, "plain");
update_note(
&conn,
&reminded.id,
&json!({"remind_at": "2026-12-01T09:00:00.000Z"}),
)
.expect("remind");
conn.execute(
"INSERT INTO attachments (id, note_id, url) VALUES ('a', ?1, '/x')",
[&attached.id],
)
.expect("attach");
let r = list(
&conn,
json!({"view": "notes", "facets": {"has_reminder": true}}),
);
assert_eq!(ids(&r), vec![reminded.id.as_str()]);
let a = list(
&conn,
json!({"view": "notes", "facets": {"has_attachment": true}}),
);
assert_eq!(ids(&a), vec![attached.id.as_str()]);
}
#[test]
fn views_split_the_board_archive_and_trash() {
let conn = db();
let kept = note(&conn, "kept");
let archived = note(&conn, "archived");
let trashed = note(&conn, "trashed");
update_note(&conn, &archived.id, &json!({"archived": true})).expect("archive");
trash(&conn, &trashed.id).expect("trash");
assert_eq!(
ids(&list(&conn, json!({"view": "notes"}))),
vec![kept.id.as_str()]
);
assert_eq!(
ids(&list(&conn, json!({"view": "archived"}))),
vec![archived.id.as_str()]
);
assert_eq!(
ids(&list(&conn, json!({"view": "trash"}))),
vec![trashed.id.as_str()]
);
}
#[test]
fn pinned_first_then_position() {
let conn = db();
let first = note(&conn, "first");
let second = note(&conn, "second");
// Newest is on top by position...
assert_eq!(
ids(&list(&conn, json!({"view": "notes"}))),
vec![second.id.as_str(), first.id.as_str()]
);
// ...until the older one is pinned.
update_note(&conn, &first.id, &json!({"pinned": true})).expect("pin");
assert_eq!(
ids(&list(&conn, json!({"view": "notes"}))),
vec![first.id.as_str(), second.id.as_str()]
);
}
#[test]
fn sort_created_orders_by_birth_not_by_edit() {
let conn = db();
stamped(
&conn,
"older",
"a",
"2026-01-01T00:00:00.000Z",
"2026-09-01T00:00:00.000Z",
);
stamped(
&conn,
"newer",
"b",
"2026-05-01T00:00:00.000Z",
"2026-05-01T00:00:00.000Z",
);
let found = list(&conn, json!({"view": "notes", "sort": "created"}));
assert_eq!(ids(&found), vec!["newer", "older"]);
}
#[test]
fn reorder_puts_the_first_id_on_top() {
let conn = db();
let a = note(&conn, "a");
let b = note(&conn, "b");
reorder(&conn, &[a.id.clone(), b.id.clone()]).expect("reorder");
assert_eq!(
ids(&list(&conn, json!({"view": "notes"}))),
vec![a.id.as_str(), b.id.as_str()]
);
}
// ---- #tags -----------------------------------------------------------------
#[test]
fn a_standalone_tag_is_lifted_into_a_manual_label() {
let conn = db();
let n = note(&conn, "milk\n#groceries");
assert_eq!(n.body, "milk");
assert_eq!(n.labels.len(), 1);
assert_eq!(n.labels[0].name, "groceries");
assert!(
!n.labels[0].via_tag,
"nothing in the body backs it any more"
);
}
#[test]
fn an_inline_tag_stays_and_detaches_when_its_text_goes() {
let conn = db();
let n = note(&conn, "buy #milk today");
assert_eq!(n.body, "buy #milk today");
assert!(n.labels.iter().any(|l| l.name == "milk" && l.via_tag));
let edited = update_note(&conn, &n.id, &json!({"body": "buy today"})).expect("edit");
assert!(edited.labels.is_empty());
}
// ---- writes ----------------------------------------------------------------
#[test]
fn only_the_first_write_of_an_editing_session_snapshots() {
let conn = db();
let n = note(&conn, "one");
update_note(&conn, &n.id, &json!({"body": "two"})).expect("edit");
update_note(&conn, &n.id, &json!({"body": "three"})).expect("edit");
let history = revisions(&conn, &n.id).expect("revisions");
assert_eq!(history.len(), 1);
assert_eq!(history[0].body, "one", "the note as it was found");
}
#[test]
fn rewriting_the_same_body_is_not_a_revision() {
let conn = db();
let n = note(&conn, "same");
update_note(&conn, &n.id, &json!({"body": "same"})).expect("edit");
assert!(revisions(&conn, &n.id).expect("revisions").is_empty());
}
#[test]
fn restoring_a_revision_brings_back_its_body() {
let conn = db();
let n = note(&conn, "original");
update_note(&conn, &n.id, &json!({"body": "changed"})).expect("edit");
let rev = revisions(&conn, &n.id).expect("revisions").remove(0);
let restored = restore_revision(&conn, &n.id, &rev.id).expect("restore");
assert_eq!(restored.body, "original");
}
#[test]
fn ticking_an_item_rewrites_its_line_and_a_stale_index_does_nothing() {
let conn = db();
let n = note(&conn, "- [ ] milk\n- [ ] eggs");
let ticked = update_item(&conn, &n.id, "1", &json!({"checked": true})).expect("tick");
assert_eq!(ticked.body, "- [ ] milk\n- [x] eggs");
let stale = update_item(&conn, &n.id, "9", &json!({"checked": true})).expect("stale");
assert_eq!(stale.body, ticked.body);
}
#[test]
fn trash_keeps_its_first_time_and_restore_clears_it() {
let conn = db();
let n = note(&conn, "bin me");
let first = trash(&conn, &n.id).expect("trash").deleted_at;
assert!(first.is_some());
let again = trash(&conn, &n.id).expect("trash again").deleted_at;
assert_eq!(again, first, "a second trash must not restart retention");
let back = restore(&conn, &n.id).expect("restore");
assert!(!back.trashed);
assert!(back.deleted_at.is_none());
}
#[test]
fn deleting_forever_leaves_a_tombstone_for_sync() {
let conn = db();
let n = note(&conn, "gone");
delete_forever(&conn, &n.id).expect("delete");
let tombstones: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pending_deletes WHERE entity = 'note' AND id = ?1",
[&n.id],
|r| r.get(0),
)
.expect("count");
assert_eq!(tombstones, 1);
assert!(get_note(&conn, &n.id).is_err());
}
// ---- reminders -------------------------------------------------------------
#[test]
fn completing_a_one_off_reminder_clears_it() {
let conn = db();
let n = note(&conn, "once");
update_note(
&conn,
&n.id,
&json!({"remind_at": "2026-01-01T09:00:00.000Z"}),
)
.expect("remind");
let done = complete_reminder(&conn, &n.id).expect("complete");
assert!(done.remind_at.is_none());
assert!(done.recurrence.is_none());
}
#[test]
fn completing_a_recurring_reminder_moves_it_into_the_future() {
let conn = db();
let n = note(&conn, "daily");
update_note(
&conn,
&n.id,
&json!({"remind_at": "2026-01-01T09:00:00.000Z", "recurrence": "daily"}),
)
.expect("remind");
let done = complete_reminder(&conn, &n.id).expect("complete");
let next = DateTime::parse_from_rfc3339(done.remind_at.as_deref().expect("still set"))
.expect("rfc3339");
assert!(next.with_timezone(&Utc) > Utc::now());
assert_eq!(done.recurrence.as_deref(), Some("daily"));
}
#[test]
fn reminders_come_soonest_first_and_skip_the_trash() {
let conn = db();
let later = note(&conn, "later");
let sooner = note(&conn, "sooner");
let binned = note(&conn, "binned");
update_note(
&conn,
&later.id,
&json!({"remind_at": "2026-12-02T09:00:00.000Z"}),
)
.expect("remind");
update_note(
&conn,
&sooner.id,
&json!({"remind_at": "2026-12-01T09:00:00.000Z"}),
)
.expect("remind");
update_note(
&conn,
&binned.id,
&json!({"remind_at": "2026-11-01T09:00:00.000Z"}),
)
.expect("remind");
trash(&conn, &binned.id).expect("trash");
assert_eq!(
ids(&reminders(&conn).expect("reminders")),
vec![sooner.id.as_str(), later.id.as_str()]
);
}
#[test]
fn only_reminders_already_due_are_due() {
let conn = db();
let past = note(&conn, "past");
let future = note(&conn, "future");
let binned = note(&conn, "binned");
update_note(
&conn,
&past.id,
&json!({"remind_at": "2026-03-01T09:00:00.000Z"}),
)
.expect("remind");
update_note(
&conn,
&future.id,
&json!({"remind_at": "2026-03-01T11:00:00.000Z"}),
)
.expect("remind");
update_note(
&conn,
&binned.id,
&json!({"remind_at": "2026-03-01T08:00:00.000Z"}),
)
.expect("remind");
trash(&conn, &binned.id).expect("trash");
let ten = DateTime::parse_from_rfc3339("2026-03-01T10:00:00Z")
.expect("rfc3339")
.timestamp_millis();
let due = due_reminders(&conn, ten).expect("due");
assert_eq!(due.len(), 1);
assert_eq!(due[0].id, past.id);
assert_eq!(due[0].title, past.display_title);
assert_eq!(due[0].remind_at, "2026-03-01T09:00:00.000Z");
assert_eq!(due[0].due_ms, ten - 3_600_000);
// At the instant itself it is due.
assert_eq!(due_reminders(&conn, ten + 3_600_000).expect("due").len(), 2);
}
// ---- labels ----------------------------------------------------------------
#[test]
fn renaming_onto_an_existing_name_merges_into_the_older_label() {
let conn = db();
let work = create_label(&conn, "work").expect("label");
let jobs = create_label(&conn, "jobs").expect("label");
let n = note(&conn, "task");
set_labels(&conn, &n.id, std::slice::from_ref(&jobs.id)).expect("labels");
let survivor = rename_label(&conn, &jobs.id, "Work").expect("rename");
assert_eq!(survivor.id, work.id);
assert_eq!(survivor.name, "Work");
assert_eq!(list_labels(&conn).expect("labels").len(), 1);
let labels = get_note(&conn, &n.id).expect("get").labels;
assert_eq!(labels.len(), 1);
assert_eq!(labels[0].id, work.id, "the note follows the merge");
}
#[test]
fn label_counts_ignore_trashed_notes() {
let conn = db();
let home = create_label(&conn, "home").expect("label");
let kept = note(&conn, "kept");
let binned = note(&conn, "binned");
set_labels(&conn, &kept.id, std::slice::from_ref(&home.id)).expect("labels");
set_labels(&conn, &binned.id, std::slice::from_ref(&home.id)).expect("labels");
trash(&conn, &binned.id).expect("trash");
assert_eq!(list_labels(&conn).expect("labels")[0].count, Some(1));
}
fn blobs(tag: &str) -> crate::sync::blobs::BlobStore {
let dir = std::env::temp_dir().join(format!("ts-store-blobs-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
crate::sync::blobs::BlobStore::new(dir).expect("blobs")
}
fn tombstones(conn: &Connection) -> Vec<(String, String)> {
let mut stmt = conn
.prepare("SELECT entity, id FROM pending_deletes ORDER BY entity, id")
.expect("prepare");
let rows = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
.expect("query");
rows.collect::<rusqlite::Result<_>>().expect("rows")
}
#[test]
fn an_attached_file_is_kept_here_and_queued_to_upload() {
let conn = db();
let blobs = blobs("attach");
let n = note(&conn, "with a receipt");
conn.execute("UPDATE notes SET dirty = 0", [])
.expect("clean");
let got = add_attachment(
&conn,
&blobs,
&n.id,
"C:\\scans\\receipt.PDF",
"Application/PDF; name=x",
b"%PDF",
)
.expect("attach");
let att = &got.attachments[0];
assert_eq!(
att.filename.as_deref(),
Some("receipt.PDF"),
"a name, not a path"
);
assert_eq!(att.mime, "application/pdf");
assert_eq!(att.size, Some(4));
let hash = att.sha256.clone().expect("hashed");
assert!(blobs.has(&hash), "the bytes are on this device");
assert!(att.url.contains(&hash), "and served from here: {}", att.url);
assert_eq!(att.upload_error, None);
let (uploaded, dirty): (bool, bool) = conn
.query_row(
"SELECT a.uploaded, n.dirty FROM attachments a JOIN notes n ON n.id = a.note_id",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.expect("row");
assert!(!uploaded, "it waits for push");
assert!(
dirty,
"the note is touched, which is what starts a background sync"
);
}
#[test]
fn attaching_to_a_note_that_is_gone_writes_nothing() {
let conn = db();
let blobs = blobs("gone");
assert!(add_attachment(&conn, &blobs, "missing", "a.txt", "text/plain", b"hi").is_err());
let rows: i64 = conn
.query_row("SELECT COUNT(*) FROM attachments", [], |r| r.get(0))
.expect("count");
assert_eq!(rows, 0);
let files = std::fs::read_dir(blobs.root()).expect("dir").count();
assert_eq!(files, 0, "and no bytes were filed for it");
}
#[test]
fn only_a_file_the_server_holds_leaves_a_tombstone_when_removed() {
let conn = db();
let blobs = blobs("remove");
let n = note(&conn, "two files");
conn.execute(
"INSERT INTO attachments (id, note_id, url) VALUES ('synced', ?1, '/x')",
[&n.id],
)
.expect("synced row");
let with_local =
add_attachment(&conn, &blobs, &n.id, "new.txt", "text/plain", b"hi").expect("attach");
let local = with_local
.attachments
.iter()
.find(|a| a.id != "synced")
.expect("local")
.id
.clone();
delete_attachment(&conn, &n.id, "synced").expect("remove synced");
delete_attachment(&conn, &n.id, &local).expect("remove local");
// Without the tombstone the next pull would put the synced file straight back.
// The local one never reached the server, so there is nothing to tell it.
assert_eq!(
tombstones(&conn),
vec![("attachment".to_string(), "synced".to_string())]
);
}
#[test]
fn dismissing_a_preview_leaves_a_tombstone() {
let conn = db();
let n = note(&conn, "https://example.com");
conn.execute(
"INSERT INTO link_previews (id, note_id, url) VALUES ('p1', ?1, 'https://example.com')",
[&n.id],
)
.expect("preview");
delete_preview(&conn, &n.id, "p1").expect("dismiss");
delete_preview(&conn, &n.id, "not-there").expect("a miss is fine");
assert_eq!(
tombstones(&conn),
vec![("preview".to_string(), "p1".to_string())]
);
}
}