AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6

Merged
bvandeusen merged 214 commits from dev into main 2026-10-21 21:40:00 -04:00
5 changed files with 26 additions and 30 deletions
Showing only changes of commit 0101b05487 - Show all commits
+12
View File
@@ -15,6 +15,7 @@ pub mod store;
use std::path::Path;
use std::sync::Mutex;
use chrono::{DateTime, SecondsFormat, Utc};
use rusqlite::Connection;
/// The store's file inside a client's data directory. The layout is the same on
@@ -23,6 +24,17 @@ pub const DB_FILE: &str = "inkwell.db";
/// Attachment bytes, filed by content hash, beside [`DB_FILE`].
pub const BLOBS_DIR: &str = "blobs";
/// An instant in the store's own form: RFC 3339, UTC, milliseconds, `Z`. Every
/// stored time is written this way, which is what makes lexical order chronological.
pub fn iso(t: DateTime<Utc>) -> String {
t.to_rfc3339_opts(SecondsFormat::Millis, true)
}
/// Now, in the store's own form ([`iso`]).
pub fn now() -> String {
iso(Utc::now())
}
/// The shared database handle. rusqlite connections aren't `Sync`, so a `Mutex`
/// serializes access — fine, since operations are quick and a client is single-user.
/// How it is held is the caller's business: Tauri manages it as state, Android holds
+6 -11
View File
@@ -13,7 +13,7 @@
use std::io::{Cursor, Read, Write};
use chrono::{DateTime, SecondsFormat, Utc};
use chrono::{DateTime, Utc};
use rusqlite::{params, Connection};
use serde::Serialize;
use serde_json::{json, Map, Value};
@@ -21,7 +21,7 @@ use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipArchive, ZipWriter};
use super::models::{Note, NoteCreateInput};
use super::{derive, recur, store};
use super::{derive, iso, now, recur, store};
use crate::sync::blobs::BlobStore;
/// The `app` an export's notes.json carries. "thoughtsync" is what every export
@@ -128,7 +128,7 @@ pub fn export_zip(conn: &Connection, blobs: &BlobStore) -> Result<Vec<u8>, Strin
let doc = json!({
"app": "inkwell",
"version": 1,
"exported_at": Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
"exported_at": now(),
"labels": labels,
"notes": notes,
});
@@ -508,9 +508,7 @@ fn create_imported(
// Last, because adding an attachment touches the note: the source's own times are
// what this note should carry, not the moment it was imported.
let trashed_at = spec
.trashed
.then(|| Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true));
let trashed_at = spec.trashed.then(now);
conn.execute(
"UPDATE notes SET pinned = ?1, archived = ?2, remind_at = ?3, recurrence = ?4,
trashed = ?5, trashed_at = ?6,
@@ -561,16 +559,13 @@ fn strings(v: Option<&Value>) -> Vec<String> {
/// Any RFC 3339 instant, in the store's own form (UTC, milliseconds, `Z`). Anything
/// else is treated as absent, as the server's `parse_dt` does.
fn instant(raw: &str) -> Option<String> {
DateTime::parse_from_rfc3339(raw).ok().map(|t| {
t.with_timezone(&Utc)
.to_rfc3339_opts(SecondsFormat::Millis, true)
})
DateTime::parse_from_rfc3339(raw).ok().map(|t| iso(t.with_timezone(&Utc)))
}
/// Keep's timestamps: integer microseconds since the epoch.
fn usec_instant(v: &Value) -> Option<String> {
let usec = v.as_i64().or_else(|| v.as_str()?.parse().ok())?;
DateTime::from_timestamp_micros(usec).map(|t| t.to_rfc3339_opts(SecondsFormat::Millis, true))
DateTime::from_timestamp_micros(usec).map(iso)
}
/// The image types the server infers from a name when the source gave no mime.
+5 -9
View File
@@ -7,7 +7,7 @@
//! ("YYYY-MM-DDTHH:MM:SS.sssZ") so string ordering and date-range comparisons line
//! up with the values the frontend sends.
use chrono::{DateTime, Duration, SecondsFormat, Utc};
use chrono::{DateTime, Duration, Utc};
use rusqlite::{params, params_from_iter, Connection, OptionalExtension, Params};
use serde_json::{json, Value};
use uuid::Uuid;
@@ -15,10 +15,7 @@ use uuid::Uuid;
use crate::local::derive;
use crate::local::models::*;
use crate::local::recur;
fn now() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
}
use crate::local::{iso, now};
fn new_id() -> String {
Uuid::new_v4().to_string()
@@ -571,8 +568,7 @@ fn should_snapshot(conn: &Connection, id: &str, new_body: &str) -> rusqlite::Res
}
// String comparison, not date maths: timestamps are RFC3339 UTC with a fixed
// millisecond field (see the module header), so lexical order IS chronological.
let cutoff = (Utc::now() - Duration::minutes(REVISION_WINDOW_MINUTES))
.to_rfc3339_opts(SecondsFormat::Millis, true);
let cutoff = iso(Utc::now() - Duration::minutes(REVISION_WINDOW_MINUTES));
let recent: i64 = conn.query_row(
"SELECT COUNT(*) FROM note_revisions WHERE note_id = ?1 AND created_at >= ?2",
params![id, cutoff],
@@ -700,7 +696,7 @@ pub fn complete_reminder(conn: &Connection, id: &str) -> rusqlite::Result<Note>
match next {
Some(at) => conn.execute(
"UPDATE notes SET remind_at = ?1 WHERE id = ?2",
params![at.to_rfc3339_opts(SecondsFormat::Millis, true), id],
params![iso(at), id],
)?,
None => conn.execute(
"UPDATE notes SET remind_at = NULL, recurrence = NULL WHERE id = ?1",
@@ -719,7 +715,7 @@ pub fn snooze_reminder(conn: &Connection, id: &str, minutes: i64) -> rusqlite::R
require_owner(conn, id)?;
// 0 or less would set a reminder in the past; a huge value overflows `Duration`.
let minutes = minutes.clamp(1, SNOOZE_MAX_MINUTES);
let t = (Utc::now() + Duration::minutes(minutes)).to_rfc3339_opts(SecondsFormat::Millis, true);
let t = iso(Utc::now() + Duration::minutes(minutes));
conn.execute(
"UPDATE notes SET remind_at = ?1 WHERE id = ?2",
params![t, id],
+2 -4
View File
@@ -4,14 +4,13 @@
//! own inside this crate, but exposing them separately would let a caller pull
//! without pushing, which quietly overwrites unsent local edits.
use chrono::{SecondsFormat, Utc};
use serde::Serialize;
use super::blobs::BlobStore;
use super::pull;
use super::push;
use super::state;
use crate::local::Db;
use crate::local::{self, Db};
#[derive(Debug, Clone, Serialize)]
pub struct SyncOutcome {
@@ -102,8 +101,7 @@ pub async fn run_cycle(
}
// Stamped only here, after BOTH halves succeeded. A timestamp written after a
// partial cycle would tell the user they're up to date when they aren't.
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
state::mark_synced(&conn, &now).map_err(|e| e.to_string())?;
state::mark_synced(&conn, &local::now()).map_err(|e| e.to_string())?;
state::status(&conn).map_err(|e| e.to_string())?
};
+1 -6
View File
@@ -6,7 +6,6 @@
//! transaction** as the page it describes — a cursor committed ahead of its data
//! would silently skip those rows forever, which reads as a clean sync.
use chrono::{SecondsFormat, Utc};
use rusqlite::{params, Connection, OptionalExtension};
use serde::Serialize;
@@ -14,7 +13,7 @@ use super::blobs::BlobStore;
use super::client;
use super::state;
use super::wire;
use crate::local::Db;
use crate::local::{now, Db};
/// Backstop against a server that never stops saying `has_more`. At the server's
/// 1000-row page cap this is 10M rows — far past any real store, so hitting it means
@@ -108,10 +107,6 @@ async fn download_missing_blobs(
Ok((downloaded, failed))
}
fn now() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
}
/// Apply one page and advance the cursor, atomically.
///
/// Labels are applied before notes so a membership never references a label row that