diff --git a/README.md b/README.md index 5b39c48..9389594 100644 --- a/README.md +++ b/README.md @@ -840,7 +840,7 @@ sealed or not. | `DELETE` | `/api/sessions/{id}` | sign another browser out, and lock the vault | | `GET`/`POST` | `/api/spaces` | the spaces / create one | | `GET`/`POST` | `/api/nodes` | children (`?parent=`) or search (`?q=`, `?space=`; a file matched by its text has `in_text` and the words around the match as `excerpt`) / create, from a template with `template_id` | -| `GET`/`PUT`/`DELETE` | `/api/nodes/{id}` | read / update / delete into the trash, subtree and all — or for good, for something already in it | +| `GET`/`PUT`/`DELETE` | `/api/nodes/{id}` | read / update only the fields sent — `name`, `body`, `created_at`, `has_board`; with `base_updated_at`, refused with `409` once it was saved since / delete into the trash, subtree and all — or for good, for something already in it | | `GET`/`DELETE` | `/api/trash` | what was deleted, the last first / empty it | | `DELETE` | `/api/trash/{id}` | delete one thing in the trash for good | | `POST` | `/api/trash/{id}/restore` | back where it was deleted from, or to its space when that folder is gone | diff --git a/docs/wiki/API.md b/docs/wiki/API.md index b28c148..8aef60d 100644 --- a/docs/wiki/API.md +++ b/docs/wiki/API.md @@ -15,7 +15,7 @@ sealed or not. | `DELETE` | `/api/sessions/{id}` | sign another browser out, and lock the vault | | `GET`/`POST` | `/api/spaces` | the spaces / create one | | `GET`/`POST` | `/api/nodes` | children (`?parent=`) or search (`?q=`, `?space=`; a file matched by its text has `in_text` and the words around the match as `excerpt`) / create, from a template with `template_id` | -| `GET`/`PUT`/`DELETE` | `/api/nodes/{id}` | read / update / delete into the trash, subtree and all — or for good, for something already in it | +| `GET`/`PUT`/`DELETE` | `/api/nodes/{id}` | read / update only the fields sent — `name`, `body`, `created_at`, `has_board`; with `base_updated_at`, refused with `409` once it was saved since / delete into the trash, subtree and all — or for good, for something already in it | | `GET`/`DELETE` | `/api/trash` | what was deleted, the last first / empty it | | `DELETE` | `/api/trash/{id}` | delete one thing in the trash for good | | `POST` | `/api/trash/{id}/restore` | back where it was deleted from, or to its space when that folder is gone | diff --git a/src/routes/nodes.rs b/src/routes/nodes.rs index 03eb814..6f0b2e8 100644 --- a/src/routes/nodes.rs +++ b/src/routes/nodes.rs @@ -160,10 +160,12 @@ pub struct CreateInput { #[derive(Deserialize)] pub struct UpdateInput { + /// Left out, the name stays as it is: a write changes only what it sends, + /// so a rename cannot empty a document and a save cannot rename one. #[serde(default)] - pub name: String, + pub name: Option, #[serde(default)] - pub body: String, + pub body: Option, #[serde(default)] pub created_at: Option, /// Spaces only, and only when it is sent at all. @@ -713,12 +715,13 @@ pub async fn update( // here is still free when it is written, and a file's name and its // download name never disagree. let mut tx = crate::db::begin_write(&state.db).await?; - let row = - sqlx::query("SELECT parent_id, kind, name, slug, updated_at FROM nodes WHERE id = ?1") - .bind(id) - .fetch_optional(&mut *tx) - .await? - .ok_or(AppError::NotFound)?; + let row = sqlx::query( + "SELECT parent_id, kind, name, slug, body, updated_at FROM nodes WHERE id = ?1", + ) + .bind(id) + .fetch_optional(&mut *tx) + .await? + .ok_or(AppError::NotFound)?; if input .base_updated_at @@ -732,7 +735,11 @@ pub async fn update( let parent_id: Option = row.get("parent_id"); let kind: String = row.get("kind"); let previous_name: String = row.get("name"); - let name = input.name.trim(); + let previous_body: String = row.get("body"); + let name = input + .name + .as_deref() + .map_or(previous_name.as_str(), str::trim); // The slug follows the name, because a path nobody can read is no better // than an id. It only moves when the name does, so a link to an unrenamed @@ -752,7 +759,7 @@ pub async fn update( let body = if kind == FILE { "" } else { - input.body.as_str() + input.body.as_deref().unwrap_or(&previous_body) }; let reason = if input.restore { revisions::Reason::Restore diff --git a/src/routes/revisions.rs b/src/routes/revisions.rs index a5ed879..a9095b0 100644 --- a/src/routes/revisions.rs +++ b/src/routes/revisions.rs @@ -319,6 +319,54 @@ mod tests { ); } + /// A write changes what it sends and nothing else: a rename leaves the + /// text, and keeps no revision since none was lost. One that carries the + /// version it saw is refused once the document has moved on. + #[tokio::test] + async fn a_rename_leaves_the_text() { + use axum::extract::{Path, State}; + + let state = crate::routes::tests::state_in(".".into()).await; + let id = document(&state.db, "plan", "the text").await; + let update = |input: serde_json::Value| { + let state = state.clone(); + async move { + crate::routes::nodes::update( + crate::auth::Session { id: "test".into() }, + State(state), + Path(id), + axum::Json(serde_json::from_value(input).unwrap()), + ) + .await + } + }; + + let renamed = update(serde_json::json!({ "name": "the plan" })) + .await + .unwrap(); + assert_eq!( + (renamed.name.as_str(), renamed.body.as_str()), + ("the plan", "the text") + ); + let edited = update(serde_json::json!({ "body": "new text" })) + .await + .unwrap(); + assert_eq!( + (edited.name.as_str(), edited.body.as_str()), + ("the plan", "new text") + ); + + let stale = update(serde_json::json!({ "name": "late", "base_updated_at": 0 })).await; + assert!(matches!(stale, Err(crate::error::AppError::Conflict(_)))); + + let kept: Vec = sqlx::query_scalar("SELECT body FROM revisions WHERE node_id = ?1") + .bind(id) + .fetch_all(&state.db) + .await + .unwrap(); + assert_eq!(kept, ["the text"]); + } + /// Old revisions go, except the newest few of every document. #[tokio::test] async fn the_newest_stay_however_old() { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 0b10444..0acfdfa 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -478,6 +478,7 @@ export const api = { request<{ prev: DiaryEntry | null; next: DiaryEntry | null }>(`/diary/around/${id}`), /** + * Only what is sent changes: a rename sends the name alone. * `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. @@ -485,8 +486,8 @@ export const api = { updateNode: ( id: number, input: { - name: string - body: string + name?: string + body?: string created_at?: number has_board?: boolean base_updated_at?: number diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index 92297db..8a974a8 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -1163,19 +1163,16 @@ class Workspace { } /** - * Rename anything in the tree — a document, a folder or a space. The body - * has to ride along because the update is a whole-node write; for the node - * being edited that is the draft, which would otherwise be rolled back to - * whatever the server still holds. + * Rename anything in the tree — a document, a folder or a space. Only the + * name is sent, so the text on the server stays as it is and a draft still + * being typed is saved by autosave as before. When the document is open + * here, the rename carries the version this tab last saw: one saved + * somewhere else since is not stamped over, which would let the next + * autosave write the draft over that save without a word. */ async renameNode(id: number, name: string) { - const current = - this.open?.id === id ? { ...this.open, body: this.draft.body } : await api.getNode(id) - const saved = await api.updateNode(id, { - name: name.trim(), - body: current.body, - created_at: current.created_at, - }) + const open = this.tilesWith(id)[0]?.open + const saved = await api.updateNode(id, { name: name.trim(), base_updated_at: open?.updated_at }) for (const tile of this.tilesWith(id)) { tile.open = saved tile.draft.name = saved.name @@ -1712,7 +1709,7 @@ class Workspace { async setBoard(on: boolean) { const space = this.space if (!space) return - await api.updateNode(space.id, { name: space.name, body: '', has_board: on }) + await api.updateNode(space.id, { has_board: on }) this.spaces = await api.listSpaces() if (on) await this.openBoard() else {