mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
439d5b083b
commit
8ffad6350f
22 files changed
+1154
-78
No files matched your search
@@ -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
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -120,7 +120,7 @@ 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());
|
||||
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
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 }]
|
||||
: []),
|
||||
|
||||
@@ -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
@@ -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
@@ -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>',
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in new issue
Block a user