trash: a delete can be undone

`x`, `dd`, `:delete` and `:rmspace` no longer end anything. What they
delete is moved, whole, under one hidden root, and `u` puts the last of it
back where it was. `:trash` lists everything deleted, the last first:
Enter or `r` restores, `x` deletes one for good and `:trash purge` empties
it, each asking first. Something whose folder has gone since comes back to
the root of its space; something whose space is in the trash too waits for
the space. The daily sweep purges what has waited WORKSPACE_TRASH_DAYS (30),
and only then are its files deleted.

While it waits, a trashed node is out of the tree, the search, the agenda,
the mails, the SFTP mount, the export and the readable files/ view, and a
shared document answers 404 until it comes back with the same link. `rm`
over the mount trashes too; a rename onto a taken name still replaces what
was there, so editor saves do not fill the trash.
This commit is contained in:
nvrl committed 2026-10-20 18:15:22 +02:00
1 parent 439d5b083b
commit 8ffad6350f
22 files changed
+1154 -78

No files matched your search

+4
View File
@@ -104,3 +104,7 @@ WORKSPACE_MAIL_KINDS=reminder,card_due,digest,login
# Minutes the vault stays open with nothing using it, before it locks itself.
# It also locks on logout and whenever the server restarts.
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
+32 -6
View File
@@ -103,7 +103,8 @@ it; in the document pane it is that document. In the browse pane:
| `Ctrl-P` | full-text search across every space; `Enter` goes to the match |
| `n` / `N` | the next / previous search result, opened beside the results; past the last it starts again at the first |
| `s` / `y` | toggle sharing / mint a new share link — either asks first when it would cut readers off |
| `x`, `dd` | delete the entry under the cursor (asks `y`/`N`) |
| `x`, `dd` | delete the entry under the cursor into the trash (asks `y`/`N`) |
| `u` | undo the last delete: it comes back out of the trash to where it was |
| `:` | command line — `Tab` / `Shift-Tab` cycle the matching commands, then the argument (folders for `:cd` and `:mv`, spaces for `:space`, options for `:set` …); `↑` / `↓` recall the lines run before that start as this one does |
| `Ctrl-S` | save from anywhere |
| `?` | help |
@@ -118,11 +119,11 @@ headings as they do in the reader. `gf` follows the link under the cursor, savin
first, and `gx` opens it in a new tab. The rest: `:today` opens today's entry, `:edit [id]` (or `:12`)
opens a node by id, `:next`, `:prev`, `:first`, `:last` and `:random` walk the
list (`:bn` and `:bp` walk the tiles), `:delete [id]` deletes, `:copy` puts the whole document on the clipboard,
`:clear` drops the search, `:reload` re-reads the folder, `:stats` counts words
`:clear` drops the search, `:trash` lists what was deleted, `:reload` re-reads the folder, `:stats` counts words
and lines, `:search!` searches every space, `:set clipboard` / `:set
noclipboard` mirrors vim yanks to the system clipboard, `:logout` ends the
session, `:logout!` every session and `:sessions rm <n>` one other. The tree has its own: `:space [name]` switches (`:space! <name>`
makes one, `:rmspace [name]` deletes one and everything in it), `:mkdir <name>`
makes one, `:rmspace [name]` puts one in the trash with everything in it), `:mkdir <name>`
makes a folder, `:cd <name|..|/>` walks it and `:mv <folder|..|/>` moves what is
selected, and `:import` uploads a folder into the one being browsed
(`:import!` takes a `.zip` instead). `:upload` outside the editor puts files
@@ -137,7 +138,7 @@ the same actions are buttons: the `⋯` on a row — or a long press — opens w
the keys do to it: rename, move, share, seal, delete. A move is picked up
there and dropped with the button the dock then shows, in whichever folder
is open. Inside a folder the list starts with `..`, the way up. The dock also
has today's entry, an upload into the folder, a search across every space,
has today's entry, an upload into the folder, the trash, a search across every space,
and on the board the card moves, renames and deletes; the agenda has a done
button per card.
@@ -254,6 +255,24 @@ 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 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
a new one took the name meanwhile. `:trash` lists everything in it, the last
deleted first: `j`/`k` walk it, `Enter` or `r` restores, `x` deletes one for
good and `:trash purge` empties it, each asking first. Something whose folder
has gone since comes back to the root of its space, and something whose space
is in the trash too waits until the space is restored. What has been in the
trash for `WORKSPACE_TRASH_DAYS` (30 by default) is deleted for good by the
daily sweep, files and all.
While it waits, a node in the trash is out of sight: not in the tree, the
search, the agenda, the mails, the SFTP mount, the export or the readable
`files/` view on Proton Drive. A card whose notes are in the trash keeps
pointing at them and finds them again after a restore, and a shared document
answers like an unknown link until it is restored — with the same link its
readers already hold.
`:export` downloads the open document as markdown. `:export!` downloads the
whole workspace as a zip: the tree as real directories, empty folders too, one
markdown file per document, every file in the tree where it sits and every
@@ -272,7 +291,7 @@ An upload that nothing embeds any more — pasted, then deleted from the
document — is kept a week and then removed, along with what a killed upload
or a crash leaves in `uploads/`. That runs at start and once a day. A file in
the tree is never one of them: it is deleted from its folder, not from
`:media`.
`:media`, and its bytes stay on the disk until it leaves the trash.
`:media` lists every upload: `j`/`k` walk it, `i` or `Enter` embeds the
highlighted one at the cursor while writing, `y` copies its address and `x`
@@ -322,6 +341,10 @@ Nothing done over the mount holds the vault open; only the browser does. A
session holds at most 256 handles and 64 MB in memory, the server at most 32
connections, and one whose client has stopped answering is dropped within a
few minutes.
`rm` and `rmdir` over the mount put things in the trash, as a delete in
the browser does. A rename onto a name that is taken replaces what was
there, as `rename(2)` does, without keeping it in the trash: editors and
`rsync` save that way, and the trash would fill up with every save.
Editor droppings — swap files, `~` backups, dotted temp files — stay in
memory for the session, and a dotted temp file renamed onto a real name is kept
as that file, which is how `rsync` and most editors save.
@@ -635,7 +658,10 @@ 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=`) / create |
| `GET`/`PUT`/`DELETE` | `/api/nodes/{id}` | read / update / delete (a delete takes the subtree) |
| `GET`/`PUT`/`DELETE` | `/api/nodes/{id}` | read / update / 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 |
| `GET` | `/api/spaces/{id}/board` | the lists and cards of one space |
| `GET` | `/api/agenda` | open cards due within a week or in hand, on every board, and whether the diary has today |
| `POST` | `/api/spaces/{id}/lists` | add a list |
+22
View File
@@ -0,0 +1,22 @@
-- A delete puts a node in the trash rather than ending it. The trash is one
-- hidden root: a folder, not a space, so nothing that lists the spaces or
-- walks down from them ever reaches it, and a trashed node — moved under it
-- whole, with everything inside — drops out of the tree, the search, the
-- mount, the export and the readable mirror at once. The slug starts with a
-- dot, which no name ever slugifies to, so no space can take it.
INSERT INTO nodes (parent_id, kind, name, slug, role, created_at, updated_at)
VALUES (NULL, 'folder', 'trash', '.trash', 'trash', strftime('%s', 'now'), strftime('%s', 'now'));
-- One row per thing deleted, not per node under it: a folder comes back with
-- what was in it. Where it came from is kept by id, to go back to, and as the
-- path it had, to show once that folder is gone too. A trashed space has no
-- parent to go back to; it goes back to the root.
CREATE TABLE trash (
node_id INTEGER PRIMARY KEY REFERENCES nodes (id) ON DELETE CASCADE,
from_parent INTEGER,
from_space INTEGER,
from_path TEXT NOT NULL,
deleted_at INTEGER NOT NULL
);
CREATE INDEX idx_trash_deleted ON trash (deleted_at);
+9 -4
View File
@@ -185,12 +185,17 @@ pub async fn run(
) -> Result<()> {
// A sealed file has no place in a view meant to be opened by name from
// anywhere: it is left out, so it leaves the view when it is sealed.
let tree: Vec<TreeNode> = sqlx::query(
"SELECT n.id, n.parent_id, n.kind, n.name, n.slug,
// Nor has the trash: a file deleted leaves the view as it leaves the tree,
// and comes back to it if it is restored.
let tree: Vec<TreeNode> = sqlx::query(&format!(
"WITH RECURSIVE {}
SELECT n.id, n.parent_id, n.kind, n.name, n.slug,
CASE WHEN m.encrypted = 0 THEN n.media_id END AS media_id,
COALESCE(m.mime, '') AS mime
FROM nodes n LEFT JOIN media m ON m.id = n.media_id",
)
FROM nodes n LEFT JOIN media m ON m.id = n.media_id
WHERE n.id IN (SELECT id FROM live)",
crate::routes::nodes::LIVE
))
.fetch_all(db)
.await?
.iter()
+8
View File
@@ -22,6 +22,8 @@ pub struct Config {
pub secure_cookie: bool,
/// How long the vault stays open with nothing using it.
pub vault_idle: std::time::Duration,
/// How many days a deleted node waits in the trash before it is purged.
pub trash_days: i64,
pub backup: BackupConfig,
pub mail: MailConfig,
pub sftp: SftpConfig,
@@ -41,6 +43,7 @@ impl std::fmt::Debug for Config {
.field("max_upload_bytes", &self.max_upload_bytes)
.field("secure_cookie", &self.secure_cookie)
.field("vault_idle", &self.vault_idle)
.field("trash_days", &self.trash_days)
.field("backup", &self.backup)
.field("mail", &self.mail)
.field("sftp", &self.sftp)
@@ -287,6 +290,9 @@ impl Config {
// A vault that locks the moment it opens could not be used; one that
// stays open for more than a day is not much of a vault.
let vault_idle = Duration::from_secs(number("VAULT_IDLE_MIN", 15, 1..=24 * 60)? * 60);
// 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 secure_cookie = flag("SECURE_COOKIE")?;
Ok(Self {
@@ -299,6 +305,7 @@ impl Config {
max_upload_bytes,
secure_cookie,
vault_idle,
trash_days,
backup: BackupConfig::from_env()?,
mail: MailConfig::from_env()?,
sftp: SftpConfig::from_env()?,
@@ -336,6 +343,7 @@ impl Config {
max_upload_bytes: 1,
secure_cookie: false,
vault_idle: std::time::Duration::from_secs(60),
trash_days: 30,
backup: BackupConfig {
device_name: "narl-diary".into(),
interval: None,
+1 -1
View File
@@ -120,7 +120,7 @@ async fn main() -> Result<()> {
let notify = notify::Notifier::new(db.clone(), Arc::clone(&config))?;
notify::spawn(Arc::clone(&notify));
sweep::spawn(db.clone(), config.uploads_dir());
sweep::spawn(db.clone(), config.uploads_dir(), config.trash_days);
let epoch = auth::Epoch::load(&db, &config).await?;
let sessions = Arc::new(auth::Sessions::load(&db).await?);
+6 -3
View File
@@ -124,7 +124,7 @@ impl Notifier {
JOIN board_lists l ON l.id = c.list_id
JOIN nodes s ON s.id = l.space_id
WHERE c.done_at IS NULL AND c.due_at IS NOT NULL AND c.due_at < ?
AND s.has_board = 1
AND s.has_board = 1 AND s.parent_id IS NULL
ORDER BY c.due_at",
)
.bind(end_of_today)
@@ -196,6 +196,7 @@ impl Notifier {
JOIN board_lists l ON l.id = c.list_id
JOIN nodes s ON s.id = l.space_id
WHERE c.done_at >= ? AND c.done_at < ? AND s.has_board = 1
AND s.parent_id IS NULL
ORDER BY s.position, c.done_at",
)
.bind(start)
@@ -209,7 +210,7 @@ impl Notifier {
JOIN board_lists l ON l.id = c.list_id
JOIN nodes s ON s.id = l.space_id
WHERE c.done_at IS NULL AND c.due_at IS NOT NULL AND c.due_at < ?
AND s.has_board = 1
AND s.has_board = 1 AND s.parent_id IS NULL
ORDER BY c.due_at",
)
.bind(end)
@@ -349,7 +350,9 @@ impl Notifier {
async fn drop_stale_cards(&self) -> Result<()> {
let tz = self.config.mail.timezone;
let waiting = sqlx::query(
"SELECT n.id, n.dedupe_key, c.due_at, c.done_at, s.has_board
"SELECT n.id, n.dedupe_key, c.due_at, c.done_at,
-- A space in the trash has no board to be due on.
CASE WHEN s.parent_id IS NULL THEN s.has_board ELSE 0 END AS has_board
FROM notifications n
LEFT JOIN cards c
ON c.id = CAST(substr(n.dedupe_key, 10, instr(substr(n.dedupe_key, 10), ':') - 1) AS INTEGER)
+1 -1
View File
@@ -191,7 +191,7 @@ async fn agenda_cards(db: &SqlitePool, horizon: i64) -> AppResult<Vec<AgendaCard
JOIN board_lists l ON l.id = c.list_id
JOIN bounds b ON b.space_id = l.space_id
JOIN nodes s ON s.id = l.space_id
WHERE s.has_board <> 0
WHERE s.has_board <> 0 AND s.parent_id IS NULL
AND c.done_at IS NULL
AND (l.position < b.last OR b.first = b.last)
AND ((c.due_at IS NOT NULL AND c.due_at < ?1) OR (l.position > b.first))
+6 -5
View File
@@ -21,7 +21,7 @@ use crate::{
routes::{
manifest::{self, CardMeta, ListMeta, Manifest, NodeMeta, SpaceMeta},
media::MEDIA_REF,
nodes::{is_container, DOCUMENT, SPACE},
nodes::{is_container, DOCUMENT, LIVE, SPACE},
},
state::AppState,
};
@@ -179,11 +179,12 @@ tree stays intact. Nothing here needs the workspace application to read it.
/// Everything, as one zip. A workspace you cannot get out of is not one you
/// can trust, so this is deliberately a plain archive of plain files.
pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Response> {
let nodes: HashMap<i64, ExportNode> = sqlx::query(
"SELECT id, parent_id, kind, name, slug, body, media_id, created_at, updated_at,
let nodes: HashMap<i64, ExportNode> = sqlx::query(&format!(
"WITH RECURSIVE {LIVE}
SELECT id, parent_id, kind, name, slug, body, media_id, created_at, updated_at,
has_board, role
FROM nodes ORDER BY created_at, id",
)
FROM nodes WHERE id IN (SELECT id FROM live) ORDER BY created_at, id"
))
.fetch_all(&state.db)
.await?
.into_iter()
+4
View File
@@ -10,6 +10,7 @@ pub mod seal;
pub mod session;
pub mod share;
pub mod ssh_keys;
pub mod trash;
pub mod vault;
use axum::{
@@ -93,6 +94,9 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
.route("/media/{id}", get(media::serve).delete(media::remove))
.route("/share/{token}", post(share::read))
.route("/share/{token}/media/{id}", get(share::serve_media))
.route("/trash", get(trash::list).delete(trash::empty))
.route("/trash/{id}", delete(trash::purge))
.route("/trash/{id}/restore", post(trash::restore))
.route("/ssh-keys", get(ssh_keys::list).post(ssh_keys::add))
.route(
"/ssh-keys/{id}",
+62 -24
View File
@@ -10,7 +10,7 @@ use sqlx::{Row, SqliteConnection, SqlitePool};
use crate::{
auth::Session,
error::{AppError, AppResult},
routes::media,
routes::{media, trash},
state::AppState,
};
@@ -28,6 +28,15 @@ pub const FILE: &str = "file";
/// slug, so it stays the diary under any name.
pub const DIARY: &str = "diary";
/// The nodes in the tree proper — the spaces and everything under them, so
/// not the trash — for a query that reads every node rather than walking down
/// from one: `WITH RECURSIVE {LIVE} SELECT … WHERE id IN (SELECT id FROM live)`.
pub const LIVE: &str = "live (id) AS (
SELECT id FROM nodes WHERE parent_id IS NULL AND kind = 'space'
UNION ALL
SELECT n.id FROM nodes n JOIN live ON n.parent_id = live.id
)";
/// Whether a node of this kind can hold other nodes.
pub fn is_container(kind: &str) -> bool {
kind == SPACE || kind == FOLDER
@@ -54,7 +63,7 @@ pub struct NodeSummary {
pub excerpt: String,
pub position: i64,
pub has_board: bool,
/// Spaces the code has to tell apart: `diary`, or nothing.
/// Nodes the code has to tell apart: `diary`, `trash`, or nothing.
pub role: Option<String>,
pub child_count: i64,
pub created_at: i64,
@@ -486,11 +495,13 @@ pub async fn list(
let searching = search.and_then(fts_query).is_some();
let rows = match search.and_then(fts_query) {
// A search reaches through the whole tree, optionally confined to one
// space: the recursive term collects that space and everything under it.
// space: the recursive term collects that space, or every space, and
// everything under it — which leaves the trash out.
Some(query) => {
sqlx::query(&format!(
"WITH RECURSIVE sub (id) AS (
SELECT id FROM nodes WHERE id = ?4
SELECT id FROM nodes
WHERE id = ?4 OR (?4 IS NULL AND parent_id IS NULL AND kind = 'space')
UNION ALL
SELECT n.id FROM nodes n JOIN sub ON n.parent_id = sub.id
)
@@ -499,7 +510,7 @@ pub async fn list(
JOIN nodes n ON n.id = nodes_fts.rowid
WHERE nodes_fts MATCH ?1
AND n.kind <> 'space'
AND (?4 IS NULL OR n.id IN (SELECT id FROM sub))
AND n.id IN (SELECT id FROM sub)
ORDER BY rank
LIMIT ?2 OFFSET ?3"
))
@@ -516,7 +527,7 @@ pub async fn list(
sqlx::query(&format!(
"SELECT {SUMMARY_COLUMNS}, substr(n.body, 1, {HEAD_CHARS}) AS head
FROM nodes n
WHERE n.parent_id IS ?1
WHERE n.parent_id IS ?1 AND n.role IS NOT 'trash'
ORDER BY CASE n.kind WHEN 'folder' THEN 0 ELSE 1 END,
n.position, n.created_at DESC, n.id DESC
LIMIT ?2 OFFSET ?3"
@@ -552,11 +563,16 @@ pub async fn list(
Ok(Json(found))
}
/// A node in the trash reads as gone: whatever still points at it — a card,
/// a link, a tab left open — finds nothing, as it did before there was one.
pub async fn get(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Node>> {
if trash::in_trash(&mut *state.db.acquire().await?, id).await? {
return Err(AppError::NotFound);
}
load(&state, id).await.map(Json)
}
@@ -577,12 +593,16 @@ pub async fn resolve(
) -> AppResult<Json<Node>> {
let mut current: Option<i64> = None;
for segment in params.path.split('/').filter(|s| !s.is_empty()) {
let row = sqlx::query("SELECT id FROM nodes WHERE parent_id IS ?1 AND slug = ?2")
.bind(current)
.bind(segment)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
// A path starts at a space, never at the trash beside them.
let row = sqlx::query(
"SELECT id FROM nodes
WHERE parent_id IS ?1 AND slug = ?2 AND (?1 IS NOT NULL OR kind = 'space')",
)
.bind(current)
.bind(segment)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
current = Some(row.get("id"));
}
load(&state, current.ok_or(AppError::NotFound)?)
@@ -717,21 +737,28 @@ pub async fn update(
load(&state, id).await.map(Json)
}
/// A delete puts the node in the trash; one already in the trash is purged.
pub async fn remove(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<serde_json::Value>> {
let removed = delete_subtree(&state.db, &state.config.uploads_dir(), id).await?;
let purged = trash::in_trash(&mut *state.db.acquire().await?, id).await?;
let removed = if purged {
purge_subtree(&state.db, &state.config.uploads_dir(), id).await?
} else {
trash::trash_subtree(&state.db, id).await?
};
state.backup.signal();
Ok(Json(serde_json::json!({ "ok": true, "removed": removed })))
Ok(Json(
serde_json::json!({ "ok": true, "removed": removed, "purged": purged }),
))
}
/// Delete a node, everything beneath it, and the files nothing outside it
/// embeds. The one way a node is deleted, so a delete through the SFTP mount
/// cleans up exactly what one through the API does. Returns how many nodes
/// went.
pub async fn delete_subtree(
/// Delete a node for good, everything beneath it, and the files nothing
/// outside it embeds. What emptying the trash does, and what the mount does
/// when a save replaces one file with another. Returns how many nodes went.
pub async fn purge_subtree(
db: &SqlitePool,
uploads: &std::path::Path,
id: i64,
@@ -748,6 +775,11 @@ pub async fn delete_subtree(
"the diary space stays — empty it instead, or rename it".into(),
));
}
if role.as_deref() == Some(trash::TRASH) {
return Err(AppError::BadRequest(
"the trash stays — :trash purge empties it".into(),
));
}
// Collected before the delete, which takes the node_media rows with it.
// Everything under a folder goes with it, so the files to clean up are
@@ -903,7 +935,8 @@ async fn movable(
Ok(nodes)
}
/// Refuse anything but a space or a folder as the place a node goes into.
/// Refuse anything but a space or a folder as the place a node goes into —
/// and one in the trash, where whatever went in would go when it is purged.
pub async fn container(db: &mut SqliteConnection, id: i64) -> AppResult<()> {
let kind: String = sqlx::query("SELECT kind FROM nodes WHERE id = ?1")
.bind(id)
@@ -916,6 +949,11 @@ pub async fn container(db: &mut SqliteConnection, id: i64) -> AppResult<()> {
"a {kind} cannot contain other nodes"
)));
}
if trash::in_trash(&mut *db, id).await? {
return Err(AppError::BadRequest(
"that folder is in the trash — :trash brings it back".into(),
));
}
Ok(())
}
@@ -1001,7 +1039,7 @@ mod tests {
use std::collections::BTreeSet;
use super::{
delete_subtree, excerpt, fts_query, next_slug, recent_documents, slugify, EXCERPT_CHARS,
excerpt, fts_query, next_slug, purge_subtree, recent_documents, slugify, EXCERPT_CHARS,
HEAD_CHARS,
};
use crate::error::AppError;
@@ -1029,7 +1067,7 @@ mod tests {
.await
.unwrap();
assert!(matches!(
delete_subtree(&db, dir.path(), diary).await,
purge_subtree(&db, dir.path(), diary).await,
Err(AppError::BadRequest(_))
));
@@ -1071,7 +1109,7 @@ mod tests {
}
}
assert_eq!(delete_subtree(&db, dir.path(), folder).await.unwrap(), 2);
assert_eq!(purge_subtree(&db, dir.path(), folder).await.unwrap(), 2);
let media: Vec<String> = sqlx::query_scalar("SELECT id FROM media")
.fetch_all(&db)
.await
@@ -1080,7 +1118,7 @@ mod tests {
assert!(!dir.path().join(only).exists());
assert!(dir.path().join(shared).exists());
assert!(matches!(
delete_subtree(&db, dir.path(), folder).await,
purge_subtree(&db, dir.path(), folder).await,
Err(AppError::NotFound)
));
}
+43 -6
View File
@@ -16,7 +16,7 @@ use crate::{
auth::{self, Session},
config::Config,
error::{AppError, AppResult},
routes::media,
routes::{media, trash},
state::AppState,
};
@@ -187,7 +187,7 @@ pub async fn read(
Json(body): Json<ReadRequest>,
) -> AppResult<impl IntoResponse> {
let row = sqlx::query(
"SELECT name, body, created_at, updated_at, share_key_hash
"SELECT id, name, body, created_at, updated_at, share_key_hash
FROM nodes WHERE share_token = ?1",
)
.bind(&token)
@@ -201,6 +201,11 @@ pub async fn read(
// confirm that the token itself is live.
return Err(AppError::NotFound);
};
// A document in the trash is not published, but its link comes back with
// it: the key is still the one its readers hold.
if trash::in_trash(&mut *state.db.acquire().await?, row.get("id")).await? {
return Err(AppError::NotFound);
}
let cookie = share_cookie(&state.config, &token, &stored);
Ok((
@@ -223,8 +228,8 @@ pub async fn serve_media(
Path((token, id)): Path<(String, String)>,
headers: HeaderMap,
) -> AppResult<Response> {
let stored: Option<String> = sqlx::query(
"SELECT n.share_key_hash
let row = sqlx::query(
"SELECT n.id, n.share_key_hash
FROM node_media nm
JOIN nodes n ON n.id = nm.node_id
WHERE nm.media_id = ?1 AND n.share_token = ?2",
@@ -232,14 +237,19 @@ pub async fn serve_media(
.bind(&id)
.bind(&token)
.fetch_optional(&state.db)
.await?
.and_then(|row| row.get("share_key_hash"));
.await?;
let stored: Option<String> = row.as_ref().and_then(|row| row.get("share_key_hash"));
let allowed =
stored.is_some_and(|stored| holds_share_cookie(&state.config, &headers, &token, &stored));
if !allowed {
return Err(AppError::NotFound);
}
if let Some(row) = &row {
if trash::in_trash(&mut *state.db.acquire().await?, row.get("id")).await? {
return Err(AppError::NotFound);
}
}
media::stream_media(&state, &id, false, headers.get(header::RANGE)).await
}
@@ -367,6 +377,33 @@ mod tests {
assert_eq!(s.media(Some(&cookie)).await, StatusCode::NOT_FOUND);
}
/// A shared document in the trash answers like an unknown link, and comes
/// back with the link its readers already hold.
#[tokio::test]
async fn the_trash_unpublishes_until_a_restore() {
let s = shared().await;
let (status, set) = s.read(&s.key).await;
assert_eq!(status, StatusCode::OK);
let cookie = set.unwrap().split(';').next().unwrap().to_string();
let doc: i64 = sqlx::query_scalar("SELECT id FROM nodes WHERE share_token = ?1")
.bind(&s.token)
.fetch_one(&s.state.db)
.await
.unwrap();
crate::routes::trash::trash_subtree(&s.state.db, doc)
.await
.unwrap();
assert_eq!(s.read(&s.key).await, (StatusCode::NOT_FOUND, None));
assert_eq!(s.media(Some(&cookie)).await, StatusCode::NOT_FOUND);
crate::routes::trash::restore_node(&s.state, doc)
.await
.unwrap();
assert_eq!(s.read(&s.key).await.0, StatusCode::OK);
assert_eq!(s.media(Some(&cookie)).await, StatusCode::OK);
}
#[tokio::test]
async fn the_key_is_not_taken_from_the_path() {
let s = shared().await;
+601
View File
@@ -0,0 +1,601 @@
//! The trash: where a delete puts things, so a slip of `dd` is a `u` away
//! from being undone rather than an old revision of the whole database away.
//!
//! A trashed node is moved, whole, under one hidden root (see migration
//! `0019`). Nothing that walks down from the spaces reaches it there, so it
//! leaves the tree, the search, the mount, the export and the readable mirror
//! without any of them having to know about the trash. What is still in it
//! after `WORKSPACE_TRASH_DAYS` is purged by the sweep, and only then are its
//! files deleted.
use axum::{
extract::{Path, State},
Json,
};
use serde::Serialize;
use sqlx::{Row, SqliteConnection, SqlitePool};
use crate::{
auth::Session,
error::{AppError, AppResult},
routes::nodes::{self, Node},
state::AppState,
};
/// The `role` of the hidden root everything trashed hangs under.
pub const TRASH: &str = "trash";
/// The trash root's id. Made by a migration, so it is always there.
pub async fn root(db: &mut SqliteConnection) -> AppResult<i64> {
Ok(sqlx::query_scalar("SELECT id FROM nodes WHERE role = ?1")
.bind(TRASH)
.fetch_one(&mut *db)
.await?)
}
/// Whether a node is in the trash, at any depth — or is the trash itself.
pub async fn in_trash(db: &mut SqliteConnection, id: i64) -> AppResult<bool> {
let found: i64 = sqlx::query_scalar(
"WITH RECURSIVE up (id, parent_id) AS (
SELECT id, parent_id FROM nodes WHERE id = ?1
UNION ALL
SELECT n.id, n.parent_id FROM nodes n JOIN up ON n.id = up.parent_id
)
SELECT count(*) FROM up JOIN nodes n ON n.id = up.id WHERE n.role = ?2",
)
.bind(id)
.bind(TRASH)
.fetch_one(&mut *db)
.await?;
Ok(found > 0)
}
/// Move a node and everything under it into the trash. Returns how many
/// nodes went. The one way a node is deleted, from the browser and from the
/// mount alike; its files stay on the disk until it is purged.
pub async fn trash_subtree(db: &SqlitePool, id: i64) -> AppResult<usize> {
let mut tx = crate::db::begin_write(db).await?;
let row = sqlx::query("SELECT parent_id, slug, role FROM nodes WHERE id = ?1")
.bind(id)
.fetch_optional(&mut *tx)
.await?
.ok_or(AppError::NotFound)?;
match row.get::<Option<String>, _>("role").as_deref() {
Some(nodes::DIARY) => {
return Err(AppError::BadRequest(
"the diary space stays — empty it instead, or rename it".into(),
))
}
Some(TRASH) => {
return Err(AppError::BadRequest(
"the trash stays — :trash purge empties it".into(),
))
}
_ => {}
}
if in_trash(&mut tx, id).await? {
return Err(AppError::BadRequest("that is in the trash already".into()));
}
let ancestry = ancestry(&mut tx, id).await?;
let from_path = ancestry
.iter()
.take(ancestry.len().saturating_sub(1))
.map(|(_, slug)| slug.as_str())
.collect::<Vec<_>>()
.join("/");
let from_space = ancestry
.first()
.map(|(id, _)| *id)
.filter(|&space| space != id);
let count = nodes::descendants(&mut tx, id).await?.len();
let trash = root(&mut tx).await?;
let slug = nodes::unique_slug(
&mut tx,
Some(trash),
&row.get::<String, _>("slug"),
Some(id),
)
.await?;
// `updated_at` stays as it was: it says when the thing was last written,
// and a restore should bring it back as it was, not as new.
sqlx::query("UPDATE nodes SET parent_id = ?1, slug = ?2 WHERE id = ?3")
.bind(trash)
.bind(&slug)
.bind(id)
.execute(&mut *tx)
.await?;
sqlx::query(
"INSERT OR REPLACE INTO trash (node_id, from_parent, from_space, from_path, deleted_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
)
.bind(id)
.bind(row.get::<Option<i64>, _>("parent_id"))
.bind(from_space)
.bind(&from_path)
.bind(crate::now())
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(count)
}
/// The ids and slugs from the root down to the node itself.
async fn ancestry(db: &mut SqliteConnection, id: i64) -> AppResult<Vec<(i64, String)>> {
let rows = sqlx::query(
"WITH RECURSIVE up (id, parent_id, slug, depth) AS (
SELECT id, parent_id, slug, 0 FROM nodes WHERE id = ?1
UNION ALL
SELECT n.id, n.parent_id, n.slug, up.depth + 1
FROM nodes n JOIN up ON n.id = up.parent_id
)
SELECT id, slug FROM up ORDER BY depth DESC",
)
.bind(id)
.fetch_all(&mut *db)
.await?;
Ok(rows
.iter()
.map(|row| (row.get("id"), row.get("slug")))
.collect())
}
#[derive(Serialize)]
pub struct TrashItem {
pub id: i64,
pub kind: String,
pub name: String,
pub slug: String,
/// The path of the folder it was deleted from, `work/notes`; empty for a
/// space.
pub from: String,
pub deleted_at: i64,
/// When the sweep purges it.
pub purge_at: i64,
/// How many nodes are inside it, for a folder or a space.
pub inside: usize,
}
/// The rows that stand for something still in the trash. A row whose node
/// has left it some other way — moved out by hand — stands for nothing.
const LIVE_ROWS: &str = "FROM trash t JOIN nodes n ON n.id = t.node_id
WHERE n.parent_id = (SELECT id FROM nodes WHERE role = 'trash')";
/// `GET /api/trash` — what is in it, the last thing deleted first.
pub async fn list(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<TrashItem>>> {
let mut conn = state.db.acquire().await?;
let rows = sqlx::query(&format!(
"SELECT t.node_id, t.from_path, t.deleted_at, n.kind, n.name, n.slug {LIVE_ROWS}
ORDER BY t.deleted_at DESC, t.node_id DESC"
))
.fetch_all(&mut *conn)
.await?;
let keep = state.config.trash_days * 86_400;
let mut items = Vec::with_capacity(rows.len());
for row in rows {
let id: i64 = row.get("node_id");
let deleted_at: i64 = row.get("deleted_at");
items.push(TrashItem {
id,
kind: row.get("kind"),
name: row.get("name"),
slug: row.get("slug"),
from: row.get("from_path"),
deleted_at,
purge_at: deleted_at + keep,
inside: nodes::descendants(&mut conn, id).await?.len() - 1,
});
}
Ok(Json(items))
}
#[derive(Serialize)]
pub struct Restored {
pub node: Node,
/// Where it came from was gone or in the trash itself, so it went to the
/// root of its space — or of the first space, when that is gone too.
pub elsewhere: bool,
}
/// `POST /api/trash/{id}/restore` — back where it was deleted from.
pub async fn restore(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Restored>> {
let elsewhere = restore_node(&state, id).await?;
state.backup.signal();
Ok(Json(Restored {
node: nodes::load(&state, id).await?,
elsewhere,
}))
}
/// Put one trashed node back. A space goes back to the root, last in the
/// switcher; anything else is moved like any other move, so a sealed folder
/// it goes back into seals it on the way in, or refuses while the vault is
/// shut. Returns whether it had to go somewhere other than where it was.
pub async fn restore_node(state: &AppState, id: i64) -> AppResult<bool> {
let mut conn = state.db.acquire().await?;
let row = sqlx::query(&format!(
"SELECT n.kind, n.name, t.from_parent, t.from_space {LIVE_ROWS} AND t.node_id = ?1"
))
.bind(id)
.fetch_optional(&mut *conn)
.await?
.ok_or(AppError::NotFound)?;
let kind: String = row.get("kind");
let name: String = row.get("name");
if kind == nodes::SPACE {
drop(conn);
let mut tx = crate::db::begin_write(&state.db).await?;
let slug = nodes::unique_slug(&mut tx, None, &nodes::slugify(&name), Some(id)).await?;
sqlx::query(
"UPDATE nodes SET parent_id = NULL, slug = ?1,
position = (SELECT COALESCE(MAX(position), -1) + 1 FROM nodes WHERE parent_id IS NULL)
WHERE id = ?2",
)
.bind(&slug)
.bind(id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM trash WHERE node_id = ?1")
.bind(id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
return Ok(false);
}
let from_parent: Option<i64> = row.get("from_parent");
let from_space: Option<i64> = row.get("from_space");
let (target, elsewhere) = match from_parent {
Some(parent) if is_live_container(&mut conn, parent).await? => (parent, false),
_ => (fallback(&mut conn, from_space).await?, true),
};
drop(conn);
nodes::move_nodes(state, &[id], target, None).await?;
// After the move, not with it: the move seals what it has to, which takes
// transactions of its own. Should this delete not happen, the row stands
// for nothing — it no longer matches `LIVE_ROWS` — and the sweep drops it.
sqlx::query("DELETE FROM trash WHERE node_id = ?1")
.bind(id)
.execute(&state.db)
.await?;
Ok(elsewhere)
}
async fn is_live_container(db: &mut SqliteConnection, id: i64) -> AppResult<bool> {
let kind: Option<String> = sqlx::query_scalar("SELECT kind FROM nodes WHERE id = ?1")
.bind(id)
.fetch_optional(&mut *db)
.await?;
Ok(kind.is_some_and(|kind| nodes::is_container(&kind)) && !in_trash(db, id).await?)
}
/// Where a node goes back to when its folder is gone: the root of its space,
/// or, when the space was purged, of the first space there is. A space that
/// is in the trash itself has to come back first, or the node would go
/// straight back into the trash with it.
async fn fallback(db: &mut SqliteConnection, space: Option<i64>) -> AppResult<i64> {
if let Some(space) = space {
let row = sqlx::query("SELECT name, parent_id FROM nodes WHERE id = ?1")
.bind(space)
.fetch_optional(&mut *db)
.await?;
if let Some(row) = row {
if row.get::<Option<i64>, _>("parent_id").is_none() {
return Ok(space);
}
return Err(AppError::BadRequest(format!(
"its space, {}, is in the trash too — restore that first",
row.get::<String, _>("name")
)));
}
}
sqlx::query_scalar(
"SELECT id FROM nodes WHERE parent_id IS NULL AND kind = 'space'
ORDER BY position, name LIMIT 1",
)
.fetch_optional(&mut *db)
.await?
.ok_or_else(|| {
AppError::BadRequest("there is no space to restore it into — :space! makes one".into())
})
}
/// `DELETE /api/trash/{id}` — purge one thing now, files and all.
pub async fn purge(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<serde_json::Value>> {
let listed: Option<i64> =
sqlx::query_scalar(&format!("SELECT t.node_id {LIVE_ROWS} AND t.node_id = ?1"))
.bind(id)
.fetch_optional(&state.db)
.await?;
if listed.is_none() {
return Err(AppError::NotFound);
}
let removed = nodes::purge_subtree(&state.db, &state.config.uploads_dir(), id).await?;
state.backup.signal();
Ok(Json(serde_json::json!({ "ok": true, "removed": removed })))
}
/// `DELETE /api/trash` — empty it.
pub async fn empty(
_: Session,
State(state): State<AppState>,
) -> AppResult<Json<serde_json::Value>> {
let removed = purge_older(&state.db, &state.config.uploads_dir(), i64::MAX).await?;
state.backup.signal();
Ok(Json(serde_json::json!({ "ok": true, "removed": removed })))
}
/// Purge everything deleted before `cutoff`, and forget the rows that no
/// longer stand for anything. Returns how many things were purged.
pub async fn purge_older(
db: &SqlitePool,
uploads: &std::path::Path,
cutoff: i64,
) -> AppResult<usize> {
let due: Vec<i64> = sqlx::query_scalar(&format!(
"SELECT t.node_id {LIVE_ROWS} AND t.deleted_at < ?1"
))
.bind(cutoff)
.fetch_all(db)
.await?;
for &id in &due {
nodes::purge_subtree(db, uploads, id).await?;
}
sqlx::query(
"DELETE FROM trash WHERE node_id NOT IN
(SELECT id FROM nodes WHERE parent_id = (SELECT id FROM nodes WHERE role = 'trash'))",
)
.execute(db)
.await?;
Ok(due.len())
}
#[cfg(test)]
mod tests {
use super::{in_trash, purge_older, restore_node, trash_subtree};
use crate::{error::AppError, state::AppState};
async fn node(state: &AppState, parent: i64, kind: &str, slug: &str) -> i64 {
sqlx::query_scalar(
"INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at)
VALUES (?1, ?2, ?3, ?3, 'text', 0, 0) RETURNING id",
)
.bind(parent)
.bind(kind)
.bind(slug)
.fetch_one(&state.db)
.await
.unwrap()
}
async fn space(state: &AppState, slug: &str) -> i64 {
sqlx::query_scalar("SELECT id FROM nodes WHERE slug = ?1 AND parent_id IS NULL")
.bind(slug)
.fetch_one(&state.db)
.await
.unwrap()
}
async fn parent_and_slug(state: &AppState, id: i64) -> (Option<i64>, String) {
sqlx::query_as("SELECT parent_id, slug FROM nodes WHERE id = ?1")
.bind(id)
.fetch_one(&state.db)
.await
.unwrap()
}
async fn trashed(state: &AppState, id: i64) -> bool {
in_trash(&mut state.db.acquire().await.unwrap(), id)
.await
.unwrap()
}
/// A folder goes with what is in it and comes back to where it was —
/// under its old slug, unless a new sibling took it meanwhile.
#[tokio::test]
async fn a_delete_comes_back_where_it_was() {
let state = crate::routes::tests::state_in(".".into()).await;
let work = space(&state, "work").await;
let folder = node(&state, work, "folder", "notes").await;
let inside = node(&state, folder, "document", "monday").await;
assert_eq!(trash_subtree(&state.db, folder).await.unwrap(), 2);
assert!(trashed(&state, folder).await);
assert!(trashed(&state, inside).await);
let taken = node(&state, work, "folder", "notes").await;
assert!(!restore_node(&state, folder).await.unwrap());
assert_eq!(
parent_and_slug(&state, folder).await,
(Some(work), "notes-2".into())
);
assert_eq!(parent_and_slug(&state, inside).await.0, Some(folder));
assert!(!trashed(&state, inside).await);
assert!(!trashed(&state, taken).await);
let rows: i64 = sqlx::query_scalar("SELECT count(*) FROM trash")
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(rows, 0);
}
/// Out of a folder that has since gone, a node lands at the root of its
/// space; out of a space that is in the trash too, it waits for that.
#[tokio::test]
async fn without_its_folder_it_lands_in_its_space() {
let state = crate::routes::tests::state_in(".".into()).await;
let uni = space(&state, "uni").await;
let folder = node(&state, uni, "folder", "algo").await;
let doc = node(&state, folder, "document", "sheet-1").await;
trash_subtree(&state.db, doc).await.unwrap();
trash_subtree(&state.db, folder).await.unwrap();
assert!(restore_node(&state, doc).await.unwrap());
assert_eq!(parent_and_slug(&state, doc).await.0, Some(uni));
trash_subtree(&state.db, doc).await.unwrap();
trash_subtree(&state.db, uni).await.unwrap();
assert!(matches!(
restore_node(&state, doc).await,
Err(AppError::BadRequest(_))
));
assert!(!restore_node(&state, uni).await.unwrap());
assert_eq!(parent_and_slug(&state, uni).await, (None, "uni".into()));
assert!(!restore_node(&state, doc).await.unwrap());
assert_eq!(parent_and_slug(&state, doc).await.0, Some(uni));
}
/// The diary and the trash itself cannot be thrown away, and nothing is
/// thrown away twice.
#[tokio::test]
async fn some_things_stay() {
let state = crate::routes::tests::state_in(".".into()).await;
let diary: i64 = sqlx::query_scalar("SELECT id FROM nodes WHERE role = 'diary'")
.fetch_one(&state.db)
.await
.unwrap();
let trash = super::root(&mut state.db.acquire().await.unwrap())
.await
.unwrap();
for id in [diary, trash] {
assert!(matches!(
trash_subtree(&state.db, id).await,
Err(AppError::BadRequest(_))
));
}
let doc = node(&state, diary, "document", "today").await;
trash_subtree(&state.db, doc).await.unwrap();
assert!(matches!(
trash_subtree(&state.db, doc).await,
Err(AppError::BadRequest(_))
));
}
/// What is in the trash is not found by a search across every space, and
/// the cards of a space in the trash leave the agenda.
#[tokio::test]
async fn the_trash_is_out_of_sight() {
use axum::extract::{Query, State};
let state = crate::routes::tests::state_in(".".into()).await;
let session = || crate::auth::Session { id: "test".into() };
let work = space(&state, "work").await;
let doc = node(&state, work, "document", "quarterly").await;
sqlx::query("UPDATE nodes SET body = 'zebra crossing' WHERE id = ?1")
.bind(doc)
.execute(&state.db)
.await
.unwrap();
for (name, position) in [("inbox", 0), ("done", 1)] {
sqlx::query(
"INSERT INTO board_lists (space_id, name, position, created_at)
VALUES (?1, ?2, ?3, 0)",
)
.bind(work)
.bind(name)
.bind(position)
.execute(&state.db)
.await
.unwrap();
}
let list: i64 = sqlx::query_scalar(
"SELECT id FROM board_lists WHERE space_id = ?1 ORDER BY position LIMIT 1",
)
.bind(work)
.fetch_one(&state.db)
.await
.unwrap();
sqlx::query(
"INSERT INTO cards (list_id, title, due_at, position, created_at, updated_at)
VALUES (?1, 'late', 1, 0, 0, 0)",
)
.bind(list)
.execute(&state.db)
.await
.unwrap();
let search = || async {
let query = serde_json::from_value(serde_json::json!({ "q": "zebra" })).unwrap();
crate::routes::nodes::list(session(), State(state.clone()), Query(query))
.await
.unwrap()
.0
.len()
};
let agenda = || async {
let agenda = crate::routes::boards::agenda(session(), State(state.clone()))
.await
.unwrap()
.0;
serde_json::to_value(agenda).unwrap()["cards"]
.as_array()
.unwrap()
.len()
};
assert_eq!(search().await, 1);
assert_eq!(agenda().await, 1);
trash_subtree(&state.db, doc).await.unwrap();
assert_eq!(search().await, 0);
trash_subtree(&state.db, work).await.unwrap();
assert_eq!(agenda().await, 0);
}
/// The sweep purges what is past the cutoff and leaves the rest, and a
/// row left behind by a node moved out by hand is forgotten, not purged.
#[tokio::test]
async fn only_what_is_old_enough_is_purged() {
let dir = tempfile::tempdir().unwrap();
let state = crate::routes::tests::state_in(dir.path().into()).await;
let work = space(&state, "work").await;
let old = node(&state, work, "document", "old").await;
let new = node(&state, work, "document", "new").await;
let moved = node(&state, work, "document", "moved").await;
for id in [old, new, moved] {
trash_subtree(&state.db, id).await.unwrap();
}
sqlx::query("UPDATE trash SET deleted_at = 100 WHERE node_id IN (?1, ?2)")
.bind(old)
.bind(moved)
.execute(&state.db)
.await
.unwrap();
sqlx::query("UPDATE nodes SET parent_id = ?1 WHERE id = ?2")
.bind(work)
.bind(moved)
.execute(&state.db)
.await
.unwrap();
assert_eq!(purge_older(&state.db, dir.path(), 1000).await.unwrap(), 1);
let left: Vec<i64> =
sqlx::query_scalar("SELECT id FROM nodes WHERE id IN (?1, ?2, ?3) ORDER BY id")
.bind(old)
.bind(new)
.bind(moved)
.fetch_all(&state.db)
.await
.unwrap();
assert_eq!(left, [new, moved]);
let rows: Vec<i64> = sqlx::query_scalar("SELECT node_id FROM trash")
.fetch_all(&state.db)
.await
.unwrap();
assert_eq!(rows, [new]);
}
}
+29 -6
View File
@@ -35,7 +35,7 @@ use crate::{
Backup,
},
error::{AppError, AppResult},
routes::{media, nodes, seal},
routes::{media, nodes, seal, trash},
state::AppState,
vault,
};
@@ -600,7 +600,8 @@ impl SpaceFs {
for slug in [leaf, slugged.as_str()] {
let row = sqlx::query(
"SELECT id, kind, slug, updated_at FROM nodes
WHERE parent_id IS ?1 AND slug = ?2 AND kind IN ('space', 'folder')",
WHERE parent_id IS ?1 AND slug = ?2 AND kind IN ('space', 'folder')
AND role IS NOT 'trash'",
)
.bind(parent)
.bind(slug)
@@ -773,8 +774,24 @@ impl SpaceFs {
Ok(())
}
/// `rm` and `rmdir`: into the trash, as a delete in the browser does.
async fn delete_node(&self, id: i64) -> SftpResult<()> {
nodes::delete_subtree(&self.db, &self.uploads, id)
trash::trash_subtree(&self.db, id)
.await
.map_err(|e| match e {
AppError::NotFound => StatusCode::NoSuchFile,
AppError::BadRequest(_) => StatusCode::PermissionDenied,
_ => StatusCode::Failure,
})?;
self.changed();
Ok(())
}
/// A rename onto a name that is taken: what was there is replaced, as
/// `rename(2)` replaces it, and not kept in the trash — an editor or
/// `rsync` saving that way would fill it with every save.
async fn replace_node(&self, id: i64) -> SftpResult<()> {
nodes::purge_subtree(&self.db, &self.uploads, id)
.await
.map_err(|e| match e {
AppError::NotFound => StatusCode::NoSuchFile,
@@ -1081,7 +1098,8 @@ impl SpaceFs {
let parent = self.resolve_dir(&segs).await?;
let rows = sqlx::query(&format!(
"SELECT kind, slug, updated_at, {DOC_SIZE} AS size FROM nodes
WHERE parent_id IS ?1 AND kind <> 'file' ORDER BY kind, slug"
WHERE parent_id IS ?1 AND kind <> 'file' AND role IS NOT 'trash'
ORDER BY kind, slug"
))
.bind(parent)
.fetch_all(&self.db)
@@ -1716,7 +1734,7 @@ impl russh_sftp::server::Handler for SpaceFs {
(Some(_), Some(Target::Doc(other))) if other.id != doc.id => {
self.save_doc(other.id, render_doc(&doc.name, &doc.body).into_bytes())
.await?;
self.delete_node(doc.id).await?;
self.replace_node(doc.id).await?;
}
(Some(stem), _) => {
let new_slug = nodes::unique_slug(
@@ -1763,7 +1781,7 @@ impl russh_sftp::server::Handler for SpaceFs {
Some(Target::Dir { .. } | Target::Doc(_)) => return Err(StatusCode::Failure),
Some(Target::File(other)) if other.id == file.id => {}
// Onto another file, as rename(2) does: that one goes.
Some(Target::File(other)) => self.delete_node(other.id).await?,
Some(Target::File(other)) => self.replace_node(other.id).await?,
Some(Target::Scratch(key)) => {
self.scratch.remove(&key);
}
@@ -1958,6 +1976,11 @@ mod tests {
.await
.expect("remove");
assert_eq!(names(&fs).await, ["notes.md"]);
// In the trash, with their bytes, until it is emptied.
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 2);
crate::routes::trash::purge_older(&fs.db, dir.path(), i64::MAX)
.await
.expect("empty the trash");
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
+24 -10
View File
@@ -1,6 +1,7 @@
//! 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.
//! 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.
use std::{collections::HashSet, path::Path, time::Duration};
@@ -18,21 +19,22 @@ const UNREFERENCED_DAYS: i64 = 7;
const EVERY: Duration = Duration::from_secs(24 * 60 * 60);
pub fn spawn(db: SqlitePool, uploads: std::path::PathBuf) {
pub fn spawn(db: SqlitePool, uploads: std::path::PathBuf, trash_days: i64) {
crate::tasks::supervise("the uploads sweep", move || {
let (db, uploads) = (db.clone(), uploads.clone());
sweep_forever(db, uploads)
sweep_forever(db, uploads, trash_days)
});
}
async fn sweep_forever(db: SqlitePool, uploads: std::path::PathBuf) {
async fn sweep_forever(db: SqlitePool, uploads: std::path::PathBuf, trash_days: i64) {
{
let mut ticker = tokio::time::interval(EVERY);
loop {
ticker.tick().await;
match run(&db, &uploads).await {
match run(&db, &uploads, trash_days).await {
Ok(swept) if swept.any() => tracing::info!(
"swept uploads: {} partial, {} without a row, {} that nothing embeds",
"swept: {} out of the trash; uploads: {} partial, {} without a row, {} that nothing embeds",
swept.trashed,
swept.partial,
swept.stray,
swept.unreferenced
@@ -46,6 +48,8 @@ async fn sweep_forever(db: SqlitePool, uploads: std::path::PathBuf) {
#[derive(Debug, Default, PartialEq)]
pub struct Swept {
/// Things deleted long enough ago to be purged from the trash.
pub trashed: usize,
pub partial: usize,
pub stray: usize,
pub unreferenced: usize,
@@ -53,12 +57,21 @@ pub struct Swept {
impl Swept {
fn any(&self) -> bool {
self.partial + self.stray + self.unreferenced > 0
self.trashed + self.partial + self.stray + self.unreferenced > 0
}
}
pub async fn run(db: &SqlitePool, uploads: &Path) -> anyhow::Result<Swept> {
let mut swept = Swept::default();
pub async fn run(db: &SqlitePool, uploads: &Path, trash_days: i64) -> anyhow::Result<Swept> {
// First, so the files only the purged nodes held are released and go in
// this same pass.
let trashed =
crate::routes::trash::purge_older(db, uploads, crate::now() - trash_days * 86_400)
.await
.map_err(|e| anyhow::anyhow!("emptying the trash: {e:?}"))?;
let mut swept = Swept {
trashed,
..Swept::default()
};
sqlx::query("DELETE FROM sessions WHERE expires_at < ?1")
.bind(crate::now())
@@ -189,10 +202,11 @@ 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()).await.unwrap();
let swept = run(&db, dir.path(), 30).await.unwrap();
assert_eq!(
swept,
Swept {
trashed: 0,
partial: 1,
stray: 2,
unreferenced: 1
+42 -1
View File
@@ -14,6 +14,7 @@
import Login from './lib/Login.svelte'
import Overview from './lib/Overview.svelte'
import MediaBrowser, { copyMedia, insertMedia, removeMedia } from './lib/MediaBrowser.svelte'
import Trash, { purgeTrashed, restoreTrashed } from './lib/Trash.svelte'
import Actions from './lib/Actions.svelte'
import Prompt from './lib/Prompt.svelte'
import SecretPrompt from './lib/SecretPrompt.svelte'
@@ -391,6 +392,35 @@
}
}
/** The trash's keys: j/k down what was deleted, Enter or r restores, x deletes for good. */
function trashKey(event: KeyboardEvent): boolean {
const items = workspace.trash
const last = Math.max(items.length - 1, 0)
const item = items[workspace.trashCursor]
switch (event.key) {
case 'j':
case 'ArrowDown':
workspace.trashCursor = Math.min(workspace.trashCursor + 1, last)
return true
case 'k':
case 'ArrowUp':
workspace.trashCursor = Math.max(workspace.trashCursor - 1, 0)
return true
case 'G':
workspace.trashCursor = last
return true
case 'Enter':
case 'r':
if (item) void restoreTrashed(item)
return true
case 'x':
if (item) void purgeTrashed(item)
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.
@@ -578,6 +608,12 @@
return
}
}
if (workspace.overlay === 'trash' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) {
if (trashKey(event)) {
event.preventDefault()
return
}
}
if (workspace.overlay === 'outline' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) {
if (outlineKey(event)) {
event.preventDefault()
@@ -862,6 +898,11 @@
event.preventDefault()
void runCommand('d')
break
case 'u':
// A delete goes to the trash, so it can be undone the way vim undoes.
event.preventDefault()
void workspace.guard(() => workspace.undoDelete())
break
case 's':
// Sharing is a key away; taking a link back from its readers asks first.
event.preventDefault()
@@ -1000,7 +1041,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')}
>
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else if workspace.overlay === 'actions'}<Actions />{:else if workspace.overlay === 'verify'}<Verify />{:else if workspace.overlay === 'outline'}<Headings />{:else}<MediaBrowser />{/if}
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else if workspace.overlay === 'actions'}<Actions />{:else if workspace.overlay === 'verify'}<Verify />{:else if workspace.overlay === 'outline'}<Headings />{:else if workspace.overlay === 'trash'}<Trash />{:else}<MediaBrowser />{/if}
</div>
{/if}
</div>
+2 -1
View File
@@ -41,7 +41,8 @@
['n / N', 'next / previous search result, opened beside the results — :noh clears the search'],
['s', 'toggle sharing for the document — asks before taking it back'],
['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 (asks y/N)'],
['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'],
[':', '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'],
+1
View File
@@ -83,6 +83,7 @@
],
[
{ icon: 'upload', label: 'Upload files into this folder', keys: [':upload'], run: () => hooks.pickTreeFiles(false) },
{ icon: 'trash', label: 'The trash — what was deleted', keys: [':trash'], run: () => runCommand('trash') },
...(workspace.marked.length
? [{ icon: 'drop' as const, label: `Drop ${dropping(workspace.marked)} here`, keys: ['p'], run: () => workspace.guard(() => workspace.drop()), on: true }]
: []),
+131
View File
@@ -0,0 +1,131 @@
<script lang="ts" module>
import type { TrashItem } from './api'
import { workspace } from './store.svelte'
/*
* What a row's buttons do, out here so the keys in App.svelte do the same:
* Enter or r restores, x deletes for good.
*/
export function restoreTrashed(item: TrashItem) {
return workspace.guard(() => workspace.restore(item.id))
}
export async function purgeTrashed(item: TrashItem) {
const inside = item.inside > 0 ? ` and the ${item.inside} inside it` : ''
if (!(await workspace.ask(`delete "${item.name.trim() || item.slug}"${inside} for good? this cannot be undone.`))) return
await workspace.guard(() => workspace.purge(item.id))
}
export async function emptyTrash() {
if (!(await workspace.ask('empty the trash? everything in it is deleted for good.'))) return
await workspace.guard(() => workspace.emptyTrash())
}
</script>
<script lang="ts">
import { relative } from './util'
let sheet = $state<HTMLDivElement>()
$effect(() => {
const index = workspace.trashCursor
sheet?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
})
const days = (at: number) => Math.max(Math.ceil((at - Date.now() / 1000) / 86400), 0)
</script>
<div class="sheet" bind:this={sheet}>
<header>
<span class="label rule"><span class="marker"></span><span class="title">trash</span> {workspace.trash.length} deleted</span>
{#if workspace.trash.length > 0}
<button class="danger" onclick={emptyTrash}>empty</button>
{/if}
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
</header>
<ul>
{#each workspace.trash as item, index (item.id)}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<li class:at={index === workspace.trashCursor} data-index={index} onpointerenter={() => (workspace.trashCursor = index)}>
<span class="kind label">{item.kind}</span>
<div class="info">
<span class="name">{item.name.trim() || item.slug}</span>
<span class="muted">
{item.from || 'the root'} · deleted {relative(item.deleted_at)}{item.inside > 0 ? ` · ${item.inside} inside` : ''} ·
gone in {days(item.purge_at)}d
</span>
</div>
<div class="actions">
<button onclick={() => restoreTrashed(item)}>restore</button>
<button class="danger" onclick={() => purgeTrashed(item)}>rm</button>
</div>
</li>
{:else}
<li class="muted">the trash is empty.</li>
{/each}
</ul>
<footer class="label">
<span class="kbd">j</span><span class="kbd">k</span> move
<span class="gap"></span>
<span class="kbd">enter</span><span class="kbd">r</span> restore
<span class="gap"></span>
<span class="kbd">x</span> delete for good
<span class="gap"></span>
<span class="kbd">q</span><span class="kbd">esc</span> close
</footer>
</div>
<style>
/* A free-standing panel, as the desktop draws one: hull, one rim, the frame's radius. */
.sheet {
display: flex;
flex-direction: column;
gap: 18px;
max-height: 100%;
background: color-mix(in oklab, var(--hull) 97%, transparent);
border: 1px solid var(--surface-alt);
border-radius: var(--frame-radius);
padding: 20px 24px;
animation: resolve var(--t-slow) var(--ease-snap) both;
width: min(100%, 92ch);
overflow: hidden;
}
header { display: flex; align-items: center; gap: 12px; }
header .label { flex: 1; }
header .marker { background: var(--light); }
header .title { color: var(--light); }
ul { list-style: none; margin: 0; padding: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
li {
display: flex;
align-items: center;
gap: 14px;
padding: 6px 8px;
border-radius: var(--radius-m);
transition: background-color var(--t-base) var(--ease-glide);
}
li:hover { background: var(--surface); }
li.at { background: var(--surface-alt); box-shadow: inset 2px 0 0 var(--light); }
footer { display: flex; align-items: center; gap: 4px; font-size: 10px; }
footer .kbd { margin-right: 2px; }
footer .gap { width: 14px; }
@media (max-width: 767px), (pointer: coarse) {
footer { display: none; }
}
.kind { width: 64px; flex: none; font-size: 9px; letter-spacing: 0.14em; }
.info { display: flex; flex-direction: column; min-width: 0; flex: 1; font-size: 12px; }
.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg); font-family: var(--font-sans); font-size: 14px; }
.muted { font-size: 11px; }
.actions { display: flex; gap: 4px; flex: none; }
.danger:hover { background: var(--alarm); border-color: var(--alarm); color: var(--ink); }
</style>
+28 -2
View File
@@ -23,7 +23,7 @@ export interface NodeSummary {
excerpt: string
position: number
has_board: boolean
/** Spaces the app has to tell apart: `diary`, or nothing. */
/** Nodes the app has to tell apart: `diary`, `trash`, or nothing. */
role: string | null
child_count: number
created_at: number
@@ -78,6 +78,21 @@ export interface SshKey {
vault: boolean
}
/** Something deleted, waiting in the trash to be restored or purged. */
export interface TrashItem {
id: number
kind: NodeKind
name: string
slug: string
/** The folder it was deleted from, `work/notes`; empty for a space. */
from: string
deleted_at: number
/** When the sweep deletes it for good. */
purge_at: number
/** How many nodes are inside it. */
inside: number
}
export interface SharedDocument {
name: string
body: string
@@ -383,8 +398,19 @@ export const api = {
input: { name: string; body: string; created_at?: number; has_board?: boolean; base_updated_at?: number },
) => request<WorkspaceNode>(`/nodes/${id}`, { method: 'PUT', body: body(input) }),
/** Into the trash; `purged` when it was in the trash already and is now gone. */
deleteNode: (id: number) =>
request<{ ok: true; removed: number }>(`/nodes/${id}`, { method: 'DELETE' }),
request<{ ok: true; removed: number; purged: boolean }>(`/nodes/${id}`, { method: 'DELETE' }),
trash: () => request<TrashItem[]>('/trash'),
/** Back where it was; `elsewhere` when that folder is gone and it went to its space. */
restore: (id: number) =>
request<{ node: WorkspaceNode; elsewhere: boolean }>(`/trash/${id}/restore`, { method: 'POST' }),
purge: (id: number) => request<{ ok: true; removed: number }>(`/trash/${id}`, { method: 'DELETE' }),
emptyTrash: () => request<{ ok: true; removed: number }>('/trash', { method: 'DELETE' }),
moveNode: (id: number, parent_id: number, position?: number) =>
request<WorkspaceNode>(`/nodes/${id}/move`, { method: 'POST', body: body({ parent_id, position }) }),
+21 -4
View File
@@ -1,6 +1,7 @@
import { tick } from 'svelte'
import { api, ApiError, isContainer, type BackupLogin, type BackupStatus } from './api'
import { workspace } from './store.svelte'
import { emptyTrash } from './Trash.svelte'
import { formatDay, parseDay, parseDue, relative, sealedNote, type Heading, type ScrollAmount, type SidewaysAmount } from './util'
export type { ScrollAmount, SidewaysAmount }
@@ -693,7 +694,7 @@ export const commands: CommandSpec[] = [
aliases: ['d', 'rm'],
args: '[id]',
group: 'documents',
help: 'delete a node, and everything under it (asks first)',
help: 'delete a node, and everything under it, into the trash (asks first) — u brings it back',
run: async (arg) => {
// On the board the thing under the cursor is a card, not a node.
if (workspace.view === 'board') {
@@ -712,7 +713,7 @@ export const commands: CommandSpec[] = [
node && isContainer(node)
? `delete the ${node.kind} ${name} and everything in it?`
: `delete ${name}?`
if (!(await workspace.ask(`${what} this cannot be undone.`))) return
if (!(await workspace.ask(`${what} it goes to the trash.`))) return
await workspace.guard(() => workspace.deleteNode(id))
},
},
@@ -761,7 +762,7 @@ export const commands: CommandSpec[] = [
aliases: ['rms'],
args: '[name]',
group: 'tree',
help: 'delete a space and everything in it (asks first)',
help: 'delete a space and everything in it, into the trash (asks first)',
complete: spaceNames,
run: async (arg) => {
const wanted = arg.trim().toLowerCase()
@@ -769,12 +770,28 @@ export const commands: CommandSpec[] = [
? workspace.spaces.find((s) => s.slug === wanted || s.name.toLowerCase() === wanted)
: workspace.space
if (!space) return workspace.say(`no such space: ${wanted || '(none open)'}`, 'error')
if (!(await workspace.ask(`delete the space "${space.name}" and everything in it? this cannot be undone.`))) {
if (!(await workspace.ask(`delete the space "${space.name}" and everything in it? it goes to the trash.`))) {
return
}
await workspace.guard(() => workspace.deleteSpace(space.id))
},
},
{
name: 'trash',
args: '[purge]',
group: 'tree',
help: 'what was deleted — j/k, Enter brings it back, x deletes it for good; :trash purge empties it',
complete: () => ['purge'],
run: async (arg) => {
if (arg.trim() === 'purge') return emptyTrash()
if (arg.trim()) return workspace.say('usage: :trash [purge]', 'error')
await workspace.guard(async () => {
await workspace.loadTrash()
workspace.trashCursor = 0
workspace.overlay = 'trash'
})
},
},
{
name: 'date',
args: '<yyyy-mm-dd>',
+77 -4
View File
@@ -12,6 +12,7 @@ import {
type MediaFile,
type NodeSummary,
type SpaceOverview,
type TrashItem,
type VaultStatus,
} from './api'
import { formatBytes, isTouchDevice, sealedNote, type Heading } from './util'
@@ -278,7 +279,7 @@ class Workspace {
pane = $state<Pane>('list')
flash = $state<Flash>(null)
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline'>('none')
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline' | 'trash'>('none')
/** `gO`: the headings of what is on screen, to jump between from the keyboard. */
outline = $state.raw<Heading[]>([])
outlineCursor = $state(0)
@@ -305,6 +306,11 @@ class Workspace {
vault = $state<VaultStatus | null>(null)
media = $state<MediaFile[]>([])
mediaCursor = $state(0)
/** What `:trash` lists: everything deleted, the last first. */
trash = $state<TrashItem[]>([])
trashCursor = $state(0)
/** The last thing deleted here, which `u` puts back. */
lastDeleted = $state<number | null>(null)
overview = $state<SpaceOverview[]>([])
overviewCursor = $state(0)
@@ -998,13 +1004,67 @@ class Workspace {
for (const tile of showing) tile.stopAutosave()
this.dropStash(id)
const node = showing[0]?.open ?? this.nodes.find((n) => n.id === id)
const { removed } = await api.deleteNode(id)
const { removed, purged } = await api.deleteNode(id)
for (const tile of showing) tile.clear()
this.prune()
this.marked = this.marked.filter((n) => n.id !== id)
await this.refresh()
const name = node ? `"${node.name.trim() || node.slug}"` : `#${id}`
this.say(removed > 1 ? `deleted ${name} and ${removed - 1} inside it` : `deleted ${name}`)
if (purged) return this.say(`deleted ${name} for good`)
this.lastDeleted = id
const what = removed > 1 ? `${name} and ${removed - 1} inside it` : name
this.say(`${what} went to the trash — u brings it back`)
}
/** `u`: the last delete, undone. */
async undoDelete() {
const id = this.lastDeleted
if (id === null) return this.say('nothing deleted here to undo — :trash has the rest', 'error')
await this.restore(id)
}
/**
* Out of the trash, back where it was. A space is entered again; anything
* else in the space being browsed gets the cursor, so `u` lands on what it
* brought back.
*/
async restore(id: number) {
const { node, elsewhere } = await api.restore(id)
if (this.lastDeleted === id) this.lastDeleted = null
const name = `"${node.name.trim() || node.slug}"`
if (node.kind === 'space') {
this.spaces = await api.listSpaces()
if (this.overlay !== 'trash') await this.enterSpace(node.id)
this.say(`restored the space ${name}`)
} else {
const parent = node.path.at(-2)
if (parent && node.path[0]?.id === this.spaceId && this.view === 'tree') {
if (parent.id === this.parentId && !this.query) await this.refresh(node.id)
else if (this.overlay !== 'trash') await this.goTo(parent.id, node.id)
}
const where = node.path.slice(0, -1).map((crumb) => crumb.slug).join('/')
this.say(elsewhere ? `restored ${name} to ${where} — the folder it was in is gone` : `restored ${name} to ${where}`)
}
if (this.overlay === 'trash') await this.loadTrash()
}
async loadTrash() {
this.trash = await api.trash()
this.trashCursor = Math.min(this.trashCursor, Math.max(this.trash.length - 1, 0))
}
/** Delete one thing in the trash for good, files and all. */
async purge(id: number) {
await api.purge(id)
if (this.lastDeleted === id) this.lastDeleted = null
await this.loadTrash()
}
async emptyTrash() {
const { removed } = await api.emptyTrash()
this.lastDeleted = null
if (this.overlay === 'trash') await this.loadTrash()
this.say(removed === 1 ? 'the trash is empty — 1 thing deleted for good' : `the trash is empty — ${removed} things deleted for good`)
}
/**
@@ -1038,6 +1098,7 @@ class Workspace {
*/
async deleteSpace(id: number) {
const { removed } = await api.deleteNode(id)
this.lastDeleted = id
for (const tile of this.tiles) tile.clear()
this.prune()
this.rawBoard = null
@@ -1053,7 +1114,7 @@ class Workspace {
this.nodes = []
this.cursor = 0
}
this.say(`deleted the space and ${removed - 1} node(s) in it`)
this.say(`the space and ${removed - 1} node(s) in it went to the trash — u brings it back`)
}
/** Reparent the node under the cursor, for `:mv`. */
@@ -1443,6 +1504,18 @@ class Workspace {
const space = this.spaceId
if (!card || space === null) return
let nodeId = card.node_id
if (nodeId !== null) {
// Deleted notes read as gone, but the card still points at them, so
// the way back to them is named rather than a bare "not found".
try {
await api.getNode(nodeId)
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
return this.say(`the notes of "${card.title}" are gone — :trash may still have them`, 'error')
}
throw error
}
}
if (nodeId === null) {
const node = await api.createNode({ parent_id: space, name: card.title, body: '' })
await api.updateCard(card.id, { node_id: node.id })