CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 1m14s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 1m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 9m20s
The core pulls with shares from a server offering them (protocol 6): a note says how it is held (owner, edit, view) and who shared it, and a revoked note leaves the device. The first such pull starts the feed over once, so notes shared before this build arrive. The store refuses what a share doesn't allow (view: everything; edit: anything but the text), push sends only the text of someone else's note, and their notes stay out of trash, reminders and reordering. Unlinking drops them. The Share dialog's calls go to the linked server over the device token, as Tauri commands and through the FFI. The desktop now offers Share and "Shared with me"; unlinked, the dialog says sharing needs a server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1202 lines
50 KiB
Rust
1202 lines
50 KiB
Rust
//! uniffi bindings: `inkwell-core` as seen from Kotlin.
|
|
//!
|
|
//! This crate is to Android what `desktop/src-tauri/src/commands/` is to the desktop
|
|
//! — a thin shim over the shared core, holding no logic of its own. If something here
|
|
//! starts making decisions about notes or sync, it belongs in the core where the
|
|
//! desktop gets it too (Scribe note 2730).
|
|
//!
|
|
//! ## Shape
|
|
//!
|
|
//! One `Inkwell` object holds the store and the blob directory, mirroring how
|
|
//! Tauri manages them as app state. Kotlin constructs it once, keeps it for the
|
|
//! process lifetime, and calls methods on it.
|
|
//!
|
|
//! ## Async
|
|
//!
|
|
//! The sync engine is reqwest all the way down, so it needs a reactor. Async methods
|
|
//! are exported with `async_runtime = "tokio"`, which uniffi turns into Kotlin
|
|
//! `suspend` functions driven by a tokio runtime on the Rust side.
|
|
//!
|
|
//! Cancellation works, and not by accident: when a coroutine is cancelled uniffi
|
|
//! drops the Rust future, and none of the core's async paths hold the store lock
|
|
//! across an `await` — a `std::sync::MutexGuard` isn't `Send`, so the compiler has
|
|
//! been enforcing that all along. A cancelled sync therefore leaves the store
|
|
//! consistent; it simply hasn't stamped `last_sync_at`, which is only written after
|
|
//! BOTH halves of a cycle succeed. The next cycle resumes from the stored cursor.
|
|
//!
|
|
//! ## A known consequence of the release profile
|
|
//!
|
|
//! The workspace sets `panic = "abort"` (Tauri's profile, for binary size). uniffi
|
|
//! would otherwise catch a panic crossing the FFI boundary and raise it in Kotlin as
|
|
//! an exception; with `abort` it takes the process down instead. That is the same
|
|
//! behaviour the desktop already has, so no surface is worse off than another — but
|
|
//! it is a deliberate cost, not an oversight. Revisit if a panic in the core ever
|
|
//! turns out to be recoverable enough that a phone should survive it.
|
|
|
|
pub mod models;
|
|
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use inkwell_core::local::{self, Db};
|
|
use inkwell_core::sync::blobs::BlobStore;
|
|
use inkwell_core::sync::{client, compat, engine, push, sharing, state};
|
|
|
|
use models::{
|
|
patch_from, BodyItem, BodyTag, ClientUpdate, Identity, Label, Member, Note, NoteDraft,
|
|
NoteEdit, NoteQuery, NoteShare, ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus,
|
|
};
|
|
|
|
uniffi::setup_scaffolding!();
|
|
|
|
/// Everything that can go wrong, as a Kotlin exception.
|
|
///
|
|
/// The core reports failures as plain `String`s today, so most of them land in
|
|
/// `Store` or `Network` by where they were raised rather than by a distinction the
|
|
/// core actually draws. `NotLinked` is the exception and earns its own variant: it
|
|
/// is the one failure that is a NORMAL state rather than a fault — an unlinked app is
|
|
/// working exactly as intended — and the UI's response is to offer linking, not to
|
|
/// show an error.
|
|
#[derive(Debug, thiserror::Error, uniffi::Error)]
|
|
// FLAT, so the Kotlin side gets the message on `Throwable` where it belongs.
|
|
//
|
|
// Without this, uniffi generates an exception subclass with a `message` PROPERTY
|
|
// per variant — which collides with `Throwable.message` and fails to compile:
|
|
// "'message' hides member of supertype 'Throwable' and needs an 'override'
|
|
// modifier". Renaming the field would dodge the collision but leave
|
|
// `e.message` null in Kotlin, so every call site would have to know the variant
|
|
// just to read the text.
|
|
//
|
|
// Flat keeps what actually matters: each variant is still its own Kotlin
|
|
// subclass, so `catch (e: CoreException.NotLinked)` still works and a `when` is
|
|
// still exhaustive. Only the FIELDS stop crossing, and the Display string —
|
|
// which is the field, for every variant that has one — comes through as the
|
|
// exception message.
|
|
#[uniffi(flat_error)]
|
|
pub enum CoreError {
|
|
/// No server is linked. Not a fault; the app is local-first and this is its
|
|
/// resting state.
|
|
#[error("this device isn't linked to a server")]
|
|
NotLinked,
|
|
|
|
/// The on-device store failed.
|
|
#[error("{message}")]
|
|
Store { message: String },
|
|
|
|
/// Talking to the server failed, or it refused.
|
|
#[error("{message}")]
|
|
Network { message: String },
|
|
}
|
|
|
|
impl CoreError {
|
|
fn store(e: impl std::fmt::Display) -> Self {
|
|
CoreError::Store {
|
|
message: e.to_string(),
|
|
}
|
|
}
|
|
|
|
fn network(e: impl std::fmt::Display) -> Self {
|
|
CoreError::Network {
|
|
message: e.to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The client handle: the on-device store plus the attachment directory beside it.
|
|
///
|
|
/// Held by Kotlin for the process lifetime. Both halves are `Send + Sync` — the store
|
|
/// behind its mutex, the blob store being a path — which is what lets uniffi share
|
|
/// one instance across coroutines.
|
|
#[derive(uniffi::Object)]
|
|
pub struct Inkwell {
|
|
db: Db,
|
|
blobs: BlobStore,
|
|
}
|
|
|
|
#[uniffi::export]
|
|
impl Inkwell {
|
|
/// Open (creating on first run) the store under `data_dir`, and the attachment
|
|
/// directory beside it.
|
|
///
|
|
/// `data_dir` comes from Kotlin because only Android knows where its app-private
|
|
/// storage is; the core must not guess at a platform path. The layout inside is
|
|
/// the core's business and matches the desktop's exactly — `inkwell.db` and
|
|
/// `blobs/` — so a store is readable by any client that opens it.
|
|
#[uniffi::constructor]
|
|
pub fn new(data_dir: String) -> Result<Arc<Self>, CoreError> {
|
|
let dir = PathBuf::from(data_dir);
|
|
std::fs::create_dir_all(&dir).map_err(CoreError::store)?;
|
|
|
|
let db = local::open(&dir.join("inkwell.db")).map_err(CoreError::store)?;
|
|
log::info!("local store ready — {}", local::summary(&db));
|
|
|
|
let blobs = BlobStore::new(dir.join("blobs")).map_err(CoreError::store)?;
|
|
Ok(Arc::new(Inkwell { db, blobs }))
|
|
}
|
|
|
|
/// A one-line count summary, for the boot log.
|
|
pub fn summary(&self) -> String {
|
|
local::summary(&self.db)
|
|
}
|
|
|
|
// ─────────────────────────────── notes ───────────────────────────────
|
|
|
|
pub fn list_notes(&self, query: NoteQuery) -> Result<Vec<Note>, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
let notes = local::store::list_notes(&conn, &query.into()).map_err(CoreError::store)?;
|
|
Ok(notes.into_iter().map(Note::from).collect())
|
|
}
|
|
|
|
pub fn get_note(&self, id: String) -> Result<Note, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::get_note(&conn, &id)
|
|
.map(Note::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
pub fn create_note(&self, draft: NoteDraft) -> Result<Note, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::create_note(&conn, &draft.into())
|
|
.map(Note::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
/// Apply a batch of field edits. See `NoteEdit` for why this is a list rather
|
|
/// than a struct of nullable fields.
|
|
pub fn update_note(&self, id: String, edits: Vec<NoteEdit>) -> Result<Note, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::update_note(&conn, &id, &patch_from(edits))
|
|
.map(Note::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
/// Full-text search across titles, bodies and checklist items.
|
|
///
|
|
/// The core owns the query — it searches the same columns the desktop and web
|
|
/// search, so "what matches" cannot drift between surfaces. Filtering the
|
|
/// board list in Kotlin would have been less code and a different product.
|
|
pub fn search_notes(&self, query: String) -> Result<Vec<Note>, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
let notes = local::store::search(&conn, &query).map_err(CoreError::store)?;
|
|
Ok(notes.into_iter().map(Note::from).collect())
|
|
}
|
|
|
|
/// Notes carrying a reminder, soonest first.
|
|
///
|
|
/// A dedicated call rather than a board `view`, because that is how the core
|
|
/// models it — `list_notes` only understands trashed/archived/default.
|
|
pub fn reminder_notes(&self) -> Result<Vec<Note>, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
let notes = local::store::reminders(&conn).map_err(CoreError::store)?;
|
|
Ok(notes.into_iter().map(Note::from).collect())
|
|
}
|
|
|
|
/// Every label with its note count, for the navigation drawer.
|
|
pub fn list_labels(&self) -> Result<Vec<Label>, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
let labels = local::store::list_labels(&conn).map_err(CoreError::store)?;
|
|
Ok(labels.into_iter().map(Label::from).collect())
|
|
}
|
|
|
|
pub fn trash_note(&self, id: String) -> Result<Note, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::trash(&conn, &id)
|
|
.map(Note::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
pub fn restore_note(&self, id: String) -> Result<Note, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::restore(&conn, &id)
|
|
.map(Note::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
/// Remove a note permanently.
|
|
///
|
|
/// Returns nothing, unlike every other mutation here: there is no note left to
|
|
/// return. The core also records a pending delete, so a linked device tells the
|
|
/// server rather than having the next pull resurrect the row.
|
|
pub fn delete_note_forever(&self, id: String) -> Result<(), CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::delete_forever(&conn, &id).map_err(CoreError::store)
|
|
}
|
|
|
|
// ──────────────────────────── checklist items ────────────────────────────
|
|
//
|
|
// Every one of these returns the whole reloaded note rather than the item it
|
|
// touched. That is the core's shape, and it is the right one for a UI: ticking
|
|
// a box changes `updated_at` and can change what the board shows, so handing
|
|
// back only the item would leave Kotlin to guess at the rest.
|
|
|
|
pub fn add_item(&self, note_id: String, text: String) -> Result<Note, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::add_item(&conn, ¬e_id, &text)
|
|
.map(Note::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
/// Retitle one item.
|
|
///
|
|
/// Split from `set_item_checked` rather than exposing the core's
|
|
/// `{text?, checked?}` patch, for the same reason `NoteEdit` exists: an
|
|
/// optional-field struct cannot say "leave this alone" in Kotlin without
|
|
/// colliding with "set it to null", and two unambiguous calls beat one
|
|
/// ambiguous one when each is three lines.
|
|
pub fn set_item_text(
|
|
&self,
|
|
note_id: String,
|
|
item_id: String,
|
|
text: String,
|
|
) -> Result<Note, CoreError> {
|
|
self.patch_item(¬e_id, &item_id, serde_json::json!({ "text": text }))
|
|
}
|
|
|
|
pub fn set_item_checked(
|
|
&self,
|
|
note_id: String,
|
|
item_id: String,
|
|
checked: bool,
|
|
) -> Result<Note, CoreError> {
|
|
self.patch_item(
|
|
¬e_id,
|
|
&item_id,
|
|
serde_json::json!({ "checked": checked }),
|
|
)
|
|
}
|
|
|
|
pub fn delete_item(&self, note_id: String, item_id: String) -> Result<Note, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::delete_item(&conn, ¬e_id, &item_id)
|
|
.map(Note::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
// ─────────────────────────────── reminders ───────────────────────────────
|
|
|
|
/// Clear the reminder, marking it dealt with.
|
|
///
|
|
/// Distinct from `NoteEdit::ClearRemindAt` even though today they do the same
|
|
/// thing: the core reserves this one for "the reminder fired and is finished",
|
|
/// which is where recurrence advancement lands when it is built. A UI that
|
|
/// called the generic clear instead would silently stop recurring reminders
|
|
/// from recurring the day that changes.
|
|
pub fn complete_reminder(&self, id: String) -> Result<Note, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::complete_reminder(&conn, &id)
|
|
.map(Note::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
/// Push the reminder out by `minutes` from now.
|
|
///
|
|
/// The core computes the new instant from its own clock rather than taking one
|
|
/// from the caller — so "in an hour" means the same thing on every surface,
|
|
/// and a phone with a skewed clock can't write a reminder the server reads as
|
|
/// already past.
|
|
pub fn snooze_reminder(&self, id: String, minutes: i64) -> Result<Note, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::snooze_reminder(&conn, &id, minutes)
|
|
.map(Note::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
// ───────────────────────────────── labels ────────────────────────────────
|
|
|
|
/// Replace the note's MANUAL labels.
|
|
///
|
|
/// `#tag` labels are owned by the body text and the core re-derives them on
|
|
/// every body edit, so they are deliberately untouched here. A picker that
|
|
/// sent the full visible set would strip a tag label the text still mandates —
|
|
/// and the next keystroke in the body would put it straight back, which is the
|
|
/// kind of fight a UI should never pick with its store.
|
|
pub fn set_note_labels(
|
|
&self,
|
|
note_id: String,
|
|
label_ids: Vec<String>,
|
|
) -> Result<Note, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::set_labels(&conn, ¬e_id, &label_ids)
|
|
.map(Note::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
/// Find or create a label by name, returning it either way.
|
|
///
|
|
/// Find-or-create rather than create: the core matches case-insensitively, so
|
|
/// typing "Errands" when "errands" exists has to attach the existing label
|
|
/// instead of minting a near-duplicate that then diverges on colour.
|
|
pub fn create_label(&self, name: String) -> Result<Label, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::create_label(&conn, &name)
|
|
.map(Label::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
/// Rename a label. Every note carrying it follows, because notes reference it
|
|
/// by id and never by name.
|
|
///
|
|
/// Renaming onto a name another tag already holds MERGES the two, and the OLDER
|
|
/// row is the survivor — it keeps its id and colour and takes the new spelling.
|
|
/// Matching is case-insensitive, like `find_or_create_label`.
|
|
///
|
|
/// So this call can return a label whose id is NOT the one passed in, and it can
|
|
/// make another label stop existing. A UI over it should say so before calling:
|
|
/// the merge cannot be undone by repeating it, and here it is reachable by a
|
|
/// typo in a text field. The web asks first (`stores/labels.ts`); this binding
|
|
/// deliberately does not, because a confirmation belongs to the surface that has
|
|
/// a person in front of it, not to the store.
|
|
///
|
|
/// `store::rename_label` and the server's PATCH implement the same rule, so the
|
|
/// phone, the desktop and the web agree on which row survives.
|
|
pub fn rename_label(&self, id: String, name: String) -> Result<Label, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::rename_label(&conn, &id, &name)
|
|
.map(Label::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
/// Recolour a label.
|
|
///
|
|
/// `color` is a palette KEY from the shared vocabulary (`NoteTint.kt` on this
|
|
/// side), not a hex value — the point of the shared palette is that a colour
|
|
/// picked on the phone resolves to the same swatch on the web and the desktop,
|
|
/// which a literal colour could not promise across themes.
|
|
pub fn set_label_color(&self, id: String, color: String) -> Result<Label, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::set_label_color(&conn, &id, &color)
|
|
.map(Label::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
/// Delete a label. The notes that carried it are NOT deleted — they simply stop
|
|
/// carrying it, which is the thing a confirmation dialog has to say out loud.
|
|
///
|
|
/// A `#tag` in a body will re-derive the label on the next edit of that note.
|
|
/// That is correct rather than a leak: the text mandates it, and deleting the
|
|
/// row cannot un-write the word.
|
|
pub fn remove_label(&self, id: String) -> Result<(), CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::remove_label(&conn, &id).map_err(CoreError::store)
|
|
}
|
|
|
|
/// Fold `source` into `target` and return the survivor.
|
|
///
|
|
/// DIRECTIONAL and NOT reversible by repeating it: source stops existing. Any
|
|
/// UI over this has to name the survivor before it runs, because afterwards
|
|
/// there is nothing left to read the direction from.
|
|
pub fn merge_labels(&self, source_id: String, target_id: String) -> Result<Label, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::merge_labels(&conn, &source_id, &target_id)
|
|
.map(Label::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
// ────────────────────────── attachments and previews ──────────────────────────
|
|
|
|
/// Attach a file. The bytes go into the blob store now and up to the server on
|
|
/// the next sync that can reach one, so attaching works with no network.
|
|
///
|
|
/// The bytes cross the FFI as one buffer. Kotlin caps what it reads before
|
|
/// calling, because this copies the whole file into Rust's memory once.
|
|
pub fn add_attachment(
|
|
&self,
|
|
note_id: String,
|
|
filename: String,
|
|
mime: String,
|
|
bytes: Vec<u8>,
|
|
) -> Result<Note, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::add_attachment(&conn, &self.blobs, ¬e_id, &filename, &mime, &bytes)
|
|
.map(Note::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
pub fn delete_attachment(
|
|
&self,
|
|
note_id: String,
|
|
attachment_id: String,
|
|
) -> Result<Note, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::delete_attachment(&conn, ¬e_id, &attachment_id)
|
|
.map(Note::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
pub fn delete_preview(&self, note_id: String, preview_id: String) -> Result<Note, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::delete_preview(&conn, ¬e_id, &preview_id)
|
|
.map(Note::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
/// Where this device holds an attachment's bytes, or None when it doesn't yet
|
|
/// (still downloading, or the download failed).
|
|
///
|
|
/// A path rather than the bytes, so Kotlin can decode an image at the size it
|
|
/// will be drawn instead of pulling the full file across the FFI for a thumbnail.
|
|
pub fn blob_path(&self, sha256: String) -> Option<String> {
|
|
self.blobs
|
|
.path(&sha256)
|
|
.filter(|p| p.is_file())
|
|
.map(|p| p.to_string_lossy().into_owned())
|
|
}
|
|
|
|
// ─────────────────────────────── sync ────────────────────────────────
|
|
|
|
pub fn sync_status(&self) -> Result<SyncStatus, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
state::status(&conn)
|
|
.map(SyncStatus::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
/// Whether anything is waiting to be sent — so the UI can show an honest
|
|
/// "unsynced changes" state without running a sync to find out.
|
|
pub fn has_pending(&self) -> Result<bool, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
push::has_pending(&conn).map_err(CoreError::store)
|
|
}
|
|
}
|
|
|
|
/// Async methods, driven by a tokio runtime and surfaced to Kotlin as `suspend`
|
|
/// functions. Split into its own impl block so the runtime attribute — and the fact
|
|
/// that everything in here touches the network — is visible at a glance.
|
|
#[uniffi::export(async_runtime = "tokio")]
|
|
impl Inkwell {
|
|
/// Ask a server who it is, without committing to anything. Called as the user
|
|
/// finishes typing an address, so they see what answered before handing over
|
|
/// credentials.
|
|
pub async fn probe(&self, url: String) -> Result<ProbeResult, CoreError> {
|
|
client::probe(&url)
|
|
.await
|
|
.map(ProbeResult::from)
|
|
.map_err(CoreError::network)
|
|
}
|
|
|
|
/// Pair with a server using an email/password, minting a device token named for
|
|
/// this phone.
|
|
///
|
|
/// The handshake runs FIRST, and an incompatible server aborts before any
|
|
/// credential is sent — an incompatible server is exactly the case where a later
|
|
/// failure would be hardest to attribute.
|
|
pub async fn link_with_password(
|
|
&self,
|
|
url: String,
|
|
email: String,
|
|
password: String,
|
|
device_name: String,
|
|
) -> Result<Identity, CoreError> {
|
|
let probe = client::probe(&url).await.map_err(CoreError::network)?;
|
|
if let compat::Compatibility::Incompatible { reason, .. } = &probe.compatibility {
|
|
return Err(CoreError::Network {
|
|
message: reason.clone(),
|
|
});
|
|
}
|
|
let (token, identity) =
|
|
client::device_login(&probe.base_url, &email, &password, &device_name)
|
|
.await
|
|
.map_err(CoreError::network)?;
|
|
self.store_link(&probe.base_url, &token, probe.server.trash_retention_days)?;
|
|
Ok(identity.into())
|
|
}
|
|
|
|
/// Pair using a device token pasted from the web app — for anyone who would
|
|
/// rather not type a password into an app, or whose account is behind SSO.
|
|
///
|
|
/// The token is verified before it is stored, so a copy/paste slip fails here
|
|
/// rather than at the next sync.
|
|
pub async fn link_with_token(&self, url: String, token: String) -> Result<Identity, CoreError> {
|
|
let probe = client::probe(&url).await.map_err(CoreError::network)?;
|
|
if let compat::Compatibility::Incompatible { reason, .. } = &probe.compatibility {
|
|
return Err(CoreError::Network {
|
|
message: reason.clone(),
|
|
});
|
|
}
|
|
let identity = client::fetch_identity(&probe.base_url, &token)
|
|
.await
|
|
.map_err(CoreError::network)?;
|
|
self.store_link(&probe.base_url, &token, probe.server.trash_retention_days)?;
|
|
Ok(identity.into())
|
|
}
|
|
|
|
/// Stop syncing, and retire this device's token on the server.
|
|
///
|
|
/// The local half is unconditional. Someone unlinking because the phone is being
|
|
/// sold or handed on must not be held to it by a server that is offline or gone,
|
|
/// so the revoke is attempted first, its outcome returned for the UI to report
|
|
/// honestly, and the link cleared either way.
|
|
pub async fn unlink(&self) -> Result<RevokeOutcome, CoreError> {
|
|
// Read and release before the network call: a std MutexGuard isn't Send, so
|
|
// it cannot be held across an await, and holding the store through a
|
|
// round-trip would freeze every note operation in the UI.
|
|
let link = {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
let current = state::read(&conn).map_err(CoreError::store)?;
|
|
current.server_url.zip(current.device_token)
|
|
};
|
|
let revoked = match &link {
|
|
Some((base_url, token)) => client::revoke_self(base_url, token).await,
|
|
None => client::RevokeOutcome::Skipped,
|
|
};
|
|
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
state::clear_link(&conn).map_err(CoreError::store)?;
|
|
log::info!("unlinked from server (server-side token: {revoked:?})");
|
|
Ok(revoked.into())
|
|
}
|
|
|
|
/// Run one full sync: push local changes, then pull the server's.
|
|
///
|
|
/// The only sync entry point, on purpose. Push and pull exist separately inside
|
|
/// the core, but offering a bare "pull" would let the UI overwrite unsent local
|
|
/// edits — the ordering isn't a suggestion, it's what keeps them.
|
|
/// The Android client the linked server is offering, if any.
|
|
///
|
|
/// `None` covers two different-looking situations that are one answer to the
|
|
/// app: this server has no client, or it has one and it is not newer than what
|
|
/// is already installed. Comparing here rather than in Kotlin keeps the rule —
|
|
/// version CODE decides, never the name — in the layer that also has to get it
|
|
/// right for the desktop.
|
|
pub async fn client_update(
|
|
&self,
|
|
installed_version_code: i64,
|
|
) -> Result<Option<ClientUpdate>, CoreError> {
|
|
let (base_url, token) = self.credentials()?;
|
|
let release = client::fetch_client_release(&base_url, &token)
|
|
.await
|
|
.map_err(CoreError::network)?;
|
|
Ok(release
|
|
.filter(|r| r.version_code > installed_version_code)
|
|
.map(ClientUpdate::from))
|
|
}
|
|
|
|
/// Download that client to `dest_path`, verified.
|
|
///
|
|
/// Takes the destination rather than choosing one: only Android knows a
|
|
/// directory its own package installer can read from, and the core has no
|
|
/// business guessing at platform paths — the same reason `Inkwell::new`
|
|
/// takes a data dir.
|
|
pub async fn download_client_update(&self, dest_path: String) -> Result<(), CoreError> {
|
|
let (base_url, token) = self.credentials()?;
|
|
let release = client::fetch_client_release(&base_url, &token)
|
|
.await
|
|
.map_err(CoreError::network)?
|
|
// Re-read rather than trusting what the caller was shown: the server
|
|
// may have published a new build between the check and the tap, and
|
|
// downloading against a stale digest would fail verification on bytes
|
|
// that are perfectly good.
|
|
.ok_or_else(|| {
|
|
CoreError::network("This server no longer has an Android client.".to_string())
|
|
})?;
|
|
client::download_client(
|
|
&base_url,
|
|
&token,
|
|
&release,
|
|
std::path::Path::new(&dest_path),
|
|
)
|
|
.await
|
|
.map_err(CoreError::network)
|
|
}
|
|
|
|
pub async fn sync_now(&self) -> Result<SyncOutcome, CoreError> {
|
|
let (base_url, token) = self.credentials()?;
|
|
engine::run_cycle(&self.db, &self.blobs, &base_url, &token)
|
|
.await
|
|
.map(SyncOutcome::from)
|
|
.map_err(CoreError::network)
|
|
}
|
|
|
|
// ────────────────────────────── sharing ──────────────────────────────
|
|
//
|
|
// The Share dialog asks the server directly (#5175). Unlinked, each answers
|
|
// `NotLinked`, which the dialog turns into "sharing needs a server".
|
|
|
|
/// Everyone on the instance a note can be shared with.
|
|
pub async fn share_directory(&self) -> Result<Vec<Member>, CoreError> {
|
|
self.credentials()?;
|
|
let members = sharing::directory(&self.db)
|
|
.await
|
|
.map_err(CoreError::network)?;
|
|
Ok(members.into_iter().map(Member::from).collect())
|
|
}
|
|
|
|
/// Who this note is shared with.
|
|
pub async fn note_shares(&self, note_id: String) -> Result<Vec<NoteShare>, CoreError> {
|
|
self.credentials()?;
|
|
let shares = sharing::list(&self.db, ¬e_id)
|
|
.await
|
|
.map_err(CoreError::network)?;
|
|
Ok(shares.into_iter().map(NoteShare::from).collect())
|
|
}
|
|
|
|
/// Share with one member at "view" or "edit", or change their permission.
|
|
pub async fn share_note(
|
|
&self,
|
|
note_id: String,
|
|
user_id: String,
|
|
permission: String,
|
|
) -> Result<Vec<NoteShare>, CoreError> {
|
|
self.credentials()?;
|
|
let shares = sharing::share(&self.db, ¬e_id, &user_id, &permission)
|
|
.await
|
|
.map_err(CoreError::network)?;
|
|
Ok(shares.into_iter().map(NoteShare::from).collect())
|
|
}
|
|
|
|
pub async fn unshare_note(
|
|
&self,
|
|
note_id: String,
|
|
share_id: String,
|
|
) -> Result<Vec<NoteShare>, CoreError> {
|
|
self.credentials()?;
|
|
let shares = sharing::unshare(&self.db, ¬e_id, &share_id)
|
|
.await
|
|
.map_err(CoreError::network)?;
|
|
Ok(shares.into_iter().map(NoteShare::from).collect())
|
|
}
|
|
}
|
|
|
|
// ── checklist text, as pure functions ───────────────────────────────────────
|
|
//
|
|
// The pair the block editor is built on: one to read a body apart, one to put a line
|
|
// back together. Between them, Kotlin can render a checklist as real checkboxes and
|
|
// write the markdown back without owning the grammar — which is the point. Three
|
|
// implementations of it is the price already being paid (Rust, Python, TypeScript);
|
|
// a fourth in Compose would be one more place for a checklist to change shape when
|
|
// it syncs.
|
|
//
|
|
// Free functions rather than methods, because they touch no database. The editor's
|
|
// body is LOCAL state — autosaved on an idle debounce, not written per keystroke —
|
|
// so editing a checklist there has to rewrite the text the editor is holding, not a
|
|
// row the store would hand back a moment later and overwrite the typing with.
|
|
|
|
/// One checklist item as the body line that stores it. For an editor that shows a
|
|
/// checkbox instead of the markup and has to write the markup back.
|
|
#[uniffi::export]
|
|
pub fn checklist_render(text: String, checked: bool) -> String {
|
|
local::derive::render_item(&text, checked)
|
|
}
|
|
|
|
/// Tell the core which app it is running inside, and which build of it.
|
|
///
|
|
/// Android has to say so because the core cannot: the same crate is compiled into
|
|
/// the desktop app, and it used to announce every phone in the field as
|
|
/// `inkwell-desktop` carrying the CORE crate's version — a number no build
|
|
/// stamps and nobody has seen. The honest value is the installed package's own
|
|
/// `versionName`, which is what Kotlin passes here.
|
|
///
|
|
/// Called once from `InkwellApplication.onCreate`, before anything can sync.
|
|
#[uniffi::export]
|
|
pub fn set_client_agent(name: String, version: String) {
|
|
compat::set_client_agent(&name, &version);
|
|
}
|
|
|
|
/// Every checklist item in a body, with the line each one sits on — so a renderer
|
|
/// walking the body line by line knows which lines are boxes and what is in them.
|
|
#[uniffi::export]
|
|
pub fn checklist_items(body: String) -> Vec<BodyItem> {
|
|
local::derive::extract_items(&body)
|
|
.into_iter()
|
|
.map(BodyItem::from)
|
|
.collect()
|
|
}
|
|
|
|
/// Every `#tag` in a body, with the line and the UTF-16 span each one occupies — so
|
|
/// a card can colour the tag where it was typed instead of printing it twice.
|
|
///
|
|
/// The same argument as `checklist_items` above, and the same answer: the grammar for
|
|
/// what a `#tag` is already exists in Rust, Python and TypeScript. Matching it a
|
|
/// fourth time in Compose would be a fourth place for a tag to change shape when it
|
|
/// syncs — and this one would fail silently, as the wrong characters tinted.
|
|
#[uniffi::export]
|
|
pub fn body_tags(body: String) -> Vec<BodyTag> {
|
|
local::derive::extract_tag_spans(&body)
|
|
.into_iter()
|
|
.map(BodyTag::from)
|
|
.collect()
|
|
}
|
|
|
|
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
|
|
/// block names, so these stay Rust-side.
|
|
impl Inkwell {
|
|
/// Apply a `{text}` or `{checked}` patch to one checklist item.
|
|
///
|
|
/// The two public setters differ only in the key they write, and the lock +
|
|
/// convert + map-error dance around it is identical, so it lives once here.
|
|
fn patch_item(
|
|
&self,
|
|
note_id: &str,
|
|
item_id: &str,
|
|
changes: serde_json::Value,
|
|
) -> Result<Note, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::update_item(&conn, note_id, item_id, &changes)
|
|
.map(Note::from)
|
|
.map_err(CoreError::store)
|
|
}
|
|
|
|
/// The server URL + token, or the `NotLinked` state. Every networked call needs
|
|
/// exactly this, and none of them may hold the lock past it.
|
|
fn credentials(&self) -> Result<(String, String), CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
let current = state::read(&conn).map_err(CoreError::store)?;
|
|
match (current.server_url, current.device_token) {
|
|
(Some(url), Some(token)) => Ok((url, token)),
|
|
_ => Err(CoreError::NotLinked),
|
|
}
|
|
}
|
|
|
|
/// Persist a fresh link, adopting the server's retention window at the same time
|
|
/// so the Trash view stops counting down against this device's offline default
|
|
/// the moment it is no longer the policy in force.
|
|
fn store_link(
|
|
&self,
|
|
base_url: &str,
|
|
token: &str,
|
|
retention_days: Option<u32>,
|
|
) -> Result<(), CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
state::set_link(&conn, base_url, token).map_err(CoreError::store)?;
|
|
if let Some(days) = retention_days {
|
|
state::set_server_retention(&conn, days as i64).map_err(CoreError::store)?;
|
|
}
|
|
log::info!("linked to {base_url}");
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A scratch directory unique to this process and call.
|
|
///
|
|
/// Process id + a counter rather than a uuid dependency: the FFI crate has no
|
|
/// business pulling one in to name a temp folder, and this is the same approach
|
|
/// the desktop's updater tests settled on.
|
|
fn scratch_dir() -> String {
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
static NEXT: AtomicU32 = AtomicU32::new(0);
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"inkwell-ffi-{}-{}",
|
|
std::process::id(),
|
|
NEXT.fetch_add(1, Ordering::Relaxed)
|
|
));
|
|
dir.to_string_lossy().into_owned()
|
|
}
|
|
|
|
fn draft(body: &str) -> NoteDraft {
|
|
NoteDraft {
|
|
body: body.to_string(),
|
|
items: None,
|
|
}
|
|
}
|
|
|
|
/// The round trip the Android skeleton has to make: open a store in a directory
|
|
/// that doesn't exist yet, write a note, read it back through the FFI types.
|
|
/// Proving it here means a failure on device is an Android problem, not a
|
|
/// binding problem.
|
|
#[test]
|
|
fn creates_a_store_and_round_trips_a_note() {
|
|
let dir = scratch_dir();
|
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
|
|
|
let created = app
|
|
.create_note(draft("Groceries\nmilk"))
|
|
.expect("create should succeed");
|
|
assert_eq!(created.body, "Groceries\nmilk");
|
|
|
|
let fetched = app
|
|
.get_note(created.id.clone())
|
|
.expect("get should succeed");
|
|
assert_eq!(fetched.id, created.id);
|
|
// The NAME is the first line — there is no title field to have set (M13 step 3).
|
|
assert_eq!(fetched.display_title, "Groceries");
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// Every note has to be nameable — that is what `display_title` is for, and the
|
|
/// Android board relies on it exactly as the desktop does.
|
|
#[test]
|
|
fn a_note_is_named_by_its_first_line() {
|
|
let dir = scratch_dir();
|
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
|
|
|
let created = app
|
|
.create_note(draft("just a thought"))
|
|
.expect("create should succeed");
|
|
assert_eq!(created.display_title, "just a thought");
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// The hole that made removing the title unsafe until checklists stopped being
|
|
/// their own kind of thing: a note with no body text still needs a name.
|
|
#[test]
|
|
fn a_note_with_only_items_is_named_by_its_first_item() {
|
|
let dir = scratch_dir();
|
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
|
|
|
let created = app
|
|
.create_note(NoteDraft {
|
|
body: String::new(),
|
|
items: Some(vec!["milk".to_string(), "eggs".to_string()]),
|
|
})
|
|
.expect("create should succeed");
|
|
assert_eq!(created.display_title, "milk");
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// An unlinked app is a normal, working app. Asking it to sync is the one
|
|
/// failure that isn't a fault, and it has to arrive as `NotLinked` so the UI can
|
|
/// offer linking rather than show an error.
|
|
#[test]
|
|
fn syncing_unlinked_reports_not_linked() {
|
|
let dir = scratch_dir();
|
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
|
|
|
let status = app.sync_status().expect("status should read");
|
|
assert!(!status.linked);
|
|
assert_eq!(status.server_url, None);
|
|
|
|
assert!(matches!(app.credentials(), Err(CoreError::NotLinked)));
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// The editor's whole checklist loop, in one pass: add a row, tick it, retitle
|
|
/// it, drop it. Each call returns the reloaded note, which is what the UI
|
|
/// splices back into the board rather than re-querying.
|
|
#[test]
|
|
fn checklist_items_can_be_added_ticked_retitled_and_removed() {
|
|
let dir = scratch_dir();
|
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
|
let note = app
|
|
.create_note(NoteDraft {
|
|
body: "Packing".to_string(),
|
|
items: Some(vec!["socks".to_string()]),
|
|
})
|
|
.expect("create");
|
|
assert_eq!(note.items.len(), 1);
|
|
|
|
let with_two = app
|
|
.add_item(note.id.clone(), "charger".to_string())
|
|
.expect("add");
|
|
assert_eq!(with_two.items.len(), 2);
|
|
// Appended, not prepended — a new row belongs at the bottom of the list the
|
|
// user is looking at.
|
|
assert_eq!(with_two.items[1].text, "charger");
|
|
|
|
let item_id = with_two.items[1].id.clone();
|
|
let ticked = app
|
|
.set_item_checked(note.id.clone(), item_id.clone(), true)
|
|
.expect("tick");
|
|
assert!(ticked.items[1].checked);
|
|
assert_eq!(
|
|
ticked.items[1].text, "charger",
|
|
"ticking a box must not disturb its text — both setters rewrite the \
|
|
same line of the body now, so one clobbering the other is a live risk \
|
|
rather than a theoretical one"
|
|
);
|
|
|
|
let renamed = app
|
|
.set_item_text(note.id.clone(), item_id.clone(), "usb-c cable".to_string())
|
|
.expect("rename");
|
|
assert_eq!(renamed.items[1].text, "usb-c cable");
|
|
assert!(
|
|
renamed.items[1].checked,
|
|
"and the same in the other direction"
|
|
);
|
|
|
|
let trimmed = app
|
|
.delete_item(note.id.clone(), item_id)
|
|
.expect("delete item");
|
|
assert_eq!(trimmed.items.len(), 1);
|
|
assert_eq!(trimmed.items[0].text, "socks");
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// A `#tag` in the body owns its label. The picker replaces MANUAL labels only,
|
|
/// so sending an empty set must not strip one the text still mandates —
|
|
/// otherwise the next body edit would re-derive it and the UI would appear to
|
|
/// fight itself.
|
|
#[test]
|
|
fn setting_labels_leaves_tag_derived_ones_alone() {
|
|
let dir = scratch_dir();
|
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
|
|
|
let note = app
|
|
.create_note(draft("Trip\nbook the ferry #travel"))
|
|
.expect("create");
|
|
assert_eq!(
|
|
note.labels.len(),
|
|
1,
|
|
"the #tag should have attached a label"
|
|
);
|
|
assert!(note.labels[0].via_tag);
|
|
|
|
let errands = app
|
|
.create_label("errands".to_string())
|
|
.expect("create label");
|
|
let tagged = app
|
|
.set_note_labels(note.id.clone(), vec![errands.id.clone()])
|
|
.expect("set labels");
|
|
assert_eq!(tagged.labels.len(), 2);
|
|
|
|
let cleared = app
|
|
.set_note_labels(note.id.clone(), vec![])
|
|
.expect("clear manual labels");
|
|
assert_eq!(cleared.labels.len(), 1);
|
|
assert!(cleared.labels[0].via_tag);
|
|
|
|
// Find-or-create, not create: a second "Errands" must be the same label,
|
|
// or the picker mints near-duplicates that then diverge on colour.
|
|
let again = app
|
|
.create_label("Errands".to_string())
|
|
.expect("create label again");
|
|
assert_eq!(again.id, errands.id);
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// Deleting forever has to actually remove the row, and the note must then be
|
|
/// unreadable rather than merely hidden.
|
|
#[test]
|
|
fn deleting_forever_removes_the_note() {
|
|
let dir = scratch_dir();
|
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
|
let note = app.create_note(draft("Ephemeral\nbody")).expect("create");
|
|
|
|
app.delete_note_forever(note.id.clone())
|
|
.expect("delete forever");
|
|
assert!(
|
|
app.get_note(note.id.clone()).is_err(),
|
|
"a permanently deleted note must not still load"
|
|
);
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// A file attached here lands in the blob store, is findable by its hash, and
|
|
/// leaves both the note and the board's view of it when removed.
|
|
#[test]
|
|
fn an_attached_file_is_stored_found_and_removable() {
|
|
let dir = scratch_dir();
|
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
|
let note = app.create_note(draft("Receipt")).expect("create");
|
|
|
|
let attached = app
|
|
.add_attachment(
|
|
note.id.clone(),
|
|
"receipt.png".into(),
|
|
"image/png".into(),
|
|
b"not really a png".to_vec(),
|
|
)
|
|
.expect("attach");
|
|
assert_eq!(attached.attachments.len(), 1);
|
|
let att = &attached.attachments[0];
|
|
assert_eq!(att.filename.as_deref(), Some("receipt.png"));
|
|
assert_eq!(att.mime, "image/png");
|
|
assert_eq!(att.upload_error, None);
|
|
|
|
let sha = att.sha256.clone().expect("a stored file carries its hash");
|
|
let path = app.blob_path(sha.clone()).expect("the bytes are on disk");
|
|
assert_eq!(std::fs::read(path).unwrap(), b"not really a png");
|
|
assert_eq!(
|
|
app.blob_path("0".repeat(64)),
|
|
None,
|
|
"an unknown hash has no path"
|
|
);
|
|
|
|
let after = app
|
|
.delete_attachment(note.id.clone(), att.id.clone())
|
|
.expect("remove");
|
|
assert!(after.attachments.is_empty());
|
|
|
|
assert!(
|
|
app.add_attachment(
|
|
"missing".into(),
|
|
"a.txt".into(),
|
|
"text/plain".into(),
|
|
vec![1]
|
|
)
|
|
.is_err(),
|
|
"attaching to a note that doesn't exist is refused"
|
|
);
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// Snooze writes a future instant from the CORE's clock; complete clears it.
|
|
#[test]
|
|
fn reminders_can_be_snoozed_and_completed() {
|
|
let dir = scratch_dir();
|
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
|
let note = app.create_note(draft("Call back")).expect("create");
|
|
assert_eq!(note.remind_at, None);
|
|
|
|
let snoozed = app.snooze_reminder(note.id.clone(), 60).expect("snooze");
|
|
let at = snoozed.remind_at.expect("snoozing must set a reminder");
|
|
let parsed = chrono_free_parse(&at);
|
|
assert!(
|
|
parsed > 0,
|
|
"the reminder must be a parseable RFC3339 instant, got {at:?}"
|
|
);
|
|
|
|
let done = app.complete_reminder(note.id.clone()).expect("complete");
|
|
assert_eq!(done.remind_at, None);
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// The path the notification's Done button takes.
|
|
///
|
|
/// Completing a RECURRING reminder must move it, not end it — this is the
|
|
/// behaviour the web has had all along and the clients did not, which made
|
|
/// "Done" on a daily reminder quietly the last time it ever fired.
|
|
#[test]
|
|
fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() {
|
|
let dir = scratch_dir();
|
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
|
let note = app.create_note(draft("Water the plants")).expect("create");
|
|
|
|
let armed = app
|
|
.update_note(
|
|
note.id.clone(),
|
|
vec![
|
|
NoteEdit::RemindAt {
|
|
value: "2026-07-01T09:00:00.000Z".into(),
|
|
},
|
|
NoteEdit::Recurrence {
|
|
value: "daily".into(),
|
|
},
|
|
],
|
|
)
|
|
.expect("arm a daily reminder");
|
|
assert_eq!(armed.recurrence.as_deref(), Some("daily"));
|
|
|
|
let done = app.complete_reminder(note.id.clone()).expect("complete");
|
|
let next = done
|
|
.remind_at
|
|
.expect("a daily reminder must still have a next occurrence");
|
|
assert!(
|
|
next.as_str() > "2026-07-01T09:00:00.000Z",
|
|
"it must move FORWARD, got {next:?}"
|
|
);
|
|
assert!(
|
|
next.ends_with("T09:00:00.000Z"),
|
|
"the time of day is what was asked for and must survive, got {next:?}"
|
|
);
|
|
assert_eq!(
|
|
done.recurrence.as_deref(),
|
|
Some("daily"),
|
|
"the rule outlives the occurrence"
|
|
);
|
|
|
|
// A one-off clears BOTH fields, so an unrecognised rule cannot linger
|
|
// invisibly on a note with no reminder.
|
|
let once = app.create_note(draft("Post the letter")).expect("create");
|
|
app.update_note(
|
|
once.id.clone(),
|
|
vec![NoteEdit::RemindAt {
|
|
value: "2026-07-01T09:00:00.000Z".into(),
|
|
}],
|
|
)
|
|
.expect("arm a one-off");
|
|
let finished = app.complete_reminder(once.id.clone()).expect("complete");
|
|
assert_eq!(finished.remind_at, None);
|
|
assert_eq!(finished.recurrence, None);
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// Renaming a tag onto a name another tag already holds MERGES the two, and the
|
|
/// OLDER row is the survivor.
|
|
///
|
|
/// Before this, the bare UPDATE met `idx_labels_name` — unique on `lower(name)` —
|
|
/// and the user got a raw "UNIQUE constraint failed" from SQLite. Merging is what
|
|
/// a person means by typing an existing tag's name onto this one.
|
|
#[test]
|
|
fn renaming_onto_an_existing_tag_merges_into_the_older_one() {
|
|
let dir = scratch_dir();
|
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
|
|
|
let older = app.create_label("grocery".to_string()).expect("older");
|
|
// `created_at` is RFC3339 to the MILLISECOND. Without a gap the two rows can
|
|
// share a timestamp, and then the tie-break is under test instead of the age
|
|
// rule this test is about.
|
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
let newer = app.create_label("errands".to_string()).expect("newer");
|
|
|
|
let one = app.create_note(draft("milk")).expect("note one");
|
|
let two = app.create_note(draft("stamps")).expect("note two");
|
|
app.set_note_labels(one.id.clone(), vec![older.id.clone()])
|
|
.expect("tag one");
|
|
app.set_note_labels(two.id.clone(), vec![newer.id.clone()])
|
|
.expect("tag two");
|
|
|
|
// The YOUNGER one is renamed onto the older's name, in a different case —
|
|
// matching is case-insensitive, and the survivor takes the spelling asked for.
|
|
let survivor = app
|
|
.rename_label(newer.id.clone(), "Grocery".to_string())
|
|
.expect("a rename onto an existing name merges instead of failing");
|
|
|
|
assert_eq!(
|
|
survivor.id, older.id,
|
|
"the older row is the one that survives"
|
|
);
|
|
assert_eq!(survivor.name, "Grocery", "spelled the way the caller asked");
|
|
|
|
let all = app.list_labels().expect("list");
|
|
assert_eq!(all.len(), 1, "the two became one");
|
|
assert_eq!(all[0].id, older.id);
|
|
assert_eq!(all[0].count, Some(2), "carrying every note from both sides");
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// The mirror of the test above. Renaming the OLDER one onto the younger's name
|
|
/// still leaves the older row standing — it just changes its name.
|
|
///
|
|
/// This is the whole reason age decides rather than "whoever already held the
|
|
/// name": otherwise the survivor depends on which way round someone typed it,
|
|
/// and two devices tidying the same pair would disagree about which id exists.
|
|
#[test]
|
|
fn the_rename_merge_survivor_does_not_depend_on_the_direction() {
|
|
let dir = scratch_dir();
|
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
|
|
|
let older = app.create_label("grocery".to_string()).expect("older");
|
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
let newer = app.create_label("errands".to_string()).expect("newer");
|
|
|
|
let survivor = app
|
|
.rename_label(older.id.clone(), "errands".to_string())
|
|
.expect("rename");
|
|
|
|
assert_eq!(survivor.id, older.id, "age wins in this direction too");
|
|
assert_eq!(survivor.name, "errands");
|
|
assert_ne!(
|
|
survivor.id, newer.id,
|
|
"the younger row is the one that went"
|
|
);
|
|
assert_eq!(app.list_labels().expect("list").len(), 1);
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// A crude RFC3339 sanity check that doesn't pull a date crate into this
|
|
/// crate's dev-dependencies to assert one field is well-formed.
|
|
fn chrono_free_parse(raw: &str) -> usize {
|
|
if raw.len() >= 20 && raw.as_bytes()[4] == b'-' && raw.contains('T') {
|
|
raw.len()
|
|
} else {
|
|
0
|
|
}
|
|
}
|
|
}
|