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>
2054 lines
74 KiB
Rust
2054 lines
74 KiB
Rust
//! The local SQLite store: every operation the repository seam needs, as plain
|
||
//! functions over a `&Connection`. The Tauri commands (commands.rs) lock the shared
|
||
//! connection and call these; keeping the SQL here (off the command layer) makes it
|
||
//! unit-testable against an in-memory database.
|
||
//!
|
||
//! Timestamps are emitted exactly like JS `Date.toISOString()`
|
||
//! ("YYYY-MM-DDTHH:MM:SS.sssZ") so string ordering and date-range comparisons line
|
||
//! up with the values the frontend sends.
|
||
|
||
use chrono::{DateTime, Duration, SecondsFormat, Utc};
|
||
use rusqlite::{params, params_from_iter, Connection, OptionalExtension};
|
||
use serde_json::{json, Value};
|
||
use uuid::Uuid;
|
||
|
||
use crate::local::derive;
|
||
use crate::local::models::*;
|
||
use crate::local::recur;
|
||
|
||
fn now() -> String {
|
||
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
|
||
}
|
||
|
||
fn new_id() -> String {
|
||
Uuid::new_v4().to_string()
|
||
}
|
||
|
||
/// The note's NAME: the first line of its body that says anything.
|
||
///
|
||
/// Mirrors `derive_display_title` in the server's notes/helpers.py — one rule written
|
||
/// twice, and they have to agree or a synced note is called different things on either
|
||
/// side of the wire.
|
||
///
|
||
/// It no longer needs the items, because the items ARE lines of the body now (M304).
|
||
/// What it needs instead is to strip the task marker off: a list-only note is still
|
||
/// named by its first item, and calling that note "- [ ] milk" would be showing
|
||
/// someone the storage rather than the note. An empty item is skipped rather than
|
||
/// naming the note "", which is what a half-typed list would otherwise do.
|
||
fn display_title(body: &str) -> String {
|
||
for line in body.lines() {
|
||
let text = derive::strip_marker(line.trim()).trim();
|
||
if !text.is_empty() {
|
||
return text.to_string();
|
||
}
|
||
}
|
||
String::new()
|
||
}
|
||
|
||
fn escape_like(s: &str) -> String {
|
||
s.replace('\\', "\\\\")
|
||
.replace('%', "\\%")
|
||
.replace('_', "\\_")
|
||
}
|
||
|
||
// ---- note assembly ----------------------------------------------------------
|
||
|
||
fn load_labels(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<NoteLabel>> {
|
||
let mut stmt = conn.prepare(
|
||
"SELECT l.id, l.name, l.color, nl.via_tag
|
||
FROM note_labels nl JOIN labels l ON l.id = nl.label_id
|
||
WHERE nl.note_id = ?1 ORDER BY l.name COLLATE NOCASE",
|
||
)?;
|
||
let rows = stmt.query_map([note_id], |r| {
|
||
Ok(NoteLabel {
|
||
id: r.get(0)?,
|
||
name: r.get(1)?,
|
||
color: r.get(2)?,
|
||
via_tag: r.get(3)?,
|
||
})
|
||
})?;
|
||
rows.collect()
|
||
}
|
||
|
||
/// The note's checklist, read out of its body. No query, because there is no table.
|
||
///
|
||
/// A `- [ ] milk` line IS the item (M304). The id is the item's ORDINAL rather than a
|
||
/// uuid — which is all it ever amounted to anyway, since `push.rs` sent text and
|
||
/// checked and never an id, and both sides replaced the whole list on every sync. It
|
||
/// is also exactly what the rewriters in `derive` take, so a UI holding an id can act
|
||
/// on it directly.
|
||
fn items_of(body: &str) -> Vec<ChecklistItem> {
|
||
derive::extract_items(body)
|
||
.into_iter()
|
||
.enumerate()
|
||
.map(|(i, item)| ChecklistItem {
|
||
id: i.to_string(),
|
||
text: item.text,
|
||
checked: item.checked,
|
||
position: i as i64,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn load_attachments(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<Attachment>> {
|
||
let mut stmt = conn.prepare(
|
||
"SELECT id, url, filename, mime, size, sha256, 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(¬e.body);
|
||
note.attachments = load_attachments(conn, id)?;
|
||
note.previews = load_previews(conn, id)?;
|
||
note.display_title = display_title(¬e.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 ¤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. 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(¶ms_str).unwrap_or_else(|_| serde_json::json!({}));
|
||
Ok(SavedFilter {
|
||
id: r.get(0)?,
|
||
name: r.get(1)?,
|
||
params,
|
||
position: r.get(3)?,
|
||
})
|
||
})?;
|
||
rows.collect()
|
||
}
|
||
|
||
pub fn create_saved_filter(
|
||
conn: &Connection,
|
||
name: &str,
|
||
params: &Value,
|
||
) -> rusqlite::Result<SavedFilter> {
|
||
let id = new_id();
|
||
let position: i64 = conn.query_row(
|
||
"SELECT COALESCE(MAX(position), 0) + 1 FROM saved_filters",
|
||
[],
|
||
|r| r.get(0),
|
||
)?;
|
||
let params_str = serde_json::to_string(params).unwrap_or_else(|_| "{}".to_string());
|
||
conn.execute(
|
||
"INSERT INTO saved_filters (id, name, params, position, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||
params![id, name, params_str, position, now()],
|
||
)?;
|
||
Ok(SavedFilter {
|
||
id,
|
||
name: name.to_string(),
|
||
params: params.clone(),
|
||
position,
|
||
})
|
||
}
|
||
|
||
pub fn remove_saved_filter(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||
conn.execute("DELETE FROM saved_filters WHERE id = ?1", [id])?;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn rename_saved_filter(
|
||
conn: &Connection,
|
||
id: &str,
|
||
name: &str,
|
||
) -> rusqlite::Result<SavedFilter> {
|
||
conn.execute(
|
||
"UPDATE saved_filters SET name = ?1 WHERE id = ?2",
|
||
params![name, id],
|
||
)?;
|
||
conn.query_row(
|
||
"SELECT id, name, params, position FROM saved_filters WHERE id = ?1",
|
||
[id],
|
||
|r| {
|
||
let params_str: String = r.get(2)?;
|
||
let params =
|
||
serde_json::from_str(¶ms_str).unwrap_or_else(|_| serde_json::json!({}));
|
||
Ok(SavedFilter {
|
||
id: r.get(0)?,
|
||
name: r.get(1)?,
|
||
params,
|
||
position: r.get(3)?,
|
||
})
|
||
},
|
||
)
|
||
}
|
||
|
||
#[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())]
|
||
);
|
||
}
|
||
}
|