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 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Failing after 1m21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m47s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m48s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m39s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 8m37s
From the audit (#5178). Each was unreachable from every client: - Checklist add-item and delete-item: REST POST /items and DELETE /items/<id>, the Tauri commands, the store, rest and local adapters, the core's add_item/delete_item, set_item_text and remove_item, and the FFI exports. Adding, rewording and removing an item are body edits in every editor. The checked toggle stays, and its rewriter is simpler without the drop branch. - Manual unfurl: POST /unfurl and its adapters. Previews arrive in the background after a save (unfurl_queue). - The /api/config `android_client` key, android_release() and the APK_NAME/MANIFEST_NAME aliases. Phones poll /api/client/android. - users.email_verified and users.avatar_path (migration 0037). Nothing set the first or read the second; the SMTP reset never checked verification. - derive::extract_tags (only tests used it; the shared fixture now runs through extract_tag_spans), the unused check and link icons, and the unused editor_add_item string. - The blob scheme is renamed tsblob -> inkblob. URLs are built as notes are read, so nothing stored carries the old one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1140 lines
48 KiB
Rust
1140 lines
48 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, Directory, Identity, Label, Note, NoteDraft,
|
|
NoteEdit, NoteQuery, NoteShare, ProbeResult, RevokeOutcome, ShareTarget, 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 ────────────────────────────
|
|
|
|
/// Tick or untick one item. Adding, rewording and removing items are edits to
|
|
/// the body, which the editor makes like any other.
|
|
///
|
|
/// 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 set_item_checked(
|
|
&self,
|
|
note_id: String,
|
|
item_id: String,
|
|
checked: bool,
|
|
) -> Result<Note, CoreError> {
|
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
local::store::update_item(
|
|
&conn,
|
|
¬e_id,
|
|
&item_id,
|
|
&serde_json::json!({ "checked": checked }),
|
|
)
|
|
.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, and every group.
|
|
pub async fn share_directory(&self) -> Result<Directory, CoreError> {
|
|
self.credentials()?;
|
|
let directory = sharing::directory(&self.db)
|
|
.await
|
|
.map_err(CoreError::network)?;
|
|
Ok(directory.into())
|
|
}
|
|
|
|
/// 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 a person or a group at "view" or "edit", or change their permission.
|
|
pub async fn share_note(
|
|
&self,
|
|
note_id: String,
|
|
target: ShareTarget,
|
|
permission: String,
|
|
) -> Result<Vec<NoteShare>, CoreError> {
|
|
self.credentials()?;
|
|
let shares = sharing::share(&self.db, ¬e_id, &target.into(), &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 {
|
|
/// 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();
|
|
}
|
|
|
|
/// Ticking a box returns the reloaded note, which is what the UI splices back
|
|
/// into the board rather than re-querying, and leaves the item's text alone.
|
|
#[test]
|
|
fn ticking_an_item_rewrites_only_its_box() {
|
|
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(), "charger".to_string()]),
|
|
})
|
|
.expect("create");
|
|
assert_eq!(note.items.len(), 2);
|
|
|
|
let item_id = note.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!(!ticked.items[0].checked);
|
|
assert_eq!(ticked.items[1].text, "charger");
|
|
|
|
let unticked = app
|
|
.set_item_checked(note.id.clone(), item_id, false)
|
|
.expect("untick");
|
|
assert_eq!(unticked.body, note.body);
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|