Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 12s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Android / Core and FFI clippy and tests (push) Successful in 1m3s
CI & Build / integration (push) Successful in 1m37s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m14s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m3s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m42s
Android / Build the server image (push) Successful in 1s
Milestone 546 "Bring the repo to the comment standard (rule 32) and compose defaults (rule 198)", step #6224 "Comment standard sweep: Rust core and desktop client". Doc comments and comments across inkwell-core, the Tauri app and desktop/packaging now say what the code does and the non-obvious why in the present tense. Gone: milestone tags (M10.x, M11.3, M13, M304, M315, M314, milestone 325), issue and Scribe-note numbers, family-idea and family-rule citations, the run numbers and commit hashes behind past bugs, and the narration of what used to be (the stable channel before it held its own bundles, CARGO_PKG_VERSION as the shown version, the MANIFEST_TAG split, the store's missing tests, the old PKGBUILD). The schema's migration notes keep what each step does and why, without "until now". The forge is called the forge rather than Forgejo. Hazards stay as facts: the dev-rolling tag that must not shadow the branch, the manifest matched on this build's version, the APK's fixed name surviving the prune, the LIKE guard in v9, -wal/-shm moving with the renamed store, panic = abort ending the app. 34 files, comments only: a comment-stripping diff of every changed file against HEAD is identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1944 lines
71 KiB
Rust
1944 lines
71 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, Utc};
|
||
use rusqlite::{params, params_from_iter, Connection, OptionalExtension, Params};
|
||
use serde_json::{json, Value};
|
||
use uuid::Uuid;
|
||
|
||
use crate::local::derive;
|
||
use crate::local::models::*;
|
||
use crate::local::recur;
|
||
use crate::local::{iso, now, OPAQUE_MIME};
|
||
|
||
fn new_id() -> String {
|
||
Uuid::new_v4().to_string()
|
||
}
|
||
|
||
fn escape_like(s: &str) -> String {
|
||
s.replace('\\', "\\\\")
|
||
.replace('%', "\\%")
|
||
.replace('_', "\\_")
|
||
}
|
||
|
||
// ---- note assembly ----------------------------------------------------------
|
||
|
||
fn load_labels(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<NoteLabel>> {
|
||
let mut stmt = conn.prepare(
|
||
"SELECT l.id, l.name, l.color, nl.via_tag
|
||
FROM note_labels nl JOIN labels l ON l.id = nl.label_id
|
||
WHERE nl.note_id = ?1 ORDER BY l.name COLLATE NOCASE",
|
||
)?;
|
||
let rows = stmt.query_map([note_id], |r| {
|
||
Ok(NoteLabel {
|
||
id: r.get(0)?,
|
||
name: r.get(1)?,
|
||
color: r.get(2)?,
|
||
via_tag: r.get(3)?,
|
||
})
|
||
})?;
|
||
rows.collect()
|
||
}
|
||
|
||
/// The note's checklist, read out of its body. No query, because there is no table.
|
||
///
|
||
/// A `- [ ] milk` line IS the item. The id is the item's ORDINAL rather than a uuid:
|
||
/// sync carries text and checked, never an id, and the ordinal is 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 a column shifts every one after it, and
|
||
// a positional read would then load the wrong timestamps 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(¬e.body);
|
||
note.attachments = load_attachments(conn, id)?;
|
||
note.previews = load_previews(conn, id)?;
|
||
note.display_title = derive::display_title(¬e.body);
|
||
Ok(note)
|
||
}
|
||
|
||
// ---- notes shared with this account ------------------------------------------
|
||
//
|
||
// 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. 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 == access::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() {
|
||
access::OWNER => Ok(()),
|
||
access::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 fold(name) = fold(?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 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 (`derive::display_title`, 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 — backed by text still in the body:
|
||
///
|
||
/// standalone lifted out, attached as an ORDINARY label. Nothing derives it, and
|
||
/// the way to remove it is the chip's ×.
|
||
/// inline left in place, attached via_tag = 1, detached when its text goes.
|
||
///
|
||
/// 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 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: rusqlite refuses a count mismatch, which
|
||
// would fail every desktop search.
|
||
sql.push_str(" AND body LIKE ? ESCAPE '\\'");
|
||
binds.push(format!("%{}%", escape_like(text)));
|
||
}
|
||
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"
|
||
});
|
||
|
||
notes_where(conn, &sql, params_from_iter(binds.iter()))
|
||
}
|
||
|
||
/// The notes whose ids `sql` selects, in its order, each loaded in full. Every list
|
||
/// the UI reads goes through `load_note`, so a list and the note it opens can never
|
||
/// disagree about what the note is.
|
||
fn notes_where<P: Params>(conn: &Connection, sql: &str, params: P) -> rusqlite::Result<Vec<Note>> {
|
||
let ids: Vec<String> = {
|
||
let mut stmt = conn.prepare(sql)?;
|
||
let rows = stmt.query_map(params, |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)
|
||
}
|
||
|
||
/// Which notes' reminders are this device's to fire: the owner's, out of the trash.
|
||
/// A note shared with us neither alerts us nor is ours to clear, the same as on the
|
||
/// server.
|
||
const OWN_REMINDERS: &str = "trashed = 0 AND remind_at IS NOT NULL AND permission = 'owner'";
|
||
|
||
pub fn reminders(conn: &Connection) -> rusqlite::Result<Vec<Note>> {
|
||
let sql = format!("SELECT id FROM notes WHERE {OWN_REMINDERS} ORDER BY remind_at ASC");
|
||
notes_where(conn, &sql, [])
|
||
}
|
||
|
||
/// 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(&format!(
|
||
"SELECT id, remind_at FROM notes WHERE {OWN_REMINDERS} 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>> {
|
||
// The command palette reads this; one query per note is fine at personal scale.
|
||
let notes = notes_where(conn, "SELECT id FROM notes WHERE trashed = 0", [])?;
|
||
Ok(notes
|
||
.into_iter()
|
||
.map(|note| TitleEntry {
|
||
id: note.id,
|
||
title: note.display_title,
|
||
})
|
||
.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> {
|
||
if note_body(conn, id)? == 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 = iso(Utc::now() - Duration::minutes(REVISION_WINDOW_MINUTES));
|
||
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 = note_body(conn, id)?;
|
||
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 == access::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![iso(at), id],
|
||
)?,
|
||
None => conn.execute(
|
||
"UPDATE notes SET remind_at = NULL, recurrence = NULL WHERE id = ?1",
|
||
[id],
|
||
)?,
|
||
};
|
||
touch(conn, id)?;
|
||
load_note(conn, id)
|
||
}
|
||
|
||
/// The longest snooze, in minutes: 30 days. The server's snooze route clamps to the
|
||
/// same range (`notes.SNOOZE_MAX_MINUTES`), so a device and the web agree.
|
||
pub const SNOOZE_MAX_MINUTES: i64 = 60 * 24 * 30;
|
||
|
||
pub fn snooze_reminder(conn: &Connection, id: &str, minutes: i64) -> rusqlite::Result<Note> {
|
||
require_owner(conn, id)?;
|
||
// 0 or less would set a reminder in the past; a huge value overflows `Duration`.
|
||
let minutes = minutes.clamp(1, SNOOZE_MAX_MINUTES);
|
||
let t = iso(Utc::now() + Duration::minutes(minutes));
|
||
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: a tick is a body edit ----------------------------------
|
||
//
|
||
// It keeps the item's name and signature because the FFI, the Tauri commands and the
|
||
// REST shape all speak in items, and a checklist is still a thing a note HAS. Routing
|
||
// it through `update_note` rather than writing the body directly is what gives it
|
||
// 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 }))
|
||
}
|
||
|
||
/// Tick or untick one item. The only item change that isn't an edit to the body's
|
||
/// text: adding, rewording and removing an item happen in the editor.
|
||
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(checked) = changes.get("checked").and_then(Value::as_bool) {
|
||
body = derive::set_item_checked(&body, index, checked);
|
||
}
|
||
set_body(conn, id, body)
|
||
}
|
||
|
||
/// 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() {
|
||
OPAQUE_MIME.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, entity::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, entity::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,
|
||
// 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 != access::OWNER)
|
||
{
|
||
return Err(refuse(OWNER_ONLY));
|
||
}
|
||
record_pending_delete(conn, entity::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)? == access::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 -----------------------------------------------------------------
|
||
|
||
/// A label with how many live notes carry it; `label_row` reads it.
|
||
const LABEL_SELECT: &str = "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),
|
||
(SELECT MAX(n.updated_at) 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";
|
||
|
||
fn label_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<Label> {
|
||
Ok(Label {
|
||
id: r.get(0)?,
|
||
name: r.get(1)?,
|
||
color: r.get(2)?,
|
||
count: Some(r.get(3)?),
|
||
last_used: r.get(4)?,
|
||
})
|
||
}
|
||
|
||
fn load_label(conn: &Connection, id: &str) -> rusqlite::Result<Label> {
|
||
conn.query_row(&format!("{LABEL_SELECT} WHERE l.id = ?1"), [id], label_row)
|
||
}
|
||
|
||
pub fn list_labels(conn: &Connection) -> rusqlite::Result<Vec<Label>> {
|
||
let mut stmt = conn.prepare(&format!("{LABEL_SELECT} ORDER BY l.name COLLATE NOCASE"))?;
|
||
let rows = stmt.query_map([], label_row)?;
|
||
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 a
|
||
/// bare statement fails with a raw SQLite "UNIQUE constraint failed". Merging 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 fold(name) = fold(?1) AND id <> ?2",
|
||
params![name, id],
|
||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||
)
|
||
.optional()?;
|
||
|
||
let renamed = 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)?;
|
||
survivor
|
||
} else {
|
||
id.to_string()
|
||
};
|
||
|
||
// A merge's 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(), renamed],
|
||
)?;
|
||
load_label(conn, &renamed)
|
||
}
|
||
|
||
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, entity::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, entity::LABEL, source_id)?;
|
||
conn.execute("DELETE FROM labels WHERE id = ?1", [source_id])?;
|
||
load_label(conn, target_id)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
//! Every test here runs a real query against a real schema: reading columns by
|
||
//! the wrong position, or binding a pattern twice for one placeholder, is the
|
||
//! kind of thing only that catches.
|
||
|
||
use super::*;
|
||
|
||
use crate::local::test_db as db;
|
||
|
||
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 ----------------------------------------
|
||
|
||
/// 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_snooze_lasts_between_a_minute_and_thirty_days() {
|
||
let conn = db();
|
||
let id = note(&conn, "call back").id;
|
||
// Minutes from just before the call, rounded: the stored time is cut to the
|
||
// millisecond, so it can land a hair under the whole minute.
|
||
let lands = |minutes: i64| -> i64 {
|
||
let before = Utc::now();
|
||
let snoozed = snooze_reminder(&conn, &id, minutes).expect("snooze");
|
||
let stamp = snoozed.remind_at.expect("set");
|
||
let at = DateTime::parse_from_rfc3339(&stamp).expect("rfc3339");
|
||
((at.with_timezone(&Utc) - before).num_seconds() + 30) / 60
|
||
};
|
||
assert_eq!(lands(0), 1, "never in the past");
|
||
assert_eq!(lands(-5), 1);
|
||
assert_eq!(lands(i64::MAX), SNOOZE_MAX_MINUTES, "no overflow");
|
||
assert_eq!(lands(60), 60);
|
||
}
|
||
|
||
#[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!(update_item(&conn, "n", "0", &json!({ "checked": true })).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"));
|
||
update_item(&conn, "n", "0", &json!({ "checked": true })).expect("a tick 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, std::slice::from_ref(&mine.id)).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 attachment_facet() {
|
||
let conn = db();
|
||
let attached = note(&conn, "attached");
|
||
note(&conn, "plain");
|
||
conn.execute(
|
||
"INSERT INTO attachments (id, note_id, url) VALUES ('a', ?1, '/x')",
|
||
[&attached.id],
|
||
)
|
||
.expect("attach");
|
||
|
||
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());
|
||
}
|
||
|
||
/// Every letter folds, accented ones included, as on the server; SQLite's lower()
|
||
/// would mint a second "CAFÉ" tag.
|
||
#[test]
|
||
fn a_tag_spelled_with_different_accented_capitals_is_the_same_tag() {
|
||
let conn = db();
|
||
let first = note(&conn, "coffee at #Café");
|
||
let second = note(&conn, "coffee at #CAFÉ");
|
||
assert_eq!(second.labels.len(), 1);
|
||
assert_eq!(second.labels[0].id, first.labels[0].id);
|
||
assert_eq!(list_labels(&conn).expect("labels").len(), 1);
|
||
}
|
||
|
||
// ---- 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));
|
||
}
|
||
|
||
#[test]
|
||
fn a_label_is_as_recent_as_its_newest_untrashed_note() {
|
||
let conn = db();
|
||
let used = create_label(&conn, "errands").expect("label");
|
||
create_label(&conn, "someday").expect("label");
|
||
let old = create_label(&conn, "old").expect("label");
|
||
let kept = note(&conn, "milk");
|
||
let binned = note(&conn, "stale");
|
||
set_labels(&conn, &kept.id, std::slice::from_ref(&used.id)).expect("labels");
|
||
set_labels(&conn, &binned.id, std::slice::from_ref(&old.id)).expect("labels");
|
||
trash(&conn, &binned.id).expect("trash");
|
||
|
||
let kept = get_note(&conn, &kept.id).expect("get");
|
||
let by_name = |name: &str| {
|
||
list_labels(&conn)
|
||
.expect("labels")
|
||
.into_iter()
|
||
.find(|l| l.name == name)
|
||
.expect("listed")
|
||
};
|
||
assert_eq!(
|
||
by_name("errands").last_used.as_deref(),
|
||
kept.updated_at.as_deref()
|
||
);
|
||
assert_eq!(by_name("someday").last_used, None, "carried by nothing");
|
||
assert_eq!(
|
||
by_name("old").last_used,
|
||
None,
|
||
"a trashed note does not count"
|
||
);
|
||
}
|
||
|
||
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 = crate::sync::blobs::scratch("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 = crate::sync::blobs::scratch("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 = crate::sync::blobs::scratch("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())]
|
||
);
|
||
}
|
||
}
|