core: extract the store and sync engine into a shared crate (M12 step 1)
Android becomes a native Kotlin client over this same code (Scribe note 2730), so
the local store and sync engine stop being modules of the desktop app and become
`thoughtsync-core`, a crate with no UI framework in it at all.
This is a move, not a rewrite, and the measurement is why: every file in local/
and sync/ already carried ZERO Tauri references — 4,980 of 6,372 lines. The
coupling was 473 lines of command shim, which stays behind in the desktop crate
as src/commands/. Kept as git renames so history follows the files.
The desktop imports them under their old names (`use thoughtsync_core::{local,
sync}`) so every call site reads exactly as before. What moved is where they
live, not what they are.
Two things a workspace changes that are easy to miss, both caught before pushing:
[profile.release] now lives at the workspace ROOT. Cargo silently ignores
profiles declared by a non-root member — leaving it in the desktop crate would
have dropped lto/strip/opt-level from every release build with only a warning.
And a workspace shares ONE target dir, so the bundles moved from
desktop/src-tauri/target to target/. Thirteen references across publish-release,
debundle-graphics, verify.sh, package-prebuilt and the workflow now point there.
Pinning target-dir back would have been the smaller diff, but the Android lane
also produces Rust artifacts and they do not belong under desktop/.
Also retires the Tauri Android lane in the same push rather than leaving a path
that is being replaced: gen/android, android.yml and docs/android-dev.md are
gone, the mobile_entry_point attribute with them, and the lib drops to rlib —
staticlib/cdylib existed for Tauri mobile, and the .so Android loads will be
built from the core crate instead. Rule 22, no parallel path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,891 @@
|
||||
//! 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::{Duration, SecondsFormat, Utc};
|
||||
use rusqlite::{params, params_from_iter, Connection, OptionalExtension};
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::local::derive;
|
||||
use crate::local::models::*;
|
||||
|
||||
fn now() -> String {
|
||||
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
|
||||
}
|
||||
|
||||
fn new_id() -> String {
|
||||
Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// title if non-empty, else the first non-blank body line — always a string.
|
||||
fn display_title(title: Option<&str>, body: &str) -> String {
|
||||
if let Some(t) = title {
|
||||
let t = t.trim();
|
||||
if !t.is_empty() {
|
||||
return t.to_string();
|
||||
}
|
||||
}
|
||||
body.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn normalize_title(raw: &str) -> Option<String> {
|
||||
let t = raw.trim();
|
||||
if t.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(t.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()
|
||||
}
|
||||
|
||||
fn load_items(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<ChecklistItem>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, text, checked, position FROM checklist_items WHERE note_id = ?1 ORDER BY position ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map([note_id], |r| {
|
||||
Ok(ChecklistItem {
|
||||
id: r.get(0)?,
|
||||
text: r.get(1)?,
|
||||
checked: r.get(2)?,
|
||||
position: r.get(3)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
fn load_attachments(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<Attachment>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, url, filename, mime, size, sha256 FROM attachments WHERE note_id = ?1 ORDER BY position ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map([note_id], |r| {
|
||||
let server_url: String = r.get(1)?;
|
||||
let mime: String = r.get(3)?;
|
||||
let sha256: Option<String> = r.get(5)?;
|
||||
Ok(Attachment {
|
||||
id: r.get(0)?,
|
||||
// Point at the LOCAL bytes, not the server's route. The stored url is the
|
||||
// server's relative path, which resolves against the app origin in the
|
||||
// webview and 404s — and even absolute it would need a bearer token the
|
||||
// webview never sends. Rewriting here rather than at each render site
|
||||
// means NoteCard and NoteEditor stay untouched and can't drift.
|
||||
//
|
||||
// Without a hash there's nothing to address the blob by (an older server
|
||||
// that predates the sha256 column), so the original url is left alone:
|
||||
// still broken, but no more broken than it already was.
|
||||
url: match sha256.as_deref() {
|
||||
Some(hash) if !hash.is_empty() => crate::sync::blobs::url_for(hash, &mime),
|
||||
_ => server_url,
|
||||
},
|
||||
filename: r.get(2)?,
|
||||
mime,
|
||||
size: r.get(4)?,
|
||||
sha256,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
fn load_previews(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<LinkPreview>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, url, title, description, image_url, site_name FROM link_previews WHERE note_id = ?1 ORDER BY position ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map([note_id], |r| {
|
||||
Ok(LinkPreview {
|
||||
id: r.get(0)?,
|
||||
url: r.get(1)?,
|
||||
title: r.get(2)?,
|
||||
description: r.get(3)?,
|
||||
image_url: r.get(4)?,
|
||||
site_name: r.get(5)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
let mut note = conn.query_row(
|
||||
"SELECT id, title, body, color, kind, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
|
||||
FROM notes WHERE id = ?1",
|
||||
[id],
|
||||
|r| {
|
||||
let title: Option<String> = r.get(1)?;
|
||||
let body: String = r.get(2)?;
|
||||
let dt = display_title(title.as_deref(), &body);
|
||||
Ok(Note {
|
||||
id: r.get(0)?,
|
||||
title,
|
||||
display_title: dt,
|
||||
body,
|
||||
color: r.get(3)?,
|
||||
kind: r.get(4)?,
|
||||
position: r.get(5)?,
|
||||
pinned: r.get(6)?,
|
||||
archived: r.get(7)?,
|
||||
trashed: r.get(8)?,
|
||||
deleted_at: r.get(13)?,
|
||||
remind_at: r.get(9)?,
|
||||
recurrence: r.get(10)?,
|
||||
labels: Vec::new(),
|
||||
items: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
previews: Vec::new(),
|
||||
created_at: r.get(11)?,
|
||||
updated_at: r.get(12)?,
|
||||
})
|
||||
},
|
||||
)?;
|
||||
note.labels = load_labels(conn, id)?;
|
||||
note.items = load_items(conn, id)?;
|
||||
note.attachments = load_attachments(conn, id)?;
|
||||
note.previews = load_previews(conn, id)?;
|
||||
Ok(note)
|
||||
}
|
||||
|
||||
fn touch(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE notes SET updated_at = ?1, dirty = 1 WHERE id = ?2",
|
||||
params![now(), id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- #tag -> label derivation ----------------------------------------------
|
||||
|
||||
fn find_or_create_label(conn: &Connection, name: &str) -> rusqlite::Result<String> {
|
||||
let existing: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT id FROM labels WHERE lower(name) = lower(?1)",
|
||||
[name],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some(id) = existing {
|
||||
return Ok(id);
|
||||
}
|
||||
let id = new_id();
|
||||
let ts = now();
|
||||
conn.execute(
|
||||
"INSERT INTO labels (id, name, color, created_at, updated_at, dirty) VALUES (?1, ?2, 'default', ?3, ?3, 1)",
|
||||
params![id, name, ts],
|
||||
)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Re-sync the note's `via_tag` labels to exactly the `#tags` in its body.
|
||||
fn sync_tags(conn: &Connection, note_id: &str, body: &str) -> rusqlite::Result<()> {
|
||||
let tags = derive::extract_tags(body);
|
||||
let mut desired: Vec<String> = Vec::with_capacity(tags.len());
|
||||
for t in &tags {
|
||||
desired.push(find_or_create_label(conn, t)?);
|
||||
}
|
||||
|
||||
let current: Vec<String> = {
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT label_id FROM note_labels WHERE note_id = ?1 AND via_tag = 1")?;
|
||||
let rows = stmt.query_map([note_id], |r| r.get::<_, String>(0))?;
|
||||
rows.collect::<rusqlite::Result<Vec<String>>>()?
|
||||
};
|
||||
for lid in ¤t {
|
||||
if !desired.contains(lid) {
|
||||
conn.execute(
|
||||
"DELETE FROM note_labels WHERE note_id = ?1 AND label_id = ?2 AND via_tag = 1",
|
||||
params![note_id, lid],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
for lid in &desired {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 1)",
|
||||
params![note_id, lid],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- notes: read ------------------------------------------------------------
|
||||
|
||||
pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note>> {
|
||||
let mut sql = String::from("SELECT id FROM notes WHERE ");
|
||||
sql.push_str(match q.view.as_str() {
|
||||
"trash" => "trashed = 1",
|
||||
"archived" => "trashed = 0 AND archived = 1",
|
||||
_ => "trashed = 0 AND archived = 0",
|
||||
});
|
||||
|
||||
let mut binds: Vec<String> = Vec::new();
|
||||
|
||||
// Label filters (sidebar label + facet labels) are ANDed: a note must carry all.
|
||||
let mut label_ids: Vec<String> = Vec::new();
|
||||
if let Some(l) = q.label_id.as_deref().filter(|s| !s.is_empty()) {
|
||||
label_ids.push(l.to_string());
|
||||
}
|
||||
if let Some(f) = &q.facets {
|
||||
if let Some(ls) = &f.label {
|
||||
for l in ls.iter().filter(|s| !s.is_empty()) {
|
||||
label_ids.push(l.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
for lid in &label_ids {
|
||||
sql.push_str(" AND EXISTS (SELECT 1 FROM note_labels nl WHERE nl.note_id = notes.id AND nl.label_id = ?)");
|
||||
binds.push(lid.clone());
|
||||
}
|
||||
|
||||
if let Some(f) = &q.facets {
|
||||
if let Some(text) = f.q.as_deref().filter(|s| !s.is_empty()) {
|
||||
sql.push_str(" AND (title LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')");
|
||||
let pat = format!("%{}%", escape_like(text));
|
||||
binds.push(pat.clone());
|
||||
binds.push(pat);
|
||||
}
|
||||
if let Some(c) = f.color.as_deref().filter(|s| !s.is_empty()) {
|
||||
sql.push_str(" AND color = ?");
|
||||
binds.push(c.to_string());
|
||||
}
|
||||
if let Some(k) = f.kind.as_deref().filter(|s| !s.is_empty()) {
|
||||
sql.push_str(" AND kind = ?");
|
||||
binds.push(k.to_string());
|
||||
}
|
||||
if f.has_reminder == Some(true) {
|
||||
sql.push_str(" AND remind_at IS NOT NULL");
|
||||
}
|
||||
if f.has_attachment == Some(true) {
|
||||
sql.push_str(" AND EXISTS (SELECT 1 FROM attachments a WHERE a.note_id = notes.id)");
|
||||
}
|
||||
if let Some(a) = f.created_after.as_deref().filter(|s| !s.is_empty()) {
|
||||
sql.push_str(" AND created_at >= ?");
|
||||
binds.push(a.to_string());
|
||||
}
|
||||
if let Some(b) = f.created_before.as_deref().filter(|s| !s.is_empty()) {
|
||||
sql.push_str(" AND created_at < ?");
|
||||
binds.push(b.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
sql.push_str(if q.sort.as_deref() == Some("created") {
|
||||
" ORDER BY created_at DESC"
|
||||
} else {
|
||||
" ORDER BY pinned DESC, position DESC, updated_at DESC"
|
||||
});
|
||||
|
||||
let ids: Vec<String> = {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params_from_iter(binds.iter()), |r| r.get::<_, String>(0))?;
|
||||
rows.collect::<rusqlite::Result<Vec<String>>>()?
|
||||
};
|
||||
ids.iter().map(|id| load_note(conn, id)).collect()
|
||||
}
|
||||
|
||||
pub fn get_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn reminders(conn: &Connection) -> rusqlite::Result<Vec<Note>> {
|
||||
let ids: Vec<String> = {
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id FROM notes WHERE trashed = 0 AND remind_at IS NOT NULL ORDER BY remind_at ASC")?;
|
||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||
rows.collect::<rusqlite::Result<Vec<String>>>()?
|
||||
};
|
||||
ids.iter().map(|id| load_note(conn, id)).collect()
|
||||
}
|
||||
|
||||
pub fn titles(conn: &Connection) -> rusqlite::Result<Vec<TitleEntry>> {
|
||||
let mut stmt = conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0")?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
let title: Option<String> = r.get(1)?;
|
||||
let body: String = r.get(2)?;
|
||||
Ok(TitleEntry {
|
||||
id: r.get(0)?,
|
||||
title: display_title(title.as_deref(), &body),
|
||||
})
|
||||
})?;
|
||||
rows.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 (title LIKE ?1 ESCAPE '\\' OR 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()
|
||||
}
|
||||
|
||||
pub fn backlinks(conn: &Connection, id: &str) -> rusqlite::Result<Vec<Backlink>> {
|
||||
let target: String = {
|
||||
let (t, b): (Option<String>, String) =
|
||||
conn.query_row("SELECT title, body FROM notes WHERE id = ?1", [id], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})?;
|
||||
display_title(t.as_deref(), &b)
|
||||
};
|
||||
if target.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0 AND id != ?1")?;
|
||||
let rows = stmt.query_map([id], |r| {
|
||||
let nid: String = r.get(0)?;
|
||||
let t: Option<String> = r.get(1)?;
|
||||
let b: String = r.get(2)?;
|
||||
Ok((nid, t, b))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (nid, t, b) = row?;
|
||||
if derive::extract_links(&b)
|
||||
.iter()
|
||||
.any(|l| l.eq_ignore_ascii_case(&target))
|
||||
{
|
||||
out.push(Backlink {
|
||||
id: nid,
|
||||
title: display_title(t.as_deref(), &b),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn link_search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<TitleEntry>> {
|
||||
let ql = q.trim().to_lowercase();
|
||||
let mut stmt = conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0")?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
let id: String = r.get(0)?;
|
||||
let t: Option<String> = r.get(1)?;
|
||||
let b: String = r.get(2)?;
|
||||
Ok((id, t, b))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (id, t, b) = row?;
|
||||
let dt = display_title(t.as_deref(), &b);
|
||||
if ql.is_empty() || dt.to_lowercase().contains(&ql) {
|
||||
out.push(TitleEntry { id, title: dt });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- notes: write -----------------------------------------------------------
|
||||
|
||||
pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Result<Note> {
|
||||
let id = new_id();
|
||||
let ts = now();
|
||||
let title = normalize_title(&input.title);
|
||||
let kind = input.kind.clone().unwrap_or_else(|| "text".to_string());
|
||||
let position: i64 = conn.query_row(
|
||||
"SELECT COALESCE(MAX(position), 0) + 1 FROM notes",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, title, body, color, kind, position, created_at, updated_at, dirty)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, 1)",
|
||||
params![id, title, input.body, input.color, kind, position, ts],
|
||||
)?;
|
||||
if let Some(items) = &input.items {
|
||||
for (i, text) in items.iter().enumerate() {
|
||||
conn.execute(
|
||||
"INSERT INTO checklist_items (id, note_id, text, position) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![new_id(), id, text, i as i64],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
sync_tags(conn, &id, &input.body)?;
|
||||
load_note(conn, &id)
|
||||
}
|
||||
|
||||
pub fn create_titled(conn: &Connection, title: &str) -> rusqlite::Result<Note> {
|
||||
let input = NoteCreateInput {
|
||||
title: title.to_string(),
|
||||
body: String::new(),
|
||||
color: "default".to_string(),
|
||||
kind: None,
|
||||
items: None,
|
||||
};
|
||||
create_note(conn, &input)
|
||||
}
|
||||
|
||||
fn snapshot_revision(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
let (title, body): (Option<String>, String) =
|
||||
conn.query_row("SELECT title, body FROM notes WHERE id = ?1", [id], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})?;
|
||||
conn.execute(
|
||||
"INSERT INTO note_revisions (id, note_id, title, body, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![new_id(), id, title, body, now()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PATCH semantics: apply exactly the fields present in `changes`.
|
||||
pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Result<Note> {
|
||||
let obj = changes
|
||||
.as_object()
|
||||
.ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?;
|
||||
|
||||
// Snapshot the pre-edit title/body once if either is being changed (version history).
|
||||
if obj.contains_key("title") || obj.contains_key("body") {
|
||||
snapshot_revision(conn, id)?;
|
||||
}
|
||||
|
||||
for (k, v) in obj {
|
||||
match k.as_str() {
|
||||
"title" => {
|
||||
let norm = v.as_str().and_then(normalize_title);
|
||||
conn.execute(
|
||||
"UPDATE notes SET title = ?1 WHERE id = ?2",
|
||||
params![norm, id],
|
||||
)?;
|
||||
}
|
||||
"body" => {
|
||||
let body = v.as_str().unwrap_or("");
|
||||
conn.execute(
|
||||
"UPDATE notes SET body = ?1 WHERE id = ?2",
|
||||
params![body, id],
|
||||
)?;
|
||||
sync_tags(conn, id, body)?;
|
||||
}
|
||||
"color" => {
|
||||
if let Some(s) = v.as_str() {
|
||||
conn.execute("UPDATE notes SET color = ?1 WHERE id = ?2", params![s, id])?;
|
||||
}
|
||||
}
|
||||
"kind" => {
|
||||
if let Some(s) = v.as_str() {
|
||||
conn.execute("UPDATE notes SET kind = ?1 WHERE id = ?2", params![s, id])?;
|
||||
}
|
||||
}
|
||||
"pinned" => {
|
||||
if let Some(b) = v.as_bool() {
|
||||
conn.execute("UPDATE notes SET pinned = ?1 WHERE id = ?2", params![b, id])?;
|
||||
}
|
||||
}
|
||||
"archived" => {
|
||||
if let Some(b) = v.as_bool() {
|
||||
conn.execute(
|
||||
"UPDATE notes SET archived = ?1 WHERE id = ?2",
|
||||
params![b, id],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
"remind_at" => {
|
||||
let val = v.as_str().map(|s| s.to_string());
|
||||
conn.execute(
|
||||
"UPDATE notes SET remind_at = ?1 WHERE id = ?2",
|
||||
params![val, id],
|
||||
)?;
|
||||
}
|
||||
"recurrence" => {
|
||||
let val = v.as_str().map(|s| s.to_string());
|
||||
conn.execute(
|
||||
"UPDATE notes SET recurrence = ?1 WHERE id = ?2",
|
||||
params![val, id],
|
||||
)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn complete_reminder(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
// Clear the reminder. (Recurrence advancement is a later refinement.)
|
||||
conn.execute("UPDATE notes SET remind_at = NULL WHERE id = ?1", [id])?;
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn snooze_reminder(conn: &Connection, id: &str, minutes: i64) -> rusqlite::Result<Note> {
|
||||
let t = (Utc::now() + Duration::minutes(minutes)).to_rfc3339_opts(SecondsFormat::Millis, true);
|
||||
conn.execute(
|
||||
"UPDATE notes SET remind_at = ?1 WHERE id = ?2",
|
||||
params![t, id],
|
||||
)?;
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn set_labels(conn: &Connection, id: &str, label_ids: &[String]) -> rusqlite::Result<Note> {
|
||||
// Manual labels are replaced wholesale; #tag (via_tag) labels are managed by text.
|
||||
conn.execute(
|
||||
"DELETE FROM note_labels WHERE note_id = ?1 AND via_tag = 0",
|
||||
[id],
|
||||
)?;
|
||||
for lid in label_ids {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 0)",
|
||||
params![id, lid],
|
||||
)?;
|
||||
}
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn add_item(conn: &Connection, id: &str, text: &str) -> rusqlite::Result<Note> {
|
||||
let pos: i64 = conn.query_row(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM checklist_items WHERE note_id = ?1",
|
||||
[id],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO checklist_items (id, note_id, text, position) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![new_id(), id, text, pos],
|
||||
)?;
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn update_item(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
item_id: &str,
|
||||
changes: &Value,
|
||||
) -> rusqlite::Result<Note> {
|
||||
if let Some(text) = changes.get("text").and_then(Value::as_str) {
|
||||
conn.execute(
|
||||
"UPDATE checklist_items SET text = ?1 WHERE id = ?2 AND note_id = ?3",
|
||||
params![text, item_id, id],
|
||||
)?;
|
||||
}
|
||||
if let Some(checked) = changes.get("checked").and_then(Value::as_bool) {
|
||||
conn.execute(
|
||||
"UPDATE checklist_items SET checked = ?1 WHERE id = ?2 AND note_id = ?3",
|
||||
params![checked, item_id, id],
|
||||
)?;
|
||||
}
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn delete_item(conn: &Connection, id: &str, item_id: &str) -> rusqlite::Result<Note> {
|
||||
conn.execute(
|
||||
"DELETE FROM checklist_items WHERE id = ?1 AND note_id = ?2",
|
||||
params![item_id, id],
|
||||
)?;
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn delete_attachment(conn: &Connection, id: &str, att_id: &str) -> rusqlite::Result<Note> {
|
||||
conn.execute(
|
||||
"DELETE FROM attachments WHERE id = ?1 AND note_id = ?2",
|
||||
params![att_id, id],
|
||||
)?;
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn delete_preview(conn: &Connection, id: &str, preview_id: &str) -> rusqlite::Result<Note> {
|
||||
conn.execute(
|
||||
"DELETE FROM link_previews WHERE id = ?1 AND note_id = ?2",
|
||||
params![preview_id, id],
|
||||
)?;
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn reorder(conn: &Connection, ordered_ids: &[String]) -> rusqlite::Result<()> {
|
||||
let total = ordered_ids.len() as i64;
|
||||
for (i, id) in ordered_ids.iter().enumerate() {
|
||||
conn.execute(
|
||||
"UPDATE notes SET position = ?1, dirty = 1 WHERE id = ?2",
|
||||
params![total - i as i64, id],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn trash(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
// COALESCE, so trashing an already-trashed note doesn't restart its retention
|
||||
// clock. The server keeps its `deleted_at` the same way — a note shouldn't earn
|
||||
// another 30 days because something touched it twice.
|
||||
conn.execute(
|
||||
"UPDATE notes SET trashed = 1, trashed_at = COALESCE(trashed_at, ?1) WHERE id = ?2",
|
||||
params![now(), id],
|
||||
)?;
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn restore(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
conn.execute(
|
||||
"UPDATE notes SET trashed = 0, trashed_at = NULL WHERE id = ?1",
|
||||
[id],
|
||||
)?;
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn delete_forever(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
record_pending_delete(conn, "note", id)?;
|
||||
conn.execute("DELETE FROM notes WHERE id = ?1", [id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remember that a row was permanently deleted, so the sync engine can tell the
|
||||
/// server. Without this the deleted row leaves no trace at all, and the next pull
|
||||
/// would resurrect it — a delete that quietly undoes itself.
|
||||
///
|
||||
/// Harmless when the app is unlinked: the row is simply never read, and a later push
|
||||
/// gets a `noop` for an id the server never had.
|
||||
pub fn record_pending_delete(conn: &Connection, entity: &str, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO pending_deletes (entity, id, deleted_at) VALUES (?1, ?2, ?3)",
|
||||
params![entity, id, now()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- device-local preferences (schema v5) -----------------------------------
|
||||
|
||||
/// A stored preference, or `None` if it was never set. Callers supply their own
|
||||
/// default rather than one being invented here — the meaning of "unset" belongs
|
||||
/// with the setting, not with the storage.
|
||||
pub fn pref(conn: &Connection, key: &str) -> rusqlite::Result<Option<String>> {
|
||||
conn.query_row("SELECT value FROM prefs WHERE key = ?1", [key], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.optional()
|
||||
}
|
||||
|
||||
pub fn set_pref(conn: &Connection, key: &str, value: &str) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO prefs (key, value) VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![key, value],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn revisions(conn: &Connection, id: &str) -> rusqlite::Result<Vec<NoteRevision>> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, title, 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)?,
|
||||
title: r.get(1)?,
|
||||
body: r.get(2)?,
|
||||
created_at: r.get(3)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite::Result<Note> {
|
||||
let (title, body): (Option<String>, String) = conn.query_row(
|
||||
"SELECT title, body FROM note_revisions WHERE id = ?1 AND note_id = ?2",
|
||||
params![rev_id, id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)?;
|
||||
snapshot_revision(conn, id)?;
|
||||
conn.execute(
|
||||
"UPDATE notes SET title = ?1, body = ?2 WHERE id = ?3",
|
||||
params![title, body, id],
|
||||
)?;
|
||||
sync_tags(conn, id, &body)?;
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
// ---- labels -----------------------------------------------------------------
|
||||
|
||||
fn load_label(conn: &Connection, id: &str) -> rusqlite::Result<Label> {
|
||||
conn.query_row(
|
||||
"SELECT l.id, l.name, l.color,
|
||||
(SELECT COUNT(*) FROM note_labels nl JOIN notes n ON n.id = nl.note_id
|
||||
WHERE nl.label_id = l.id AND n.trashed = 0)
|
||||
FROM labels l WHERE l.id = ?1",
|
||||
[id],
|
||||
|r| {
|
||||
Ok(Label {
|
||||
id: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
color: r.get(2)?,
|
||||
count: Some(r.get(3)?),
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_labels(conn: &Connection) -> rusqlite::Result<Vec<Label>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT l.id, l.name, l.color,
|
||||
(SELECT COUNT(*) FROM note_labels nl JOIN notes n ON n.id = nl.note_id
|
||||
WHERE nl.label_id = l.id AND n.trashed = 0)
|
||||
FROM labels l ORDER BY l.name COLLATE NOCASE",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok(Label {
|
||||
id: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
color: r.get(2)?,
|
||||
count: Some(r.get(3)?),
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn create_label(conn: &Connection, name: &str) -> rusqlite::Result<Label> {
|
||||
let id = find_or_create_label(conn, name)?;
|
||||
load_label(conn, &id)
|
||||
}
|
||||
|
||||
pub fn rename_label(conn: &Connection, id: &str, name: &str) -> rusqlite::Result<Label> {
|
||||
conn.execute(
|
||||
"UPDATE labels SET name = ?1, updated_at = ?2, dirty = 1 WHERE id = ?3",
|
||||
params![name, now(), id],
|
||||
)?;
|
||||
load_label(conn, id)
|
||||
}
|
||||
|
||||
pub fn set_label_color(conn: &Connection, id: &str, color: &str) -> rusqlite::Result<Label> {
|
||||
conn.execute(
|
||||
"UPDATE labels SET color = ?1, updated_at = ?2, dirty = 1 WHERE id = ?3",
|
||||
params![color, now(), id],
|
||||
)?;
|
||||
load_label(conn, id)
|
||||
}
|
||||
|
||||
pub fn remove_label(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
record_pending_delete(conn, "label", id)?;
|
||||
conn.execute("DELETE FROM labels WHERE id = ?1", [id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn merge_labels(
|
||||
conn: &Connection,
|
||||
source_id: &str,
|
||||
target_id: &str,
|
||||
) -> rusqlite::Result<Label> {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag)
|
||||
SELECT note_id, ?2, 0 FROM note_labels WHERE label_id = ?1",
|
||||
params![source_id, target_id],
|
||||
)?;
|
||||
// The notes that carried the source now have a different label set, and that set
|
||||
// only reaches the server via the note itself (push sends label_ids per note).
|
||||
// Without this the merge would look done locally and never sync. Marked BEFORE
|
||||
// the delete, which cascades the membership rows away.
|
||||
conn.execute(
|
||||
"UPDATE notes SET dirty = 1
|
||||
WHERE id IN (SELECT note_id FROM note_labels WHERE label_id = ?1)",
|
||||
[source_id],
|
||||
)?;
|
||||
record_pending_delete(conn, "label", source_id)?;
|
||||
conn.execute("DELETE FROM labels WHERE id = ?1", [source_id])?;
|
||||
load_label(conn, target_id)
|
||||
}
|
||||
|
||||
// ---- saved filters ----------------------------------------------------------
|
||||
|
||||
pub fn list_saved_filters(conn: &Connection) -> rusqlite::Result<Vec<SavedFilter>> {
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id, name, params, position FROM saved_filters ORDER BY position ASC, name COLLATE NOCASE")?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
let params_str: String = r.get(2)?;
|
||||
let params = serde_json::from_str(¶ms_str).unwrap_or_else(|_| serde_json::json!({}));
|
||||
Ok(SavedFilter {
|
||||
id: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
params,
|
||||
position: r.get(3)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn create_saved_filter(
|
||||
conn: &Connection,
|
||||
name: &str,
|
||||
params: &Value,
|
||||
) -> rusqlite::Result<SavedFilter> {
|
||||
let id = new_id();
|
||||
let position: i64 = conn.query_row(
|
||||
"SELECT COALESCE(MAX(position), 0) + 1 FROM saved_filters",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let params_str = serde_json::to_string(params).unwrap_or_else(|_| "{}".to_string());
|
||||
conn.execute(
|
||||
"INSERT INTO saved_filters (id, name, params, position, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![id, name, params_str, position, now()],
|
||||
)?;
|
||||
Ok(SavedFilter {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
params: params.clone(),
|
||||
position,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn remove_saved_filter(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM saved_filters WHERE id = ?1", [id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn rename_saved_filter(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
name: &str,
|
||||
) -> rusqlite::Result<SavedFilter> {
|
||||
conn.execute(
|
||||
"UPDATE saved_filters SET name = ?1 WHERE id = ?2",
|
||||
params![name, id],
|
||||
)?;
|
||||
conn.query_row(
|
||||
"SELECT id, name, params, position FROM saved_filters WHERE id = ?1",
|
||||
[id],
|
||||
|r| {
|
||||
let params_str: String = r.get(2)?;
|
||||
let params =
|
||||
serde_json::from_str(¶ms_str).unwrap_or_else(|_| serde_json::json!({}));
|
||||
Ok(SavedFilter {
|
||||
id: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
params,
|
||||
position: r.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user