diff --git a/.env.example b/.env.example index 01b70a3..da2c84c 100644 --- a/.env.example +++ b/.env.example @@ -108,3 +108,7 @@ WORKSPACE_VAULT_IDLE_MIN=15 # Days a deleted document, folder, file or space waits in the trash, where # :trash can bring it back, before it and its files are deleted for good. WORKSPACE_TRASH_DAYS=30 + +# Days a document's earlier versions are kept for :history. The newest 20 of +# every document are kept however old they are. +WORKSPACE_REVISION_DAYS=90 diff --git a/README.md b/README.md index 2b80473..74658c9 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ it; in the document pane it is that document. In the browse pane: | `]]` / `[[` | the next / previous heading of the open document, in the editor too | | `gO` | the document's headings as a list — `j`/`k`, `Enter` jumps there (`:outline`) | | `gx` | open the file under the cursor in a tab of its own | +| `gh` | the open document's earlier versions, as diffs — `j`/`k`, `Enter` brings one back (`:history`) | | `F` | a letter over every link and button in view; typing it follows it — links out of the workspace open in a tab of their own | | `Enter`, `l` | open the highlighted entry; on an open keystore or sealed file, `Enter` asks for its password | | `o` | a new document here, straight into insert mode | @@ -255,6 +256,21 @@ space and folder each match is in. A document made with `o` and left with neithe it is removed again on the way out, so abandoned starts do not pile up. If a tab dies mid-document, opening it again restores the draft and says so — `:e!` throws it away, the way vim handles a swap file. +A save keeps what it replaces. `gh` or `:history` lists the open document's +earlier versions, the newest first, and shows the highlighted one as a diff +against the text as it is now: `-` is what bringing it back would take out, +`+` what it would put back. `Enter` brings it back, and the text it replaces +becomes a version of its own, so a restore is undone the same way. Autosave +writes every few seconds, so an ordinary save keeps a version at most every +ten minutes; `:w!`, a save over the SFTP mount and a restore always keep one, +because each of them may undo something. A rename alone keeps nothing. The +daily sweep forgets versions older than `WORKSPACE_REVISION_DAYS` (90 by +default), but never the newest 20 of a document, and an upload stays as long +as a version still embeds it. A file that has been sealed since is gone from +the old version — sealing deletes the plaintext on purpose. Versions are not +in the export, the mount or the readable view on Proton Drive; they are in +`diary.db`, and so in its mirror. + A delete is not the end of anything. `x`, `dd`, `:delete` and `:rmspace` put what they delete in the trash, with everything inside it, and `u` puts the last of them back where it was — the same folder, the same name, unless @@ -674,6 +690,8 @@ sealed or not. | `POST` | `/api/nodes/{id}/import` | import a folder or zip (multipart) under this node | | `POST` | `/api/import` | import an export at the root: directories become spaces | | `GET` | `/api/resolve?path=` | the node behind a slug path, `work/notes/monday` | +| `GET` | `/api/nodes/{id}/revisions` | a document's earlier versions, the newest first | +| `GET` | `/api/nodes/{id}/revisions/{rev}` | one earlier version, with its text | | `POST`/`DELETE` | `/api/nodes/{id}/share` | mint or rotate / revoke a share link | | `GET` | `/api/export` | the whole workspace, boards and dates included, as a zip | | `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now | diff --git a/migrations/0020_revisions.sql b/migrations/0020_revisions.sql new file mode 100644 index 0000000..99a0ab6 --- /dev/null +++ b/migrations/0020_revisions.sql @@ -0,0 +1,15 @@ +-- What a document said before it was written over. A row holds the text that +-- was replaced, not the text that replaced it: the newest version is the +-- document itself. Autosave writes every few seconds, so an ordinary save +-- keeps one at most every ten minutes; `:w!`, a save over the mount and a +-- restore always keep one, because each of those may undo something. +CREATE TABLE revisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER NOT NULL REFERENCES nodes (id) ON DELETE CASCADE, + name TEXT NOT NULL, + body TEXT NOT NULL, + saved_at INTEGER NOT NULL, + reason TEXT NOT NULL CHECK (reason IN ('edit', 'force', 'sftp', 'restore')) +); + +CREATE INDEX idx_revisions_node ON revisions (node_id, saved_at DESC); diff --git a/src/config.rs b/src/config.rs index 67e0986..39c30fd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -24,6 +24,9 @@ pub struct Config { pub vault_idle: std::time::Duration, /// How many days a deleted node waits in the trash before it is purged. pub trash_days: i64, + /// How many days a document's earlier versions are kept, past the newest + /// few that always are. + pub revision_days: i64, pub backup: BackupConfig, pub mail: MailConfig, pub sftp: SftpConfig, @@ -44,6 +47,7 @@ impl std::fmt::Debug for Config { .field("secure_cookie", &self.secure_cookie) .field("vault_idle", &self.vault_idle) .field("trash_days", &self.trash_days) + .field("revision_days", &self.revision_days) .field("backup", &self.backup) .field("mail", &self.mail) .field("sftp", &self.sftp) @@ -293,6 +297,7 @@ impl Config { // A day at least, so an undo is never raced by the sweep; ten years // at most, as for the sessions. let trash_days = number("TRASH_DAYS", 30, 1..=3650)? as i64; + let revision_days = number("REVISION_DAYS", 90, 1..=3650)? as i64; let secure_cookie = flag("SECURE_COOKIE")?; Ok(Self { @@ -306,6 +311,7 @@ impl Config { secure_cookie, vault_idle, trash_days, + revision_days, backup: BackupConfig::from_env()?, mail: MailConfig::from_env()?, sftp: SftpConfig::from_env()?, @@ -344,6 +350,7 @@ impl Config { secure_cookie: false, vault_idle: std::time::Duration::from_secs(60), trash_days: 30, + revision_days: 90, backup: BackupConfig { device_name: "narl-diary".into(), interval: None, diff --git a/src/main.rs b/src/main.rs index d40e34a..82df2a8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -120,7 +120,11 @@ async fn main() -> Result<()> { let notify = notify::Notifier::new(db.clone(), Arc::clone(&config))?; notify::spawn(Arc::clone(¬ify)); - sweep::spawn(db.clone(), config.uploads_dir(), config.trash_days); + sweep::spawn( + db.clone(), + config.uploads_dir(), + sweep::Keep::from(&*config), + ); let epoch = auth::Epoch::load(&db, &config).await?; let sessions = Arc::new(auth::Sessions::load(&db).await?); diff --git a/src/routes/mod.rs b/src/routes/mod.rs index a7ece1c..a9748bb 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -6,6 +6,7 @@ pub mod inspect; pub mod manifest; pub mod media; pub mod nodes; +pub mod revisions; pub mod seal; pub mod session; pub mod share; @@ -69,6 +70,8 @@ pub fn api_router(max_upload: usize) -> Router { .route("/nodes/{id}", get(nodes::get)) .route("/nodes/{id}", put(nodes::update)) .route("/nodes/{id}", delete(nodes::remove)) + .route("/nodes/{id}/revisions", get(revisions::list)) + .route("/nodes/{id}/revisions/{rev}", get(revisions::get)) .route("/nodes/move", post(nodes::move_many)) .route("/nodes/{id}/move", post(nodes::move_node)) .route("/import", post(import::import_root).layer(uploads)) diff --git a/src/routes/nodes.rs b/src/routes/nodes.rs index 5d1f035..aefeb65 100644 --- a/src/routes/nodes.rs +++ b/src/routes/nodes.rs @@ -10,7 +10,7 @@ use sqlx::{Row, SqliteConnection, SqlitePool}; use crate::{ auth::Session, error::{AppError, AppResult}, - routes::{media, trash}, + routes::{media, revisions, trash}, state::AppState, }; @@ -162,6 +162,10 @@ pub struct UpdateInput { /// write goes through whatever happened in between. #[serde(default)] pub base_updated_at: Option, + /// The text sent is a revision brought back: what it replaces is kept + /// as a revision of its own, however recently the last one was. + #[serde(default)] + pub restore: bool, } #[derive(Deserialize)] @@ -702,6 +706,14 @@ pub async fn update( } else { input.body.as_str() }; + let reason = if input.restore { + revisions::Reason::Restore + } else if input.base_updated_at.is_none() { + revisions::Reason::Force + } else { + revisions::Reason::Edit + }; + revisions::keep(&mut tx, id, body, reason).await?; if kind == FILE { sqlx::query( "UPDATE media SET filename = ?1 WHERE id = (SELECT media_id FROM nodes WHERE id = ?2)", diff --git a/src/routes/revisions.rs b/src/routes/revisions.rs new file mode 100644 index 0000000..a5ed879 --- /dev/null +++ b/src/routes/revisions.rs @@ -0,0 +1,342 @@ +//! Revisions: what a document said before it was written over, so a `:w!` +//! that should not have been, a save over the mount, or a paragraph deleted +//! last week can be taken back. +//! +//! A revision is kept by the write that replaces the text, in the same +//! transaction, so there is never a save without the version it replaced. +//! The sweep forgets them after `WORKSPACE_REVISION_DAYS`, but always keeps +//! the newest few of every document. + +use axum::{ + extract::{Path, State}, + Json, +}; +use serde::Serialize; +use sqlx::{Row, SqliteConnection, SqlitePool}; + +use crate::{ + auth::Session, + error::{AppError, AppResult}, + routes::trash, + state::AppState, +}; + +/// Why a revision was kept. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Reason { + /// An ordinary save, autosave included. + Edit, + /// `:w!`: a write that did not check what it wrote over. + Force, + /// A save over the SFTP mount. + Sftp, + /// A revision brought back, replacing what was there. + Restore, +} + +impl Reason { + fn as_str(self) -> &'static str { + match self { + Reason::Edit => "edit", + Reason::Force => "force", + Reason::Sftp => "sftp", + Reason::Restore => "restore", + } + } +} + +/// Autosave writes every few seconds; an ordinary save keeps a revision only +/// when the newest one is at least this old. +const EDIT_GAP: i64 = 10 * 60; + +/// How many of a document's revisions the sweep never forgets, however old. +pub const ALWAYS_KEPT: i64 = 20; + +/// Keep what a document says now, before `new_body` replaces it. Called by +/// every write of a document's text, in its transaction. Returns whether a +/// revision was kept. +pub async fn keep( + db: &mut SqliteConnection, + id: i64, + new_body: &str, + reason: Reason, +) -> AppResult { + let Some(row) = sqlx::query("SELECT kind, name, body FROM nodes WHERE id = ?1") + .bind(id) + .fetch_optional(&mut *db) + .await? + else { + return Ok(false); + }; + let body: String = row.get("body"); + // Only text is worth keeping: a rename is not a loss, and an empty + // document has nothing to lose. + if row.get::("kind") != crate::routes::nodes::DOCUMENT + || body == new_body + || body.trim().is_empty() + { + return Ok(false); + } + + let now = crate::now(); + if reason == Reason::Edit { + let newest: Option = + sqlx::query_scalar("SELECT MAX(saved_at) FROM revisions WHERE node_id = ?1") + .bind(id) + .fetch_one(&mut *db) + .await?; + if newest.is_some_and(|at| now - at < EDIT_GAP) { + return Ok(false); + } + } + + sqlx::query( + "INSERT INTO revisions (node_id, name, body, saved_at, reason) + VALUES (?1, ?2, ?3, ?4, ?5)", + ) + .bind(id) + .bind(row.get::("name")) + .bind(&body) + .bind(now) + .bind(reason.as_str()) + .execute(&mut *db) + .await?; + Ok(true) +} + +/// Forget the revisions saved before `cutoff`, except the newest +/// `ALWAYS_KEPT` of each document. Returns how many went. +pub async fn forget_older(db: &SqlitePool, cutoff: i64) -> sqlx::Result { + let done = sqlx::query( + "DELETE FROM revisions + WHERE saved_at < ?1 + AND id NOT IN ( + SELECT r.id FROM revisions r + WHERE r.node_id = revisions.node_id + ORDER BY r.saved_at DESC, r.id DESC + LIMIT ?2 + )", + ) + .bind(cutoff) + .bind(ALWAYS_KEPT) + .execute(db) + .await?; + Ok(done.rows_affected()) +} + +#[derive(Serialize)] +pub struct RevisionSummary { + pub id: i64, + pub name: String, + pub saved_at: i64, + pub reason: String, + /// Characters of text. + pub size: i64, +} + +#[derive(Serialize)] +pub struct Revision { + pub id: i64, + pub name: String, + pub body: String, + pub saved_at: i64, + pub reason: String, +} + +/// A document in the trash has no history to read, as it has no text: both +/// come back with it. +async fn readable(state: &AppState, id: i64) -> AppResult<()> { + if trash::in_trash(&mut *state.db.acquire().await?, id).await? { + return Err(AppError::NotFound); + } + Ok(()) +} + +/// `GET /api/nodes/{id}/revisions` — the newest first. +pub async fn list( + _: Session, + State(state): State, + Path(id): Path, +) -> AppResult>> { + readable(&state, id).await?; + let rows = sqlx::query( + "SELECT id, name, saved_at, reason, length(body) AS size FROM revisions + WHERE node_id = ?1 ORDER BY saved_at DESC, id DESC", + ) + .bind(id) + .fetch_all(&state.db) + .await?; + Ok(Json( + rows.iter() + .map(|row| RevisionSummary { + id: row.get("id"), + name: row.get("name"), + saved_at: row.get("saved_at"), + reason: row.get("reason"), + size: row.get("size"), + }) + .collect(), + )) +} + +/// `GET /api/nodes/{id}/revisions/{rev}` — one, with its text. +pub async fn get( + _: Session, + State(state): State, + Path((id, rev)): Path<(i64, i64)>, +) -> AppResult> { + readable(&state, id).await?; + let row = sqlx::query( + "SELECT id, name, body, saved_at, reason FROM revisions WHERE id = ?1 AND node_id = ?2", + ) + .bind(rev) + .bind(id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + Ok(Json(Revision { + id: row.get("id"), + name: row.get("name"), + body: row.get("body"), + saved_at: row.get("saved_at"), + reason: row.get("reason"), + })) +} + +#[cfg(test)] +mod tests { + use super::{forget_older, keep, Reason, ALWAYS_KEPT}; + use sqlx::SqlitePool; + + async fn db() -> SqlitePool { + let db = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::migrate!("./migrations").run(&db).await.unwrap(); + db + } + + async fn document(db: &SqlitePool, slug: &str, body: &str) -> i64 { + sqlx::query_scalar( + "INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at) + VALUES ((SELECT id FROM nodes WHERE slug = 'work'), 'document', ?1, ?1, ?2, 0, 0) + RETURNING id", + ) + .bind(slug) + .bind(body) + .fetch_one(db) + .await + .unwrap() + } + + async fn write(db: &SqlitePool, id: i64, body: &str, reason: Reason) -> bool { + let mut conn = db.acquire().await.unwrap(); + let kept = keep(&mut conn, id, body, reason).await.unwrap(); + sqlx::query("UPDATE nodes SET body = ?1 WHERE id = ?2") + .bind(body) + .bind(id) + .execute(&mut *conn) + .await + .unwrap(); + kept + } + + async fn bodies(db: &SqlitePool, id: i64) -> Vec { + sqlx::query_scalar("SELECT body FROM revisions WHERE node_id = ?1 ORDER BY id") + .bind(id) + .fetch_all(db) + .await + .unwrap() + } + + /// Autosave keeps one revision per ten minutes; `:w!` and a save over + /// the mount always keep one; nothing is kept of an unchanged text or + /// of an empty one. + #[tokio::test] + async fn what_a_write_keeps() { + let db = db().await; + let id = document(&db, "plan", "").await; + assert!(!write(&db, id, "one", Reason::Edit).await); + assert!(write(&db, id, "two", Reason::Edit).await); + assert!(!write(&db, id, "three", Reason::Edit).await); + assert!(!write(&db, id, "three", Reason::Force).await); + assert!(write(&db, id, "four", Reason::Force).await); + assert!(write(&db, id, "five", Reason::Sftp).await); + assert_eq!(bodies(&db, id).await, ["one", "three", "four"]); + + sqlx::query("UPDATE revisions SET saved_at = saved_at - 3600") + .execute(&db) + .await + .unwrap(); + assert!(write(&db, id, "six", Reason::Edit).await); + } + + /// Through the API: a save that checked what it wrote over keeps one + /// revision per ten minutes, `:w!` and a restore always keep one. + #[tokio::test] + async fn saves_keep_what_they_replace() { + use axum::extract::{Path, State}; + + let state = crate::routes::tests::state_in(".".into()).await; + let id = document(&state.db, "plan", "first").await; + let save = |body: &'static str, base: bool, restore: bool| { + let state = state.clone(); + async move { + let input = serde_json::from_value(serde_json::json!({ + "name": "plan", + "body": body, + "base_updated_at": base.then_some(i64::MAX), + "restore": restore, + })) + .unwrap(); + let _saved = crate::routes::nodes::update( + crate::auth::Session { id: "test".into() }, + State(state), + Path(id), + axum::Json(input), + ) + .await + .unwrap(); + } + }; + save("second", true, false).await; + save("third", true, false).await; + save("fourth", false, false).await; + save("first", true, true).await; + + let kept: Vec<(String, String)> = + sqlx::query_as("SELECT body, reason FROM revisions WHERE node_id = ?1 ORDER BY id") + .bind(id) + .fetch_all(&state.db) + .await + .unwrap(); + let kept: Vec<(&str, &str)> = kept.iter().map(|(b, r)| (b.as_str(), r.as_str())).collect(); + assert_eq!( + kept, + [("first", "edit"), ("third", "force"), ("fourth", "restore")] + ); + } + + /// Old revisions go, except the newest few of every document. + #[tokio::test] + async fn the_newest_stay_however_old() { + let db = db().await; + let busy = document(&db, "busy", "start").await; + let quiet = document(&db, "quiet", "start").await; + for n in 0..ALWAYS_KEPT + 5 { + write(&db, busy, &format!("busy {n}"), Reason::Force).await; + } + write(&db, quiet, "only", Reason::Force).await; + sqlx::query("UPDATE revisions SET saved_at = id") + .execute(&db) + .await + .unwrap(); + + assert_eq!(forget_older(&db, i64::MAX).await.unwrap(), 5); + assert_eq!(bodies(&db, busy).await.len() as i64, ALWAYS_KEPT); + assert_eq!(bodies(&db, busy).await[0], "busy 4"); + assert_eq!(bodies(&db, quiet).await, ["start"]); + } +} diff --git a/src/sftp/fs.rs b/src/sftp/fs.rs index e612fd2..a99aeda 100644 --- a/src/sftp/fs.rs +++ b/src/sftp/fs.rs @@ -35,7 +35,7 @@ use crate::{ Backup, }, error::{AppError, AppResult}, - routes::{media, nodes, seal, trash}, + routes::{media, nodes, revisions, seal, trash}, state::AppState, vault, }; @@ -757,6 +757,14 @@ impl SpaceFs { let (title, body) = split_heading(&text); let now = crate::now(); self.own_writes.insert(id, now); + // One transaction, so the text this replaces is kept as a revision + // exactly when it is replaced. + let mut tx = crate::db::begin_write(&self.db) + .await + .map_err(|_| StatusCode::Failure)?; + revisions::keep(&mut tx, id, &body, revisions::Reason::Sftp) + .await + .map_err(|_| StatusCode::Failure)?; sqlx::query( "UPDATE nodes SET name = COALESCE(?1, name), body = ?2, updated_at = ?3 WHERE id = ?4", ) @@ -764,12 +772,13 @@ impl SpaceFs { .bind(&body) .bind(now) .bind(id) - .execute(&self.db) + .execute(&mut *tx) .await .map_err(|_| StatusCode::Failure)?; - media::link_to_node(&mut *self.conn().await?, id, &body) + media::link_to_node(&mut tx, id, &body) .await .map_err(|_| StatusCode::Failure)?; + tx.commit().await.map_err(|_| StatusCode::Failure)?; self.changed(); Ok(()) } @@ -2483,6 +2492,15 @@ mod tests { (id, "Renamed", "v2\n") ); assert!(fs.scratch.is_empty()); + + // The save over the mount kept what it replaced. + let kept: Vec<(String, String)> = + sqlx::query_as("SELECT body, reason FROM revisions WHERE node_id = ?1 ORDER BY id") + .bind(id) + .fetch_all(&fs.db) + .await + .expect("revisions"); + assert_eq!(kept, [("v1\n".to_string(), "sftp".to_string())]); } #[tokio::test] diff --git a/src/sweep.rs b/src/sweep.rs index 291c577..9e1efd6 100644 --- a/src/sweep.rs +++ b/src/sweep.rs @@ -1,7 +1,8 @@ //! Housekeeping for `uploads/`: what a crash, a killed upload or a forgotten //! embed leaves behind. Nothing else ever deletes these, so without this they //! would only grow — on the disk and in every backup of it. The trash is -//! emptied here too, of what has waited in it long enough. +//! emptied here too, of what has waited in it long enough, and the revisions +//! of documents are thinned out. use std::{collections::HashSet, path::Path, time::Duration}; @@ -19,22 +20,39 @@ const UNREFERENCED_DAYS: i64 = 7; const EVERY: Duration = Duration::from_secs(24 * 60 * 60); -pub fn spawn(db: SqlitePool, uploads: std::path::PathBuf, trash_days: i64) { +/// How long what the sweep thins out is kept, in days. +#[derive(Clone, Copy)] +pub struct Keep { + pub trash_days: i64, + pub revision_days: i64, +} + +impl From<&crate::config::Config> for Keep { + fn from(config: &crate::config::Config) -> Self { + Self { + trash_days: config.trash_days, + revision_days: config.revision_days, + } + } +} + +pub fn spawn(db: SqlitePool, uploads: std::path::PathBuf, keep: Keep) { crate::tasks::supervise("the uploads sweep", move || { let (db, uploads) = (db.clone(), uploads.clone()); - sweep_forever(db, uploads, trash_days) + sweep_forever(db, uploads, keep) }); } -async fn sweep_forever(db: SqlitePool, uploads: std::path::PathBuf, trash_days: i64) { +async fn sweep_forever(db: SqlitePool, uploads: std::path::PathBuf, keep: Keep) { { let mut ticker = tokio::time::interval(EVERY); loop { ticker.tick().await; - match run(&db, &uploads, trash_days).await { + match run(&db, &uploads, keep).await { Ok(swept) if swept.any() => tracing::info!( - "swept: {} out of the trash; uploads: {} partial, {} without a row, {} that nothing embeds", + "swept: {} out of the trash, {} old revisions; uploads: {} partial, {} without a row, {} that nothing embeds", swept.trashed, + swept.revisions, swept.partial, swept.stray, swept.unreferenced @@ -50,6 +68,8 @@ async fn sweep_forever(db: SqlitePool, uploads: std::path::PathBuf, trash_days: pub struct Swept { /// Things deleted long enough ago to be purged from the trash. pub trashed: usize, + /// Revisions older than they are kept for. + pub revisions: u64, pub partial: usize, pub stray: usize, pub unreferenced: usize, @@ -57,19 +77,23 @@ pub struct Swept { impl Swept { fn any(&self) -> bool { - self.trashed + self.partial + self.stray + self.unreferenced > 0 + self.trashed + self.revisions as usize + self.partial + self.stray + self.unreferenced > 0 } } -pub async fn run(db: &SqlitePool, uploads: &Path, trash_days: i64) -> anyhow::Result { - // First, so the files only the purged nodes held are released and go in - // this same pass. +pub async fn run(db: &SqlitePool, uploads: &Path, keep: Keep) -> anyhow::Result { + // First, so the files only the purged nodes and the forgotten revisions + // held are released and go in this same pass. let trashed = - crate::routes::trash::purge_older(db, uploads, crate::now() - trash_days * 86_400) + crate::routes::trash::purge_older(db, uploads, crate::now() - keep.trash_days * 86_400) .await .map_err(|e| anyhow::anyhow!("emptying the trash: {e:?}"))?; + let revisions = + crate::routes::revisions::forget_older(db, crate::now() - keep.revision_days * 86_400) + .await?; let mut swept = Swept { trashed, + revisions, ..Swept::default() }; @@ -89,7 +113,9 @@ pub async fn run(db: &SqlitePool, uploads: &Path, trash_days: i64) -> anyhow::Re -- Belt and braces: a body or a card that names the file keeps it, -- whatever the link table says. AND NOT EXISTS (SELECT 1 FROM nodes n WHERE instr(n.body, m.id) > 0) - AND NOT EXISTS (SELECT 1 FROM cards c WHERE instr(c.body, m.id) > 0)", + AND NOT EXISTS (SELECT 1 FROM cards c WHERE instr(c.body, m.id) > 0) + -- An earlier version of a document brings its pictures back with it. + AND NOT EXISTS (SELECT 1 FROM revisions r WHERE instr(r.body, m.id) > 0)", ) .bind(cutoff) .fetch_all(db) @@ -141,7 +167,7 @@ pub async fn run(db: &SqlitePool, uploads: &Path, trash_days: i64) -> anyhow::Re #[cfg(test)] mod tests { - use super::{run, Swept}; + use super::{run, Keep, Swept}; #[tokio::test] async fn only_what_nothing_knows_about_goes() { @@ -158,7 +184,8 @@ mod tests { let embedded = "22222222-2222-4222-8222-222222222222"; let forgotten = "33333333-3333-4333-8333-333333333333"; let stray = "44444444-4444-4444-8444-444444444444"; - for id in [kept, embedded, forgotten] { + let remembered = "66666666-6666-4666-8666-666666666666"; + for id in [kept, embedded, forgotten, remembered] { sqlx::query("INSERT INTO media (id, filename, mime, size, created_at) VALUES (?1, 'x', 'image/png', 1, ?2)") .bind(id) .bind(old) @@ -187,9 +214,26 @@ mod tests { .execute(&db) .await .unwrap(); + // `remembered` only by an earlier version of that document. + sqlx::query( + "INSERT INTO revisions (node_id, name, body, saved_at, reason) + SELECT id, 'a', ?1, 0, 'edit' FROM nodes WHERE slug = 'a'", + ) + .bind(format!("![](/api/media/{remembered})")) + .execute(&db) + .await + .unwrap(); let hour_ago = std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 60 * 60); - for name in [kept, embedded, forgotten, stray, "x.part", "notes.txt"] { + for name in [ + kept, + embedded, + remembered, + forgotten, + stray, + "x.part", + "notes.txt", + ] { let path = dir.path().join(name); std::fs::write(&path, b"x").unwrap(); std::fs::File::options() @@ -202,11 +246,21 @@ mod tests { let fresh = "55555555-5555-4555-8555-555555555555.part"; std::fs::write(dir.path().join(fresh), b"x").unwrap(); - let swept = run(&db, dir.path(), 30).await.unwrap(); + let swept = run( + &db, + dir.path(), + Keep { + trash_days: 30, + revision_days: 90, + }, + ) + .await + .unwrap(); assert_eq!( swept, Swept { trashed: 0, + revisions: 0, partial: 1, stray: 2, unreferenced: 1 @@ -217,10 +271,11 @@ mod tests { .unwrap() .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) .collect(); - let expected: std::collections::BTreeSet = [kept, embedded, "notes.txt", fresh] - .iter() - .map(|s| s.to_string()) - .collect(); + let expected: std::collections::BTreeSet = + [kept, embedded, remembered, "notes.txt", fresh] + .iter() + .map(|s| s.to_string()) + .collect(); assert_eq!(left, expected); } } diff --git a/web/src/App.svelte b/web/src/App.svelte index d706336..e5d2d03 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -15,6 +15,7 @@ import Overview from './lib/Overview.svelte' import MediaBrowser, { copyMedia, insertMedia, removeMedia } from './lib/MediaBrowser.svelte' import Trash, { purgeTrashed, restoreTrashed } from './lib/Trash.svelte' + import History, { moveHistory, restoreShown } from './lib/History.svelte' import Actions from './lib/Actions.svelte' import Prompt from './lib/Prompt.svelte' import SecretPrompt from './lib/SecretPrompt.svelte' @@ -421,6 +422,28 @@ } } + /** The history's keys: j/k through the versions, Enter brings the one shown back. */ + function historyKey(event: KeyboardEvent): boolean { + switch (event.key) { + case 'j': + case 'ArrowDown': + moveHistory(1) + return true + case 'k': + case 'ArrowUp': + moveHistory(-1) + return true + case 'G': + moveHistory(workspace.history.length) + return true + case 'Enter': + void restoreShown() + return true + default: + return false + } + } + /* * `F`: the hints are pinned where the targets are, so a pane still sliding * in — a space switch, the board just opened — is let settle first. @@ -608,6 +631,12 @@ return } } + if (workspace.overlay === 'history' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) { + if (historyKey(event)) { + event.preventDefault() + return + } + } if (workspace.overlay === 'trash' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) { if (trashKey(event)) { event.preventDefault() @@ -759,6 +788,11 @@ void runCommand('outline') return } + if (previous === 'g' && key === 'h') { + event.preventDefault() + void runCommand('history') + return + } if (previous === 'g' && key === 'x') { event.preventDefault() openInTab() @@ -1041,7 +1075,7 @@
e.target === e.currentTarget && (workspace.overlay = 'none')} > - {#if workspace.overlay === 'help'}{:else if workspace.overlay === 'overview'}{:else if workspace.overlay === 'agenda'}{:else if workspace.overlay === 'actions'}{:else if workspace.overlay === 'verify'}{:else if workspace.overlay === 'outline'}{:else if workspace.overlay === 'trash'}{:else}{/if} + {#if workspace.overlay === 'help'}{:else if workspace.overlay === 'overview'}{:else if workspace.overlay === 'agenda'}{:else if workspace.overlay === 'actions'}{:else if workspace.overlay === 'verify'}{:else if workspace.overlay === 'outline'}{:else if workspace.overlay === 'trash'}{:else if workspace.overlay === 'history'}{:else}{/if}
{/if} diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index 4a5f276..632d6de 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -43,6 +43,7 @@ ['y', 'mint a new share link and copy it — asks, the old one stops working'], ['x · dd', 'delete what has focus — the row in the list, the document in its pane — into the trash (asks y/N)'], ['u', 'undo the last delete: it comes back out of the trash — :trash has the rest'], + ['gh', "the open document's earlier versions, as diffs — Enter brings one back"], [':', 'command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before'], ['Ctrl-S', 'save, from anywhere'], ['?', 'this screen — in the editor too'], diff --git a/web/src/lib/History.svelte b/web/src/lib/History.svelte new file mode 100644 index 0000000..3f9309e --- /dev/null +++ b/web/src/lib/History.svelte @@ -0,0 +1,164 @@ + + + + +
+
+ + history + {workspace.open?.name.trim() || workspace.open?.slug} · {workspace.history.length} earlier + + {#if workspace.revision} + + {/if} + +
+ +
+
    + {#each workspace.history as summary, index (summary.id)} + +
  • workspace.guard(() => workspace.showRevision(index))}> + {formatStamp(summary.saved_at)} + {relative(summary.saved_at)} · {reason(summary)} · {summary.size} chars +
  • + {:else} +
  • no earlier versions yet — one is kept when a save replaces the text.
  • + {/each} +
+ +
+ {#if workspace.revision} + {#if workspace.revision.name !== workspace.draft.name} +
- # {workspace.draft.name}
+
+ # {workspace.revision.name}
+ {/if} + {#each rows as row, index (index)} + {#if row.kind === 'fold'} +
··· {row.count} unchanged
+ {:else} +
{row.kind === 'gone' ? '-' : row.kind === 'added' ? '+' : ' '} {row.text}
+ {/if} + {/each} + {#if !changed} +
the text is the same as now
+ {/if} + {/if} +
+
+ +
+ jk earlier / later + + enter restore + + - goes, + comes back + + qesc close +
+
+ + diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index bac7b54..a5703c5 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -78,6 +78,21 @@ export interface SshKey { vault: boolean } +/** What a document said before a save replaced it. */ +export interface RevisionSummary { + id: number + name: string + saved_at: number + /** `edit`, `force` (`:w!`), `sftp`, or `restore`. */ + reason: string + /** Characters of text. */ + size: number +} + +export interface Revision extends Omit { + body: string +} + /** Something deleted, waiting in the trash to be restored or purged. */ export interface TrashItem { id: number @@ -392,12 +407,28 @@ export const api = { created_at?: number }) => request('/nodes', { method: 'POST', body: body(input) }), - /** `base_updated_at`: refused with 409 if the node was saved since then. */ + /** + * `base_updated_at`: refused with 409 if the node was saved since then. + * `restore`: the text is a revision brought back, so what it replaces is + * kept as a revision whatever the last one was. + */ updateNode: ( id: number, - input: { name: string; body: string; created_at?: number; has_board?: boolean; base_updated_at?: number }, + input: { + name: string + body: string + created_at?: number + has_board?: boolean + base_updated_at?: number + restore?: boolean + }, ) => request(`/nodes/${id}`, { method: 'PUT', body: body(input) }), + /** A document's earlier versions, the newest first. */ + revisions: (id: number) => request(`/nodes/${id}/revisions`), + + revision: (id: number, rev: number) => request(`/nodes/${id}/revisions/${rev}`), + /** Into the trash; `purged` when it was in the trash already and is now gone. */ deleteNode: (id: number) => request<{ ok: true; removed: number; purged: boolean }>(`/nodes/${id}`, { method: 'DELETE' }), diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 2fb33d8..e0348b1 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -776,6 +776,19 @@ export const commands: CommandSpec[] = [ await workspace.guard(() => workspace.deleteSpace(space.id)) }, }, + { + name: 'history', + aliases: ['hist'], + group: 'documents', + help: "the open document's earlier versions as diffs — j/k, Enter brings one back (gh)", + run: async () => { + if (!requireOpen()) return + await workspace.guard(async () => { + await workspace.loadHistory() + workspace.overlay = 'history' + }) + }, + }, { name: 'trash', args: '[purge]', diff --git a/web/src/lib/diff.ts b/web/src/lib/diff.ts new file mode 100644 index 0000000..372435d --- /dev/null +++ b/web/src/lib/diff.ts @@ -0,0 +1,87 @@ +/** One line of a diff: kept, only in the old text, or only in the new. */ +export type DiffLine = { kind: 'same' | 'gone' | 'added'; text: string } + +/** A run of unchanged lines too long to be worth showing, folded to a count. */ +export type DiffRow = DiffLine | { kind: 'fold'; count: number } + +/* + * Past this many lines in the part that differs, the table a longest common + * subsequence needs would be tens of megabytes; the change is shown as the + * old lines out and the new ones in instead. Documents are rarely near it. + */ +const MAX_CELLS = 4_000_000 + +/** Lines of `before` and `after`, matched up the way `diff` would. */ +export function diffLines(before: string, after: string): DiffLine[] { + const a = before.split('\n') + const b = after.split('\n') + + // What both start and end with needs no table. + let start = 0 + while (start < a.length && start < b.length && a[start] === b[start]) start++ + let end = 0 + while (end < a.length - start && end < b.length - start && a[a.length - 1 - end] === b[b.length - 1 - end]) end++ + + const head = a.slice(0, start).map((text): DiffLine => ({ kind: 'same', text })) + const tail = a.slice(a.length - end).map((text): DiffLine => ({ kind: 'same', text })) + const x = a.slice(start, a.length - end) + const y = b.slice(start, b.length - end) + + if (x.length * y.length > MAX_CELLS) { + return [ + ...head, + ...x.map((text): DiffLine => ({ kind: 'gone', text })), + ...y.map((text): DiffLine => ({ kind: 'added', text })), + ...tail, + ] + } + + // lcs[i][j]: the longest common subsequence of x[i..] and y[j..]. + const width = y.length + 1 + const lcs = new Uint32Array((x.length + 1) * width) + for (let i = x.length - 1; i >= 0; i--) { + for (let j = y.length - 1; j >= 0; j--) { + lcs[i * width + j] = + x[i] === y[j] ? lcs[(i + 1) * width + j + 1] + 1 : Math.max(lcs[(i + 1) * width + j], lcs[i * width + j + 1]) + } + } + + const middle: DiffLine[] = [] + let i = 0 + let j = 0 + while (i < x.length && j < y.length) { + if (x[i] === y[j]) { + middle.push({ kind: 'same', text: x[i] }) + i++ + j++ + } else if (lcs[(i + 1) * width + j] >= lcs[i * width + j + 1]) { + middle.push({ kind: 'gone', text: x[i++] }) + } else { + middle.push({ kind: 'added', text: y[j++] }) + } + } + while (i < x.length) middle.push({ kind: 'gone', text: x[i++] }) + while (j < y.length) middle.push({ kind: 'added', text: y[j++] }) + + return [...head, ...middle, ...tail] +} + +/** The diff with only `context` unchanged lines kept around each change. */ +export function folded(lines: DiffLine[], context = 3): DiffRow[] { + const near = new Uint8Array(lines.length) + lines.forEach((line, index) => { + if (line.kind === 'same') return + for (let k = Math.max(index - context, 0); k <= Math.min(index + context, lines.length - 1); k++) near[k] = 1 + }) + const rows: DiffRow[] = [] + let skipped = 0 + lines.forEach((line, index) => { + if (near[index]) { + if (skipped) rows.push({ kind: 'fold', count: skipped }) + skipped = 0 + rows.push(line) + } else skipped++ + }) + if (skipped) rows.push({ kind: 'fold', count: skipped }) + return rows +} diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index 6971d45..777fcce 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -11,11 +11,13 @@ import { type WorkspaceNode, type MediaFile, type NodeSummary, + type Revision, + type RevisionSummary, type SpaceOverview, type TrashItem, type VaultStatus, } from './api' -import { formatBytes, isTouchDevice, sealedNote, type Heading } from './util' +import { formatBytes, formatStamp, isTouchDevice, sealedNote, type Heading } from './util' export type Pane = 'list' | 'document' /** The tree of documents, or the board of the space being browsed. */ @@ -279,7 +281,7 @@ class Workspace { pane = $state('list') flash = $state(null) - overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline' | 'trash'>('none') + overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline' | 'trash' | 'history'>('none') /** `gO`: the headings of what is on screen, to jump between from the keyboard. */ outline = $state.raw([]) outlineCursor = $state(0) @@ -311,6 +313,10 @@ class Workspace { trashCursor = $state(0) /** The last thing deleted here, which `u` puts back. */ lastDeleted = $state(null) + /** `:history`: the open document's earlier versions, and the one shown. */ + history = $state([]) + historyCursor = $state(0) + revision = $state.raw(null) overview = $state([]) overviewCursor = $state(0) @@ -961,7 +967,7 @@ class Workspace { * since it was opened here — another tab, the phone, the SFTP mount — * unless `force`, which is `:w!`. */ - async save({ quiet = false, force = false, tile = this.tile } = {}) { + async save({ quiet = false, force = false, restore = false, tile = this.tile } = {}) { if (!tile.open) return // Snapshot what goes over the wire: an autosave races the typing that // triggered it, and anything typed since must stay marked unsaved. @@ -969,6 +975,7 @@ class Workspace { const saved = await api.updateNode(tile.open.id, { ...sent, base_updated_at: force ? undefined : tile.open.updated_at, + restore, }) tile.open = saved @@ -1048,6 +1055,47 @@ class Workspace { if (this.overlay === 'trash') await this.loadTrash() } + /** `:history`: the open document's earlier versions, the newest first. */ + async loadHistory() { + const open = this.open + if (!open) return + // What is typed and not yet written is part of what a revision is + // compared with, and of what a restore must not lose. + await this.flushTile() + this.history = await api.revisions(open.id) + this.historyCursor = 0 + this.revision = null + await this.showRevision(0) + } + + /** Read the revision at `index`, for the overlay to compare with the draft. */ + async showRevision(index: number) { + const open = this.open + const summary = this.history[index] + this.historyCursor = index + if (!open || !summary) return + const revision = await api.revision(open.id, summary.id) + // A later j may have moved on while this one was being read. + if (this.history[this.historyCursor]?.id === revision.id) this.revision = revision + } + + /** + * Bring a revision back: it becomes the document again, and what it + * replaces is kept as a revision of its own, so a restore is undone the + * same way. The editor reads the draft when it opens, so this leaves it. + */ + async restoreRevision(revision: Revision) { + const tile = this.tile + if (!tile.open) return + await this.flushTile(tile) + tile.editing = false + tile.draft = { ...tile.draft, name: revision.name, body: revision.body } + tile.dirty = true + await this.save({ quiet: true, restore: true, tile }) + this.overlay = 'none' + this.say(`restored the version from ${formatStamp(revision.saved_at)} — :history has the one it replaced`) + } + async loadTrash() { this.trash = await api.trash() this.trashCursor = Math.min(this.trashCursor, Math.max(this.trash.length - 1, 0))