desktop M10.4: Rust local SQLite store + Tauri commands
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 24s
The on-device core that makes the desktop app work with no server and no login. - rusqlite (bundled SQLite, so no system libsqlite dependency to vary across builds); uuid v4 ids; RFC3339/Date.toISOString-compatible timestamps. - Schema mirroring the note model: notes, labels, note_labels (with via_tag), checklist_items, attachments, link_previews, note_revisions, saved_filters, plus per-row sync_revision/dirty + a sync_state row for the M10.7 engine. user_version-gated migrations. - derive.rs: pure [[wiki-link]] + #tag scanners (mirror the frontend inline rules, no regex dep) with unit tests; #tags re-sync via_tag labels on save, [[links]] drive backlinks at query time (derived, never stored). - store.rs: the full repository surface (facet/label/date/text list, create, PATCH-semantics update, pin/archive/color/kind, checklist items, labels CRUD + merge, reminders complete/snooze, reorder, trash/restore/delete, revisions + restore, titles/search/backlinks/link-search, saved filters). - commands.rs: ~38 #[tauri::command]s over a Mutex<Connection> in managed state. - lib.rs: opens the DB in the platform app-data dir on setup; synthetic offline config/user so the auth-gated router resolves with no login. Attachment upload / URL unfurl / import are intentionally deferred (network/file concerns); adapters/local.ts (M10.5) wires all of the above via invoke. Task 1993. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -18,6 +18,12 @@ tauri-build = { version = "2", features = [] }
|
|||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
# Local-first store (M10.4): bundled = compile SQLite in, so there's no system
|
||||||
|
# libsqlite dependency to vary across the AppImage / native / Windows builds.
|
||||||
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
# RFC3339 timestamps for created_at/updated_at/remind_at (Date.parse-able on the JS side).
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
|
|
||||||
# Tauri's default release profile: smaller, faster shipped binaries.
|
# Tauri's default release profile: smaller, faster shipped binaries.
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//! AppImage) and boots the UI.
|
//! AppImage) and boots the UI.
|
||||||
|
|
||||||
mod integration;
|
mod integration;
|
||||||
|
mod local;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@@ -14,10 +15,56 @@ pub fn run() {
|
|||||||
harden_linux_webkit_rendering();
|
harden_linux_webkit_rendering();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.setup(|app| {
|
||||||
|
use tauri::Manager;
|
||||||
|
// The on-device store lives in the platform app-data dir (e.g. Linux
|
||||||
|
// ~/.local/share/com.fabledsword.thoughtsync/thoughtsync.db), created on
|
||||||
|
// first launch. This is what makes the app work with no server or login.
|
||||||
|
let dir = app.path().app_data_dir()?;
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
app.manage(local::open(&dir.join("thoughtsync.db"))?);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
integration::integration_status,
|
integration::integration_status,
|
||||||
integration::integrate_desktop,
|
integration::integrate_desktop,
|
||||||
integration::unintegrate_desktop,
|
integration::unintegrate_desktop,
|
||||||
|
local::commands::config_get,
|
||||||
|
local::commands::auth_me,
|
||||||
|
local::commands::notes_list,
|
||||||
|
local::commands::notes_get,
|
||||||
|
local::commands::notes_create,
|
||||||
|
local::commands::notes_create_titled,
|
||||||
|
local::commands::notes_update,
|
||||||
|
local::commands::notes_complete_reminder,
|
||||||
|
local::commands::notes_snooze_reminder,
|
||||||
|
local::commands::notes_set_labels,
|
||||||
|
local::commands::notes_add_item,
|
||||||
|
local::commands::notes_update_item,
|
||||||
|
local::commands::notes_delete_item,
|
||||||
|
local::commands::notes_delete_attachment,
|
||||||
|
local::commands::notes_delete_preview,
|
||||||
|
local::commands::notes_reorder,
|
||||||
|
local::commands::notes_trash,
|
||||||
|
local::commands::notes_restore,
|
||||||
|
local::commands::notes_delete_forever,
|
||||||
|
local::commands::notes_revisions,
|
||||||
|
local::commands::notes_restore_revision,
|
||||||
|
local::commands::notes_reminders,
|
||||||
|
local::commands::notes_titles,
|
||||||
|
local::commands::notes_search,
|
||||||
|
local::commands::notes_backlinks,
|
||||||
|
local::commands::notes_link_search,
|
||||||
|
local::commands::labels_list,
|
||||||
|
local::commands::labels_create,
|
||||||
|
local::commands::labels_rename,
|
||||||
|
local::commands::labels_set_color,
|
||||||
|
local::commands::labels_remove,
|
||||||
|
local::commands::labels_merge,
|
||||||
|
local::commands::saved_filters_list,
|
||||||
|
local::commands::saved_filters_create,
|
||||||
|
local::commands::saved_filters_remove,
|
||||||
|
local::commands::saved_filters_rename,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running the ThoughtSync desktop app");
|
.expect("error while running the ThoughtSync desktop app");
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
//! Tauri command surface for the local store — the offline mirror of the REST API
|
||||||
|
//! the frontend's repository seam calls. Each command locks the shared connection and
|
||||||
|
//! delegates to `store`; errors are stringified for the JS boundary. adapters/local.ts
|
||||||
|
//! (M10.5) invokes these.
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::local::models::*;
|
||||||
|
use crate::local::store;
|
||||||
|
use crate::local::Db;
|
||||||
|
|
||||||
|
// A macro would hide the (very regular) locking; kept explicit so each command reads
|
||||||
|
// as an obvious lock -> delegate -> stringify.
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn config_get() -> PublicConfig {
|
||||||
|
// Offline defaults: no signups, no server-side URL unfurling (needs network).
|
||||||
|
PublicConfig {
|
||||||
|
site_name: "ThoughtSync".to_string(),
|
||||||
|
allow_registration: false,
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
enable_url_unfurl: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn auth_me() -> User {
|
||||||
|
// The single synthetic local user, so the auth-gated router resolves with no login.
|
||||||
|
User {
|
||||||
|
id: "local".to_string(),
|
||||||
|
email: "local@thoughtsync.app".to_string(),
|
||||||
|
display_name: "You".to_string(),
|
||||||
|
email_verified: true,
|
||||||
|
is_admin: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_list(query: ListQuery, db: State<'_, Db>) -> Result<Vec<Note>, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::list_notes(&conn, &query).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_get(id: String, db: State<'_, Db>) -> Result<Note, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::get_note(&conn, &id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_create(input: NoteCreateInput, db: State<'_, Db>) -> Result<Note, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::create_note(&conn, &input).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_create_titled(title: String, db: State<'_, Db>) -> Result<Note, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::create_titled(&conn, &title).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_update(id: String, changes: Value, db: State<'_, Db>) -> Result<Note, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::update_note(&conn, &id, &changes).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_complete_reminder(id: String, db: State<'_, Db>) -> Result<Note, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::complete_reminder(&conn, &id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_snooze_reminder(id: String, minutes: i64, db: State<'_, Db>) -> Result<Note, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::snooze_reminder(&conn, &id, minutes).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_set_labels(id: String, label_ids: Vec<String>, db: State<'_, Db>) -> Result<Note, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::set_labels(&conn, &id, &label_ids).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_add_item(id: String, text: String, db: State<'_, Db>) -> Result<Note, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::add_item(&conn, &id, &text).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_update_item(id: String, item_id: String, changes: Value, db: State<'_, Db>) -> Result<Note, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::update_item(&conn, &id, &item_id, &changes).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_delete_item(id: String, item_id: String, db: State<'_, Db>) -> Result<Note, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::delete_item(&conn, &id, &item_id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_delete_attachment(id: String, att_id: String, db: State<'_, Db>) -> Result<Note, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::delete_attachment(&conn, &id, &att_id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_delete_preview(id: String, preview_id: String, db: State<'_, Db>) -> Result<Note, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::delete_preview(&conn, &id, &preview_id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_reorder(ordered_ids: Vec<String>, db: State<'_, Db>) -> Result<(), String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::reorder(&conn, &ordered_ids).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_trash(id: String, db: State<'_, Db>) -> Result<Note, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::trash(&conn, &id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_restore(id: String, db: State<'_, Db>) -> Result<Note, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::restore(&conn, &id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_delete_forever(id: String, db: State<'_, Db>) -> Result<(), String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::delete_forever(&conn, &id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_revisions(id: String, db: State<'_, Db>) -> Result<Vec<NoteRevision>, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::revisions(&conn, &id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_restore_revision(id: String, rev_id: String, db: State<'_, Db>) -> Result<Note, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::restore_revision(&conn, &id, &rev_id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_reminders(db: State<'_, Db>) -> Result<Vec<Note>, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::reminders(&conn).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_titles(db: State<'_, Db>) -> Result<Vec<TitleEntry>, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::titles(&conn).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_search(q: String, db: State<'_, Db>) -> Result<Vec<Note>, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::search(&conn, &q).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_backlinks(id: String, db: State<'_, Db>) -> Result<Vec<Backlink>, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::backlinks(&conn, &id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notes_link_search(q: String, db: State<'_, Db>) -> Result<Vec<TitleEntry>, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::link_search(&conn, &q).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn labels_list(db: State<'_, Db>) -> Result<Vec<Label>, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::list_labels(&conn).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn labels_create(name: String, db: State<'_, Db>) -> Result<Label, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::create_label(&conn, &name).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn labels_rename(id: String, name: String, db: State<'_, Db>) -> Result<Label, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::rename_label(&conn, &id, &name).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn labels_set_color(id: String, color: String, db: State<'_, Db>) -> Result<Label, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::set_label_color(&conn, &id, &color).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn labels_remove(id: String, db: State<'_, Db>) -> Result<(), String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::remove_label(&conn, &id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn labels_merge(source_id: String, into: String, db: State<'_, Db>) -> Result<Label, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::merge_labels(&conn, &source_id, &into).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn saved_filters_list(db: State<'_, Db>) -> Result<Vec<SavedFilter>, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::list_saved_filters(&conn).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn saved_filters_create(name: String, params: Value, db: State<'_, Db>) -> Result<SavedFilter, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::create_saved_filter(&conn, &name, ¶ms).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn saved_filters_remove(id: String, db: State<'_, Db>) -> Result<(), String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::remove_saved_filter(&conn, &id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn saved_filters_rename(id: String, name: String, db: State<'_, Db>) -> Result<SavedFilter, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
store::rename_saved_filter(&conn, &id, &name).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
//! Deriving `[[wiki-links]]` and `#tags` from a note's body — the local mirror of
|
||||||
|
//! what the server computes on save. Pure string scanning (no regex dependency),
|
||||||
|
//! kept in lockstep with the frontend's inline rules (see frontend notes/markdown.ts):
|
||||||
|
//!
|
||||||
|
//! - `[[link]]`: `[[` … `]]` with no brackets inside, inner text trimmed. Used to
|
||||||
|
//! compute backlinks at query time (links are derived, never stored/synced).
|
||||||
|
//! - `#tag`: `#` at a word boundary followed by tag characters (letter first).
|
||||||
|
//! On save these become labels attached with `via_tag = true`.
|
||||||
|
//!
|
||||||
|
//! Both dedupe case-insensitively, preserving first-seen order.
|
||||||
|
|
||||||
|
/// Extract the trimmed inner text of every `[[wiki-link]]` in `body`.
|
||||||
|
pub fn extract_links(body: &str) -> Vec<String> {
|
||||||
|
let bytes = body.as_bytes();
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i + 1 < bytes.len() {
|
||||||
|
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
|
||||||
|
if let Some(rel) = body[i + 2..].find("]]") {
|
||||||
|
let inner = &body[i + 2..i + 2 + rel];
|
||||||
|
// Mirror the frontend's `[^[\]]+`: no stray brackets inside.
|
||||||
|
if !inner.contains('[') && !inner.contains(']') {
|
||||||
|
let t = inner.trim();
|
||||||
|
if !t.is_empty() {
|
||||||
|
push_unique(&mut out, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 2 + rel + 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract every `#tag` name (without the leading `#`) from `body`.
|
||||||
|
pub fn extract_tags(body: &str) -> Vec<String> {
|
||||||
|
let chars: Vec<char> = body.chars().collect();
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < chars.len() {
|
||||||
|
if chars[i] == '#' {
|
||||||
|
let boundary = i == 0 || (!is_tag_char(chars[i - 1]) && chars[i - 1] != '#');
|
||||||
|
// A tag must start with a letter (so "#1" or a bare "#" is not a tag).
|
||||||
|
if boundary && i + 1 < chars.len() && chars[i + 1].is_alphabetic() {
|
||||||
|
let mut j = i + 1;
|
||||||
|
while j < chars.len() && is_tag_char(chars[j]) {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
let tag: String = chars[i + 1..j].iter().collect();
|
||||||
|
push_unique(&mut out, &tag);
|
||||||
|
i = j;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_tag_char(c: char) -> bool {
|
||||||
|
c.is_alphanumeric() || c == '_' || c == '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_unique(out: &mut Vec<String>, candidate: &str) {
|
||||||
|
if !out.iter().any(|x| x.eq_ignore_ascii_case(candidate)) {
|
||||||
|
out.push(candidate.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn links_basic_and_trim() {
|
||||||
|
assert_eq!(extract_links("see [[ Alpha ]] and [[Beta]]"), vec!["Alpha", "Beta"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn links_dedupe_case_insensitive_first_seen() {
|
||||||
|
assert_eq!(extract_links("[[Note]] then [[note]] again"), vec!["Note"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn links_ignore_malformed_and_nested_brackets() {
|
||||||
|
assert_eq!(extract_links("[[a[b]] [[]] [ [x] ] plain"), Vec::<String>::new());
|
||||||
|
assert_eq!(extract_links("[[ok]] [[a]b]]"), vec!["ok"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tags_basic() {
|
||||||
|
assert_eq!(extract_tags("a #todo and #Work-item_2 here"), vec!["todo", "Work-item_2"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tags_require_letter_start_and_boundary() {
|
||||||
|
// "#1" (digit) and an in-word "#" (email-ish) are not tags.
|
||||||
|
assert_eq!(extract_tags("#1 nope a#b no but #Yes"), vec!["Yes"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tags_dedupe_case_insensitive() {
|
||||||
|
assert_eq!(extract_tags("#Home #home #HOME"), vec!["Home"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_body() {
|
||||||
|
assert!(extract_links("").is_empty());
|
||||||
|
assert!(extract_tags("").is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
//! The local-first core: an on-device SQLite store + Tauri commands implementing the
|
||||||
|
//! frontend's repository seam, so the desktop app is fully usable with no server and
|
||||||
|
//! no login. adapters/local.ts (M10.5) calls into `commands`.
|
||||||
|
|
||||||
|
pub mod commands;
|
||||||
|
pub mod derive;
|
||||||
|
pub mod models;
|
||||||
|
pub mod schema;
|
||||||
|
pub mod store;
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
/// The shared database handle, held in Tauri's managed state. rusqlite connections
|
||||||
|
/// aren't `Sync`, so a `Mutex` serializes access (fine — operations are quick and the
|
||||||
|
/// UI is single-user).
|
||||||
|
pub struct Db(pub Mutex<Connection>);
|
||||||
|
|
||||||
|
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
|
||||||
|
pub fn open(path: &Path) -> rusqlite::Result<Db> {
|
||||||
|
let conn = Connection::open(path)?;
|
||||||
|
schema::migrate(&conn)?;
|
||||||
|
Ok(Db(Mutex::new(conn)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
//! Serde shapes for the local store. Output structs mirror the JSON the frontend
|
||||||
|
//! already consumes (frontend/src/stores/*.ts) so the same components render whether
|
||||||
|
//! the data comes from REST or from these Tauri commands. Field names/optionality
|
||||||
|
//! match the TypeScript interfaces exactly.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Note {
|
||||||
|
pub id: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
/// title if set, else the note's first body line — always present, so body-only
|
||||||
|
/// notes are still nameable and `[[link]]`-able. Derived, never stored.
|
||||||
|
pub display_title: String,
|
||||||
|
pub body: String,
|
||||||
|
pub color: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub position: i64,
|
||||||
|
pub pinned: bool,
|
||||||
|
pub archived: bool,
|
||||||
|
pub trashed: bool,
|
||||||
|
pub remind_at: Option<String>,
|
||||||
|
pub recurrence: Option<String>,
|
||||||
|
pub labels: Vec<NoteLabel>,
|
||||||
|
pub items: Vec<ChecklistItem>,
|
||||||
|
pub attachments: Vec<Attachment>,
|
||||||
|
pub previews: Vec<LinkPreview>,
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
pub updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct NoteLabel {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub color: String,
|
||||||
|
/// True when attached because of a `#tag` in the body (kept in sync with the text).
|
||||||
|
pub via_tag: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ChecklistItem {
|
||||||
|
pub id: String,
|
||||||
|
pub text: String,
|
||||||
|
pub checked: bool,
|
||||||
|
pub position: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Attachment {
|
||||||
|
pub id: String,
|
||||||
|
pub url: String,
|
||||||
|
pub filename: Option<String>,
|
||||||
|
pub mime: String,
|
||||||
|
pub size: Option<i64>,
|
||||||
|
pub sha256: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct LinkPreview {
|
||||||
|
pub id: String,
|
||||||
|
pub url: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub image_url: Option<String>,
|
||||||
|
pub site_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct NoteRevision {
|
||||||
|
pub id: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub body: String,
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Label {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub color: String,
|
||||||
|
// Note count is only meaningful in listings; omitted elsewhere so the frontend
|
||||||
|
// preserves the count it already holds (matches the REST single-label responses).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub count: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct TitleEntry {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Backlink {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SavedFilter {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
/// Mirrors NoteFacets — stored as a JSON blob, round-tripped opaquely.
|
||||||
|
pub params: serde_json::Value,
|
||||||
|
pub position: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct PublicConfig {
|
||||||
|
pub site_name: String,
|
||||||
|
pub allow_registration: bool,
|
||||||
|
pub version: String,
|
||||||
|
pub enable_url_unfurl: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The synthetic single user the offline core reports, so the app's auth-gated
|
||||||
|
/// router resolves without a login. There is no real account offline.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: String,
|
||||||
|
pub email: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub email_verified: bool,
|
||||||
|
pub is_admin: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_color() -> String {
|
||||||
|
"default".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct NoteCreateInput {
|
||||||
|
#[serde(default)]
|
||||||
|
pub title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub body: String,
|
||||||
|
#[serde(default = "default_color")]
|
||||||
|
pub color: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub kind: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub items: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The board query (mirrors adapters/repo.ts NoteListQuery). `labelId` is camelCase
|
||||||
|
/// on the wire; the facet fields are snake_case, matching NoteFacets.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ListQuery {
|
||||||
|
pub view: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub label_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub facets: Option<Facets>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sort: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Facets {
|
||||||
|
#[serde(default)]
|
||||||
|
pub q: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub color: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub kind: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub label: Option<Vec<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub has_reminder: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub has_attachment: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_after: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_before: Option<String>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
//! Local SQLite schema + migrations. The schema mirrors the note/label model so an
|
||||||
|
//! offline note can later sync 1:1 with the server. Each syncable row carries local
|
||||||
|
//! `sync_revision` + `dirty` bookkeeping (consumed by the sync engine in M10.7);
|
||||||
|
//! `[[links]]` are NOT stored (derived at query time), matching docs/sync.md.
|
||||||
|
//!
|
||||||
|
//! Migrations are gated on `PRAGMA user_version`; bump it and add a block per change.
|
||||||
|
|
||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
const SCHEMA_V1: &str = r#"
|
||||||
|
CREATE TABLE notes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT,
|
||||||
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
color TEXT NOT NULL DEFAULT 'default',
|
||||||
|
kind TEXT NOT NULL DEFAULT 'text', -- 'text' | 'list'
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
pinned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
trashed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
remind_at TEXT,
|
||||||
|
recurrence TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
sync_revision INTEGER NOT NULL DEFAULT 0,
|
||||||
|
dirty INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE labels (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT 'default',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
sync_revision INTEGER NOT NULL DEFAULT 0,
|
||||||
|
dirty INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX idx_labels_name ON labels (lower(name));
|
||||||
|
|
||||||
|
CREATE TABLE note_labels (
|
||||||
|
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||||
|
label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
|
||||||
|
via_tag INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (note_id, label_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_note_labels_label ON note_labels (label_id);
|
||||||
|
|
||||||
|
CREATE TABLE checklist_items (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||||
|
text TEXT NOT NULL DEFAULT '',
|
||||||
|
checked INTEGER NOT NULL DEFAULT 0,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_items_note ON checklist_items (note_id);
|
||||||
|
|
||||||
|
CREATE TABLE attachments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
filename TEXT,
|
||||||
|
mime TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||||
|
size INTEGER,
|
||||||
|
sha256 TEXT,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_attachments_note ON attachments (note_id);
|
||||||
|
|
||||||
|
CREATE TABLE link_previews (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
description TEXT,
|
||||||
|
image_url TEXT,
|
||||||
|
site_name TEXT,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_previews_note ON link_previews (note_id);
|
||||||
|
|
||||||
|
CREATE TABLE note_revisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||||
|
title TEXT,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_revisions_note ON note_revisions (note_id, created_at);
|
||||||
|
|
||||||
|
CREATE TABLE saved_filters (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
params TEXT NOT NULL DEFAULT '{}', -- NoteFacets JSON
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Single-row sync bookkeeping (server URL / device token / last-consumed cursor).
|
||||||
|
CREATE TABLE sync_state (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
server_url TEXT,
|
||||||
|
device_token TEXT,
|
||||||
|
last_cursor TEXT
|
||||||
|
);
|
||||||
|
INSERT INTO sync_state (id) VALUES (1);
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Bring the database up to the latest schema. Idempotent.
|
||||||
|
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
||||||
|
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||||
|
let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
||||||
|
if version < 1 {
|
||||||
|
conn.execute_batch(SCHEMA_V1)?;
|
||||||
|
conn.execute_batch("PRAGMA user_version = 1;")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,719 @@
|
|||||||
|
//! 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| {
|
||||||
|
Ok(Attachment {
|
||||||
|
id: r.get(0)?,
|
||||||
|
url: r.get(1)?,
|
||||||
|
filename: r.get(2)?,
|
||||||
|
mime: r.get(3)?,
|
||||||
|
size: r.get(4)?,
|
||||||
|
sha256: r.get(5)?,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
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
|
||||||
|
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)?,
|
||||||
|
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")?;
|
||||||
|
stmt.query_map([], |r| r.get::<_, String>(0))?.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",
|
||||||
|
)?;
|
||||||
|
stmt.query_map([&pat], |r| r.get::<_, String>(0))?.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> {
|
||||||
|
conn.execute("UPDATE notes SET trashed = 1 WHERE id = ?1", [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 WHERE id = ?1", [id])?;
|
||||||
|
touch(conn, id)?;
|
||||||
|
load_note(conn, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_forever(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||||
|
conn.execute("DELETE FROM notes WHERE id = ?1", [id])?;
|
||||||
|
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<()> {
|
||||||
|
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],
|
||||||
|
)?;
|
||||||
|
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