From 2103edc29960ddf57bde4307158ba2cdc5ed0758 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 19 Oct 2026 15:44:30 +0200 Subject: [PATCH] files: a node kind of their own, in the tree beside the notes A file node points at one media row and owns it the way a document owns what it embeds, so folder deletes, the media browser and exports keep working. POST /api/nodes/{id}/files streams uploads into a folder; imports turn non-markdown files into file nodes; exports put them at their tree path. The browser previews images, video, audio and text, offers the rest as a download, and takes files dropped on the list. Migration 0010 widens the kind CHECK by editing the stored schema, as SQLite documents for loosening a constraint: sqlx runs migrations in a transaction, where foreign keys cannot be switched off for a rebuild. --- README.md | 27 ++++- migrations/0010_files.sql | 46 ++++++++ src/routes/export.rs | 94 ++++++++++++--- src/routes/import.rs | 53 +++++---- src/routes/media.rs | 104 +++++++++++++++++ src/routes/mod.rs | 1 + src/routes/nodes.rs | 188 +++++++++++++++++++++++++----- src/sftp/fs.rs | 7 +- web/src/App.svelte | 16 ++- web/src/app.css | 10 +- web/src/lib/FilePane.svelte | 221 ++++++++++++++++++++++++++++++++++++ web/src/lib/Sidebar.svelte | 46 ++++++-- web/src/lib/api.ts | 39 ++++++- web/src/lib/commands.ts | 15 ++- web/src/lib/store.svelte.ts | 35 +++++- 15 files changed, 808 insertions(+), 94 deletions(-) create mode 100644 migrations/0010_files.sql create mode 100644 web/src/lib/FilePane.svelte diff --git a/README.md b/README.md index 62f1aed..f0f27c5 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ one binary. It was called `narl-diary` until it grew everything but the diary. - Import a folder — or a zip of one — as a tree of documents, links and all. - A kanban board per space, so what is open at work is not mixed up with uni. - Drag, drop or paste images, video and audio straight into an entry. +- Files of any kind — PDFs, archives, recordings — live in the tree beside the + notes about them, streamed to disk however large. - Share any document behind an unguessable link — revocable at any time. - Backs itself up to Proton Drive, end-to-end encrypted, as a registered device. - Mails you — a reminder when the diary is empty, due cards, a weekly digest. @@ -84,7 +86,8 @@ mode, macros, `:w` to write. `:help` lists every ex command; the useful ones are makes one, `:rmspace [name]` deletes one and everything in it), `:mkdir ` makes a folder, `:cd ` walks it and `:mv ` moves what is selected, and `:import` uploads a folder into the one being browsed -(`:import!` takes a `.zip` instead). `:name ` renames whatever is under +(`:import!` takes a `.zip` instead). `:upload` outside the editor puts files +into the folder being browsed, and so does dropping them on the list. `:name ` renames whatever is under the cursor — a document, a folder, or a card on a board — and `:name!` renames the space itself. The board has `:board`, `:card ` (`:card!` links the open document to it), @@ -152,15 +155,31 @@ says so — `:e!` throws it away, the way vim handles a swap file. `:export` downloads the open document as markdown. `:export!` downloads the whole workspace as a zip: the tree as real directories, one markdown file per -document, every embedded file under its real name, and relative links between -them, so the archive reads in any markdown viewer without this application. +document, every file in the tree where it sits and every other embedded file in +`media/`, under their real names, and relative links between them, so the +archive reads in any markdown viewer without this application. + +## Files + +A file is a node like a document, only with bytes instead of text: it sits in +a folder, shows its size in the list, and `Enter` opens it — images, video, +audio and plain text are shown in place, anything else is a download. `:name` +renames it, and the download is called whatever it is called now; `:mv`, `m` +and `p` move it; `x` deletes it. `:upload` picks files for the folder being +browsed, or drop them onto the list. Each upload is written to disk as it +arrives, never held in memory, so the only ceiling is `WORKSPACE_MAX_UPLOAD_MB` +(1024 by default) — which is per request, so a proxy in front needs a body +limit at least that large too. + +The SFTP mount does not show files yet; a folder that holds only files lists +as empty there. ## Importing a folder `:import` picks a folder and uploads it whole — every file carries the path it had inside, and the tree is rebuilt on the other side: directories become folders, `.md` and `.markdown` files become documents (titled by their first -heading), anything else becomes an attached file. `:import!` does the same with +heading), anything else becomes a file in the tree. `:import!` does the same with a zip, which is the shape `:export!` writes, so a workspace can be poured back into another one. diff --git a/migrations/0010_files.sql b/migrations/0010_files.sql new file mode 100644 index 0000000..db13b1f --- /dev/null +++ b/migrations/0010_files.sql @@ -0,0 +1,46 @@ +-- Files join the tree. A PDF or a zip used to exist only as media embedded in +-- a document; now it can sit in a folder beside the notes about it, as a node +-- of its own kind that points at one row of `media`. + +-- Files only: the bytes. Deleting the media row — `rm` in the media browser — +-- takes the file out of the tree with it. +ALTER TABLE nodes ADD COLUMN media_id TEXT REFERENCES media (id) ON DELETE CASCADE + CHECK ((kind = 'file') = (media_id IS NOT NULL)); + +-- `kind` has to admit 'file', and SQLite cannot alter a CHECK constraint. The +-- usual way round is rebuilding the table with foreign keys off, but sqlx runs +-- every migration inside a transaction, where that pragma does nothing — and +-- dropping `nodes` with them on cascades into every card, list and attachment. +-- +-- Widening a CHECK changes nothing on disk, though, so SQLite documents editing +-- the stored definition instead. RESET re-reads the schema on this connection; +-- the CREATE INDEX after it bumps the schema cookie, which makes every other +-- connection re-read it too. The INSERT further down is the proof: it fails, +-- and with it the whole migration, unless the new constraint is in force. +PRAGMA writable_schema = ON; +UPDATE sqlite_schema +SET sql = replace(sql, '''folder'', ''document'')', '''folder'', ''document'', ''file'')') +WHERE type = 'table' AND name = 'nodes'; +PRAGMA writable_schema = RESET; + +CREATE INDEX idx_nodes_media ON nodes (media_id) WHERE media_id IS NOT NULL; + +-- An import used to hang a file that no document linked to on the folder it +-- arrived in, where only the media browser could see it. Each of those becomes +-- a file node in that folder. The slug cannot be derived from the name in SQL, +-- so it is the start of the media id: unique, and renaming fixes it for good. +INSERT INTO nodes (parent_id, kind, name, slug, created_at, updated_at, media_id) +SELECT nm.node_id, 'file', m.filename, 'file-' || substr(m.id, 1, 8), + m.created_at, m.created_at, m.id +FROM node_media nm +JOIN nodes n ON n.id = nm.node_id +JOIN media m ON m.id = nm.media_id +WHERE n.kind IN ('space', 'folder'); + +-- The file node owns its bytes the same way a document owns what it embeds, +-- so deleting a folder still finds every file that goes with it. +DELETE FROM node_media +WHERE node_id IN (SELECT id FROM nodes WHERE kind IN ('space', 'folder')); + +INSERT INTO node_media (node_id, media_id) +SELECT id, media_id FROM nodes WHERE kind = 'file'; diff --git a/src/routes/export.rs b/src/routes/export.rs index 78adfa4..3ca0f05 100644 --- a/src/routes/export.rs +++ b/src/routes/export.rs @@ -23,14 +23,20 @@ struct ExportNode { name: String, slug: String, body: String, + media_id: Option<String>, } /// Where a document lands in the archive: the slugs of its ancestors as /// directories, its own slug as the file. Slugs are already unique among /// siblings, so the path is unique without adding the id to it. fn document_path(nodes: &HashMap<i64, ExportNode>, node: &ExportNode) -> String { - let mut segments = vec![format!("{}.md", node.slug)]; - let mut parent = node.parent_id; + tree_path(nodes, node.parent_id, format!("{}.md", node.slug)) +} + +/// `leaf` under the directories of a node's ancestors. +fn tree_path(nodes: &HashMap<i64, ExportNode>, parent: Option<i64>, leaf: String) -> String { + let mut segments = vec![leaf]; + let mut parent = parent; // Bounded by the number of nodes, so a parent chain that somehow loops // cannot spin here. for _ in 0..nodes.len() { @@ -78,15 +84,19 @@ fn day_string(seconds: i64) -> String { format!("{y:04}-{m:02}-{d:02}") } -/// Uploads are stored under a uuid; the archive gives them their name back. /// Only characters that are safe in a path *and* need no escaping inside a /// markdown link survive, because the entries link to these names directly. -fn media_filename(id: &str, original: &str) -> String { +fn safe_name(original: &str) -> String { let safe: String = original .chars() .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '_' }) .collect(); - let safe = safe.trim_matches(['.', '_']).to_string(); + safe.trim_matches(['.', '_']).to_string() +} + +/// Uploads are stored under a uuid; the archive gives them their name back. +fn media_filename(id: &str, original: &str) -> String { + let safe = safe_name(original); if safe.is_empty() { id.to_string() } else { @@ -94,12 +104,32 @@ fn media_filename(id: &str, original: &str) -> String { } } +/// Where a file node lands: in its folder, under its own name. Two files may +/// share a name where they can never share a slug, so the loser of a clash is +/// told apart by its node id. +fn file_path( + nodes: &HashMap<i64, ExportNode>, + node: &ExportNode, + taken: &mut std::collections::HashSet<String>, +) -> String { + let safe = safe_name(&node.name); + let name = if safe.is_empty() { node.slug.clone() } else { safe }; + let path = tree_path(nodes, node.parent_id, name.clone()); + if taken.insert(path.clone()) { + return path; + } + let path = tree_path(nodes, node.parent_id, format!("{}-{name}", node.id)); + taken.insert(path.clone()); + path +} + const README: &str = "\ This is a complete copy of a ~/workspace. - <space>/ one directory per space, holding the same tree of folders and - documents that the workspace shows; one markdown file per document - media/ every file the documents embed + <space>/ one directory per space, holding the same tree of folders, + documents and files that the workspace shows; one markdown file + per document, and every file under its own name + media/ the files documents embed that are not in the tree themselves Embedded media, and links from one document to another, are relative — so the documents render and cross-link correctly in any markdown reader as long as the @@ -110,7 +140,7 @@ tree stays intact. Nothing here needs the workspace application to read it. /// 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 FROM nodes ORDER BY created_at, id", + "SELECT id, parent_id, kind, name, slug, body, media_id FROM nodes ORDER BY created_at, id", ) .fetch_all(&state.db) .await? @@ -123,6 +153,7 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp name: row.get("name"), slug: row.get("slug"), body: row.get("body"), + media_id: row.get("media_id"), }; (node.id, node) }) @@ -150,8 +181,19 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp zip.start_file("README.txt", text)?; zip.write_all(README.as_bytes())?; + // A file node's bytes go where the node is; the rest go to media/. + // Ordered by id, so a clash of names resolves the same way every time. + let mut files: Vec<&ExportNode> = nodes.values().filter(|n| n.media_id.is_some()).collect(); + files.sort_by_key(|n| n.id); + let mut taken = std::collections::HashSet::new(); + let in_tree: HashMap<String, String> = files + .into_iter() + .filter_map(|n| Some((n.media_id.clone()?, file_path(&nodes, n, &mut taken)))) + .collect(); + let names: Vec<(String, String)> = media .iter() + .filter(|(id, _)| !in_tree.contains_key(id)) .map(|(id, filename)| (id.clone(), media_filename(id, filename))) .collect(); @@ -176,6 +218,9 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp for (id, name) in &names { body = body.replace(&format!("/api/media/{id}"), &format!("{up}media/{name}")); } + for (id, target) in &in_tree { + body = body.replace(&format!("/api/media/{id}"), &relative_link(&path, target)); + } for (address, target) in &addresses { // Matching the `)` or `#` that ends a markdown link is what // keeps `/n/work/a` from matching inside `/n/work/ab`. @@ -192,13 +237,17 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp zip.write_all(document.as_bytes())?; } - for (id, name) in &names { + let entries = names + .iter() + .map(|(id, name)| (id, format!("media/{name}"))) + .chain(in_tree.iter().map(|(id, path)| (id, path.clone()))); + for (id, entry) in entries { // A row whose file has gone missing must not fail the whole backup. - let Ok(bytes) = std::fs::read(uploads.join(id)) else { + let Ok(mut source) = std::fs::File::open(uploads.join(id)) else { continue; }; - zip.start_file(format!("media/{name}"), blob)?; - zip.write_all(&bytes)?; + zip.start_file(entry, blob)?; + std::io::copy(&mut source, &mut zip)?; } let mut file = zip.finish()?; @@ -229,7 +278,7 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp mod tests { use std::collections::HashMap; - use super::{day_string, document_path, media_filename, relative_link, ExportNode}; + use super::{day_string, document_path, file_path, media_filename, relative_link, ExportNode}; #[test] fn renders_days_without_a_date_crate() { @@ -251,12 +300,29 @@ mod tests { name: (*slug).into(), slug: (*slug).into(), body: String::new(), + media_id: (*kind == "file").then(|| format!("m{id}")), }, ) }) .collect() } + #[test] + fn a_file_keeps_its_name_and_a_clash_gets_its_id() { + let mut nodes = tree(&[ + (1, None, "space", "uni"), + (2, Some(1), "folder", "algo"), + (3, Some(2), "file", "slides-pdf"), + (4, Some(2), "file", "slides-pdf-2"), + ]); + nodes.get_mut(&3).unwrap().name = "Slides 3.pdf".into(); + nodes.get_mut(&4).unwrap().name = "Slides 3.pdf".into(); + + let mut taken = std::collections::HashSet::new(); + assert_eq!(file_path(&nodes, &nodes[&3], &mut taken), "uni/algo/Slides_3.pdf"); + assert_eq!(file_path(&nodes, &nodes[&4], &mut taken), "uni/algo/4-Slides_3.pdf"); + } + #[test] fn a_document_keeps_its_place_in_the_tree() { let nodes = tree(&[ diff --git a/src/routes/import.rs b/src/routes/import.rs index 2e34a77..fc4d42f 100644 --- a/src/routes/import.rs +++ b/src/routes/import.rs @@ -4,7 +4,7 @@ //! `docs/`, a folder of lecture notes, an export of this very diary. Storing it //! as opaque files would make it unsearchable and uneditable, so the importer //! reproduces the directory as nodes: directories become folders, markdown -//! becomes documents, everything else becomes media. The links between the +//! becomes documents, everything else becomes a file. The links between the //! files are rewritten as they land, because a set of documents that can no //! longer reach each other is a worse copy of what was uploaded. @@ -25,7 +25,7 @@ use crate::{ error::{AppError, AppResult}, routes::{ media, - nodes::{self, next_slug, slugify, DOCUMENT, FOLDER}, + nodes::{self, next_slug, slugify, DOCUMENT, FILE, FOLDER}, }, state::AppState, }; @@ -434,7 +434,7 @@ pub async fn import( multipart: Multipart, ) -> AppResult<Json<ImportReport>> { let target = nodes::load(&state, id).await?; - if target.kind == DOCUMENT { + if !nodes::is_container(&target.kind) { return Err(AppError::BadRequest( "a folder can only be imported into a space or a folder".into(), )); @@ -597,6 +597,35 @@ async fn build( .execute(&mut *tx) .await?; + // The file sits in the tree under the name it had, and owns its + // bytes the way a document owns what it embeds — so deleting the + // folder it landed in takes it along. + let name = file_name(&upload.path); + let taken = siblings.entry(parent).or_default(); + let slug = next_slug(taken, &slugify(name))?; + taken.insert(slug.clone()); + + let node: i64 = sqlx::query( + "INSERT INTO nodes (parent_id, kind, name, slug, created_at, updated_at, media_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?5, ?6) + RETURNING id", + ) + .bind(parent) + .bind(FILE) + .bind(name) + .bind(&slug) + .bind(now) + .bind(media_id) + .fetch_one(&mut *tx) + .await? + .get("id"); + + sqlx::query("INSERT INTO node_media (node_id, media_id) VALUES (?1, ?2)") + .bind(node) + .bind(media_id) + .execute(&mut *tx) + .await?; + landed.insert( upload.path.clone(), Landed::Media(format!("/api/media/{media_id}")), @@ -608,7 +637,6 @@ async fn build( // Second pass: point the links at where things landed, and attach every // file a document embeds to it the way a hand-written document does. let mut links = 0usize; - let mut attached: BTreeSet<String> = BTreeSet::new(); for (id, path, body) in &documents { let (rewritten, count) = rewrite_links(body, parent_of(path), &landed); @@ -632,23 +660,6 @@ async fn build( .bind(&media_id) .execute(&mut *tx) .await?; - attached.insert(media_id); - } - } - - // A file nobody links to still belongs to the folder it was uploaded in, - // or deleting that folder would leave it behind with no way back to it. - for upload in &uploads { - if let Some(Landed::Media(url)) = landed.get(&upload.path) { - let media_id = url.trim_start_matches("/api/media/").to_string(); - if attached.contains(&media_id) { - continue; - } - sqlx::query("INSERT OR IGNORE INTO node_media (node_id, media_id) VALUES (?1, ?2)") - .bind(dir_ids[parent_of(&upload.path)]) - .bind(&media_id) - .execute(&mut *tx) - .await?; } } diff --git a/src/routes/media.rs b/src/routes/media.rs index 66277af..8f2c6a6 100644 --- a/src/routes/media.rs +++ b/src/routes/media.rs @@ -17,6 +17,7 @@ use uuid::Uuid; use crate::{ auth::Session, error::{AppError, AppResult}, + routes::nodes, state::AppState, }; @@ -213,6 +214,109 @@ pub async fn upload( Ok(Json(uploaded)) } +/// `POST /api/nodes/{id}/files` — every file in the body becomes a file node +/// in that space or folder, under the name it was uploaded with. +pub async fn upload_into( + _: Session, + State(state): State<AppState>, + Path(parent): Path<i64>, + mut multipart: Multipart, +) -> AppResult<Json<Vec<nodes::Node>>> { + nodes::container(&state.db, parent).await?; + + let mut created = Vec::new(); + let mut budget = state.config.max_upload_bytes as u64; + + while let Some(mut field) = multipart + .next_field() + .await + .map_err(|e| AppError::BadRequest(format!("malformed upload: {e}")))? + { + // A browser sends a bare name; anything path-shaped is cut to its last + // segment, because the folder is the one in the URL. + let filename = field + .file_name() + .map(|name| name.rsplit(['/', '\\']).next().unwrap_or(name).trim().to_string()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "upload".to_string()); + let mime = mime_of(&filename, field.content_type()); + + let id = Uuid::new_v4().to_string(); + let size = store_field(&state, &mut field, &id, &mut budget).await?; + if size == 0 { + continue; + } + + match insert_file(&state.db, parent, &id, &filename, &mime, size).await { + Ok(node) => created.push(node), + Err(e) => { + let _ = tokio::fs::remove_file(state.config.uploads_dir().join(&id)).await; + return Err(e); + } + } + } + + if created.is_empty() { + return Err(AppError::BadRequest("no files in upload".into())); + } + state.backup.signal(); + + let mut out = Vec::with_capacity(created.len()); + for id in created { + out.push(nodes::load(&state, id).await?); + } + Ok(Json(out)) +} + +/// The media row and the file node that owns it, in one transaction so the +/// tree never shows a file whose bytes it does not know about. +async fn insert_file( + db: &SqlitePool, + parent: i64, + media_id: &str, + filename: &str, + mime: &str, + size: u64, +) -> AppResult<i64> { + let slug = nodes::unique_slug(db, Some(parent), &nodes::slugify(filename), None).await?; + let now = crate::now(); + let mut tx = db.begin().await?; + + sqlx::query( + "INSERT INTO media (id, filename, mime, size, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", + ) + .bind(media_id) + .bind(filename) + .bind(mime) + .bind(size as i64) + .bind(now) + .execute(&mut *tx) + .await?; + + let id: i64 = sqlx::query( + "INSERT INTO nodes (parent_id, kind, name, slug, created_at, updated_at, media_id) + VALUES (?1, 'file', ?2, ?3, ?4, ?4, ?5) + RETURNING id", + ) + .bind(parent) + .bind(filename) + .bind(&slug) + .bind(now) + .bind(media_id) + .fetch_one(&mut *tx) + .await? + .get("id"); + + sqlx::query("INSERT INTO node_media (node_id, media_id) VALUES (?1, ?2)") + .bind(id) + .bind(media_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(id) +} + pub async fn list(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<Media>>> { let rows = sqlx::query( "SELECT m.id, m.filename, m.mime, m.size, m.created_at, diff --git a/src/routes/mod.rs b/src/routes/mod.rs index e225a5f..2e5eea4 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -38,6 +38,7 @@ pub fn api_router() -> Router<AppState> { .route("/nodes/{id}", delete(nodes::remove)) .route("/nodes/{id}/move", post(nodes::move_node)) .route("/nodes/{id}/import", post(import::import)) + .route("/nodes/{id}/files", post(media::upload_into)) .route("/nodes/{id}/share", post(share::enable)) .route("/nodes/{id}/share", delete(share::disable)) .route("/media", post(media::upload).get(media::list)) diff --git a/src/routes/nodes.rs b/src/routes/nodes.rs index 3f214e6..8edef41 100644 --- a/src/routes/nodes.rs +++ b/src/routes/nodes.rs @@ -16,10 +16,26 @@ use crate::{ /// What a node is. Spaces are the roots (`diary`, `work`, `uni`, ...), folders /// group things inside them to any depth, documents are the leaves that hold -/// text. One table, one recursion, so a space can nest exactly like a folder. +/// text and files the leaves that hold anything else. One table, one recursion, +/// so a space can nest exactly like a folder. pub const SPACE: &str = "space"; pub const FOLDER: &str = "folder"; pub const DOCUMENT: &str = "document"; +pub const FILE: &str = "file"; + +/// Whether a node of this kind can hold other nodes. +pub fn is_container(kind: &str) -> bool { + kind == SPACE || kind == FOLDER +} + +/// The bytes behind a file node, as the client needs them to show or fetch it. +#[derive(Serialize)] +pub struct FileInfo { + pub media_id: String, + pub mime: String, + pub size: i64, + pub url: String, +} #[derive(Serialize)] pub struct NodeSummary { @@ -36,6 +52,8 @@ pub struct NodeSummary { pub updated_at: i64, pub shared: bool, pub share_token: Option<String>, + /// Files only. + pub file: Option<FileInfo>, } #[derive(Serialize)] @@ -56,6 +74,7 @@ pub struct Node { pub updated_at: i64, pub shared: bool, pub share_token: Option<String>, + pub file: Option<FileInfo>, /// This node's ancestors, the space first and the node itself last — the /// breadcrumb, and everything needed to build its /n/... path. pub path: Vec<Crumb>, @@ -136,8 +155,10 @@ const EXCERPT_CHARS: usize = 180; /// The columns every list query selects, so a row always converts the same way. const SUMMARY_COLUMNS: &str = "n.id, n.parent_id, n.kind, n.name, n.slug, n.position, n.has_board, - n.created_at, n.updated_at, n.share_token, - (SELECT COUNT(*) FROM nodes c WHERE c.parent_id = n.id) AS child_count"; + n.created_at, n.updated_at, n.share_token, n.media_id, + (SELECT COUNT(*) FROM nodes c WHERE c.parent_id = n.id) AS child_count, + (SELECT m.mime FROM media m WHERE m.id = n.media_id) AS file_mime, + (SELECT m.size FROM media m WHERE m.id = n.media_id) AS file_size"; /// A short, plain-ish preview of the body for the list pane. `head` is only the /// leading `HEAD_CHARS` of the document, so truncation is inferred from its @@ -258,6 +279,14 @@ pub async fn unique_slug( fn row_to_summary(row: &sqlx::sqlite::SqliteRow, head: &str) -> NodeSummary { let share_token: Option<String> = row.get("share_token"); + let file = row + .get::<Option<String>, _>("media_id") + .map(|media_id| FileInfo { + url: format!("/api/media/{media_id}"), + media_id, + mime: row.get::<Option<String>, _>("file_mime").unwrap_or_default(), + size: row.get::<Option<i64>, _>("file_size").unwrap_or_default(), + }); NodeSummary { id: row.get("id"), parent_id: row.get("parent_id"), @@ -272,6 +301,7 @@ fn row_to_summary(row: &sqlx::sqlite::SqliteRow, head: &str) -> NodeSummary { updated_at: row.get("updated_at"), shared: share_token.is_some(), share_token, + file, } } @@ -492,17 +522,7 @@ pub async fn create( ))); } - let parent_kind: String = sqlx::query("SELECT kind FROM nodes WHERE id = ?1") - .bind(input.parent_id) - .fetch_optional(&state.db) - .await? - .ok_or(AppError::NotFound)? - .get("kind"); - if parent_kind == DOCUMENT { - return Err(AppError::BadRequest( - "a document cannot contain other nodes".into(), - )); - } + container(&state.db, input.parent_id).await?; let now = crate::now(); let created_at = input.created_at.unwrap_or(now); @@ -565,6 +585,17 @@ pub async fn update( return Err(AppError::BadRequest("only a space has a board".into())); } + // A file has no text, only a name, and the name is also what a download + // of it is called. + let body = if kind == FILE { "" } else { input.body.as_str() }; + if kind == FILE { + sqlx::query("UPDATE media SET filename = ?1 WHERE id = (SELECT media_id FROM nodes WHERE id = ?2)") + .bind(name) + .bind(id) + .execute(&state.db) + .await?; + } + let affected = sqlx::query( "UPDATE nodes SET name = ?1, body = ?2, slug = ?3, updated_at = ?4, @@ -573,7 +604,7 @@ pub async fn update( WHERE id = ?7", ) .bind(name) - .bind(&input.body) + .bind(body) .bind(&slug) .bind(crate::now()) .bind(input.created_at) @@ -587,7 +618,9 @@ pub async fn update( return Err(AppError::NotFound); } - media::link_to_node(&state.db, id, &input.body).await?; + if kind != FILE { + media::link_to_node(&state.db, id, body).await?; + } state.backup.signal(); load(&state, id).await.map(Json) } @@ -635,17 +668,7 @@ pub async fn move_node( return Err(AppError::BadRequest("a node cannot contain itself".into())); } - let target_kind: String = sqlx::query("SELECT kind FROM nodes WHERE id = ?1") - .bind(input.parent_id) - .fetch_optional(&state.db) - .await? - .ok_or(AppError::NotFound)? - .get("kind"); - if target_kind == DOCUMENT { - return Err(AppError::BadRequest( - "a document cannot contain other nodes".into(), - )); - } + container(&state.db, input.parent_id).await?; // Moving a folder into its own subtree would cut that subtree loose from // every root, where nothing could reach it again. @@ -688,6 +711,22 @@ pub async fn move_node( load(&state, id).await.map(Json) } +/// Refuse anything but a space or a folder as the place a node goes into. +pub async fn container(db: &SqlitePool, id: i64) -> AppResult<()> { + let kind: String = sqlx::query("SELECT kind FROM nodes WHERE id = ?1") + .bind(id) + .fetch_optional(db) + .await? + .ok_or(AppError::NotFound)? + .get("kind"); + if !is_container(&kind) { + return Err(AppError::BadRequest(format!( + "a {kind} cannot contain other nodes" + ))); + } + Ok(()) +} + /// A node and everything beneath it, the node itself first. pub async fn descendants(db: &SqlitePool, id: i64) -> AppResult<Vec<i64>> { let rows = sqlx::query( @@ -758,6 +797,7 @@ pub async fn load(state: &AppState, id: i64) -> AppResult<Node> { updated_at: summary.updated_at, shared: summary.shared, share_token: summary.share_token, + file: summary.file, path: path_of(&state.db, id).await?, }) } @@ -816,6 +856,100 @@ mod tests { assert_eq!(slugs, ["b", "c"]); } + /// 0010 widens a CHECK by editing the stored schema. Every connection — + /// not just the one that ran it — has to accept a file node afterwards, + /// the database has to stay intact, and a file an old import hung on a + /// folder has to come out as a file node in that folder. + #[tokio::test] + async fn files_join_the_tree_on_a_database_that_predates_them() { + use sqlx::{sqlite::SqliteConnectOptions, Connection, Executor}; + + let dir = tempfile::tempdir().expect("tempdir"); + let db = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(2) + .connect_with( + SqliteConnectOptions::new() + .filename(dir.path().join("t.db")) + .create_if_missing(true) + .foreign_keys(true), + ) + .await + .expect("a database"); + + let mut before = sqlx::migrate!("./migrations"); + before.migrations = before.migrations.iter().filter(|m| m.version < 10).cloned().collect(); + before.run(&db).await.expect("migrations up to 0009"); + + let folder: i64 = sqlx::query_scalar( + "INSERT INTO nodes (parent_id, kind, name, slug, created_at, updated_at) + VALUES ((SELECT id FROM nodes WHERE slug = 'uni'), 'folder', 'algo', 'algo', 1, 1) + RETURNING id", + ) + .fetch_one(&db) + .await + .expect("a folder"); + db.execute( + "INSERT INTO media (id, filename, mime, size, created_at) + VALUES ('0a1b2c3d-0000-0000-0000-000000000000', 'slides.pdf', 'application/pdf', 3, 1)", + ) + .await + .expect("media"); + sqlx::query("INSERT INTO node_media (node_id, media_id) VALUES (?1, '0a1b2c3d-0000-0000-0000-000000000000')") + .bind(folder) + .execute(&db) + .await + .expect("hung on the folder"); + + // Opened before the migration, so it holds the schema as it was. + let mut stale = db.acquire().await.expect("a second connection"); + stale.ping().await.expect("ping"); + + sqlx::migrate!("./migrations").run(&db).await.expect("0010 applies"); + + let (name, slug, owned): (String, String, i64) = sqlx::query_as( + "SELECT n.name, n.slug, (SELECT COUNT(*) FROM node_media nm WHERE nm.node_id = n.id) + FROM nodes n WHERE n.kind = 'file' AND n.parent_id = ?1", + ) + .bind(folder) + .fetch_one(&db) + .await + .expect("the adopted file"); + assert_eq!((name.as_str(), slug.as_str(), owned), ("slides.pdf", "file-0a1b2c3d", 1)); + let on_folder: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM node_media WHERE node_id = ?1") + .bind(folder) + .fetch_one(&db) + .await + .expect("count"); + assert_eq!(on_folder, 0); + + db.execute("INSERT INTO media (id, filename, mime, size, created_at) VALUES ('m2', 'b.zip', 'application/octet-stream', 1, 1)") + .await + .expect("media"); + sqlx::query( + "INSERT INTO nodes (parent_id, kind, name, slug, created_at, updated_at, media_id) + VALUES (?1, 'file', 'b.zip', 'b-zip', 1, 1, 'm2')", + ) + .bind(folder) + .execute(&mut *stale) + .await + .expect("the stale connection accepts a file node"); + + // A file without bytes, or bytes on a document, is refused. + assert!(sqlx::query( + "INSERT INTO nodes (parent_id, kind, name, slug, created_at, updated_at) + VALUES (?1, 'file', 'x', 'x', 1, 1)", + ) + .bind(folder) + .execute(&db) + .await + .is_err()); + + let check: String = sqlx::query_scalar("PRAGMA integrity_check").fetch_one(&db).await.expect("check"); + assert_eq!(check, "ok"); + let dangling = sqlx::query("PRAGMA foreign_key_check").fetch_all(&db).await.expect("fk check"); + assert!(dangling.is_empty()); + } + #[test] fn strips_markdown_furniture() { let body = "# Title\n\n![pic](/api/media/x)\n\n> a quote\n- a point"; diff --git a/src/sftp/fs.rs b/src/sftp/fs.rs index 6ed1389..86f9b76 100644 --- a/src/sftp/fs.rs +++ b/src/sftp/fs.rs @@ -1,6 +1,7 @@ //! The virtual filesystem an SFTP client sees: `/<space>/<folder>/.../<doc>.md`. //! Directories are spaces and folders; the files that persist are documents, -//! always `.md`. There is no on-disk mirror — every call reads or writes +//! always `.md`. File nodes are not shown here yet: a folder holding only those +//! lists as empty, and refuses `rmdir` because it is not. There is no on-disk mirror — every call reads or writes //! `nodes` directly, so a save here is a save in the browser too. //! //! A document reads as `# <title>` followed by its body, the same shape the @@ -214,7 +215,7 @@ 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 <> 'document'", + WHERE parent_id IS ?1 AND slug = ?2 AND kind IN ('space', 'folder')", ) .bind(parent) .bind(slug) @@ -383,7 +384,7 @@ 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 ORDER BY kind, slug" + WHERE parent_id IS ?1 AND kind <> 'file' ORDER BY kind, slug" )) .bind(parent) .fetch_all(&self.db) diff --git a/web/src/App.svelte b/web/src/App.svelte index 94a522b..5dd2a98 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -6,6 +6,7 @@ import Board from './lib/Board.svelte' import CommandLine from './lib/CommandLine.svelte' import DocumentPane from './lib/DocumentPane.svelte' + import FilePane from './lib/FilePane.svelte' import Help from './lib/Help.svelte' import Login from './lib/Login.svelte' import Overview from './lib/Overview.svelte' @@ -29,6 +30,7 @@ let cmdline = $state<string | null>(null) let cmdGlobal = $state(false) let fileInput = $state<HTMLInputElement>() + let treeInput = $state<HTMLInputElement>() let folderInput = $state<HTMLInputElement>() let importInput = $state<HTMLInputElement>() let shell = $state<HTMLDivElement>() @@ -43,6 +45,7 @@ $effect(() => { hooks.pickFiles = () => fileInput?.click() + hooks.pickTreeFiles = () => treeInput?.click() hooks.pickFolder = () => folderInput?.click() hooks.pickArchive = () => importInput?.click() hooks.focusList = () => shell?.focus() @@ -118,6 +121,14 @@ }) } + async function put(event: Event) { + const input = event.target as HTMLInputElement + const files = Array.from(input.files ?? []) + input.value = '' + if (files.length === 0) return + await workspace.guard(() => workspace.putFiles(files)) + } + /* * A folder upload is the same multipart body as any other, except that each * file carries the path it had inside the folder — which is what makes the @@ -644,7 +655,9 @@ class:focused={!!workspace.open && workspace.pane === 'document'} onpointerdown={() => workspace.open && (workspace.pane = 'document')} > - {#if workspace.open} + {#if workspace.open?.kind === 'file'} + <FilePane /> + {:else if workspace.open} <DocumentPane /> {:else} <div class="placeholder"> @@ -686,6 +699,7 @@ {/if} <input class="hidden" type="file" multiple bind:this={fileInput} onchange={attach} /> +<input class="hidden" type="file" multiple bind:this={treeInput} onchange={put} /> <!-- `webkitdirectory` is set from script: it is the one attribute browsers agree on for picking a folder, and no typed attribute list carries it. --> <input class="hidden" type="file" multiple bind:this={folderInput} onchange={importFolder} /> diff --git a/web/src/app.css b/web/src/app.css index 4415ec3..845f77c 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -98,7 +98,8 @@ a:focus-visible { background: var(--light); color: var(--ink); text-decoration: * A button rests as a hairline and a label. Hover lifts it a step and lights * the label; only focus inverts — the NieR cursor, light fill and ink text. */ -button { +/* `a.button` is a link that has to look like one: open, download. */ +button, a.button { font: inherit; font-size: 11px; letter-spacing: 0.12em; @@ -114,9 +115,10 @@ button { color var(--t-base) var(--ease-glide), border-color var(--t-base) var(--ease-glide); } -button:hover { background: var(--surface-alt); color: var(--light); border-color: var(--outline); } -button:active { transform: translateY(1px); } -button:focus-visible { background: var(--light); color: var(--ink); border-color: var(--light); outline: none; } +a.button { display: inline-flex; align-items: center; justify-content: center; text-decoration: none; } +:is(button, a.button):hover { background: var(--surface-alt); color: var(--light); border-color: var(--outline); } +:is(button, a.button):active { transform: translateY(1px); } +:is(button, a.button):focus-visible { background: var(--light); color: var(--ink); border-color: var(--light); outline: none; } button:disabled { color: var(--muted); cursor: default; } input, textarea { diff --git a/web/src/lib/FilePane.svelte b/web/src/lib/FilePane.svelte new file mode 100644 index 0000000..fa50365 --- /dev/null +++ b/web/src/lib/FilePane.svelte @@ -0,0 +1,221 @@ +<script lang="ts"> + import { hooks, runCommand, type ScrollAmount } from './commands' + import Icon from './Icon.svelte' + import { workspace } from './store.svelte' + import { formatBytes, formatStamp, relative, scrollPage } from './util' + + const node = $derived(workspace.open!) + const file = $derived(node.file!) + const kind = $derived(file.mime.split('/')[0]) + /* What the browser can show by itself; everything else is a download. */ + const viewable = $derived(file.mime !== 'application/octet-stream') + + /* + * Plain text is shown inline, but only its beginning: a log file can run to + * gigabytes, and the download has the rest. + */ + const TEXT_PREVIEW = 256 * 1024 + let text = $state<string | null>(null) + $effect(() => { + text = null + if (file.mime !== 'text/plain') return + const url = file.url + void head(url, TEXT_PREVIEW).then((body) => { + if (body !== null && url === file.url) text = body + }) + }) + + /** The first `limit` bytes of a download, as text; the rest is never fetched. */ + async function head(url: string, limit: number): Promise<string | null> { + const response = await fetch(url) + if (!response.ok || !response.body) return null + const reader = response.body.getReader() + const bytes = new Uint8Array(limit) + let size = 0 + while (size < limit) { + const { done, value } = await reader.read() + if (done) break + const take = value.subarray(0, limit - size) + bytes.set(take, size) + size += take.length + } + void reader.cancel() + return new TextDecoder().decode(bytes.subarray(0, size)) + } + + let reader = $state<HTMLElement>() + + // Same arrangement as the document pane: j/k scroll whatever is shown here. + $effect(() => { + const scroll = (amount: ScrollAmount) => { + if (!reader) return false + scrollPage(reader, amount) + return true + } + hooks.scrollReader = scroll + return () => { + if (hooks.scrollReader === scroll) hooks.scrollReader = () => false + } + }) +</script> + +<section class="pane"> + <header> + <div class="crumb label rule" title="{workspace.here}/{node.slug}"> + <button class="back" aria-label="back to the list" onclick={() => runCommand('q')}> + <Icon name="back" size={16} /> + </button> + <span class="marker"></span> + <span class="path">{workspace.here}/{node.slug}</span> + </div> + + <div class="row"> + <dl class="meta"> + <dt>size</dt> + <dd>{formatBytes(file.size)}</dd> + <dt>type</dt> + <dd>{file.mime}</dd> + <dt>uploaded</dt> + <dd title={formatStamp(node.created_at)}>{relative(node.created_at)}</dd> + </dl> + + <div class="actions"> + {#if viewable} + <a class="button" href={file.url} target="_blank" rel="noopener">open</a> + {/if} + <a class="button" href={file.url} download={node.name}>download</a> + <button class="danger" onclick={() => runCommand('d')}>delete</button> + <button class="close" onclick={() => runCommand('q')}>close</button> + </div> + </div> + </header> + + <div class="body" bind:this={reader}> + <h1 class="name">{node.name}</h1> + {#key file.url} + {#if kind === 'image'} + <img src={file.url} alt={node.name} /> + {:else if kind === 'video'} + <!-- svelte-ignore a11y_media_has_caption --> + <video src={file.url} controls playsinline preload="metadata"></video> + {:else if kind === 'audio'} + <audio src={file.url} controls preload="metadata"></audio> + {:else if text !== null} + <pre>{text}</pre> + {#if file.size > TEXT_PREVIEW} + <p class="muted">the first {formatBytes(TEXT_PREVIEW)} of {formatBytes(file.size)} — download it for the rest.</p> + {/if} + {:else} + <p class="muted"> + no preview for {file.mime} — + {#if viewable}<a href={file.url} target="_blank" rel="noopener">open it</a> or{/if} + <a href={file.url} download={node.name}>download it</a>. + </p> + {/if} + {/key} + </div> +</section> + +<style> + .pane { + display: flex; + flex-direction: column; + height: 100%; + min-width: 0; + } + + header { + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px 24px 12px 28px; + flex: none; + } + + .crumb { min-width: 0; white-space: nowrap; } + .path { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--subtle); + letter-spacing: 0.08em; + text-transform: none; + } + + .row { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + min-width: 0; + } + + .meta { + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + margin: 0; + font-size: 11px; + min-width: 0; + } + .meta dt { color: var(--muted); letter-spacing: 0.18em; text-transform: uppercase; } + .meta dd { margin: 0 10px 0 0; color: var(--fg); } + + .actions { display: flex; gap: 4px; flex-wrap: wrap; flex: none; } + .actions .danger:hover { background: var(--alarm); border-color: var(--alarm); color: var(--ink); } + + .body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 18px 28px 30vh; + } + .name { + margin: 0 0 18px; + color: var(--light); + font-family: var(--font-sans); + font-size: 24px; + font-weight: 300; + overflow-wrap: anywhere; + } + img, video { + display: block; + max-width: 100%; + max-height: 75vh; + border-radius: var(--radius-m); + } + audio { width: 100%; max-width: 46rem; } + pre { + margin: 0; + padding: 14px 16px; + border: 1px solid var(--rim); + border-radius: var(--radius-m); + background: var(--surface); + font-family: var(--font-mono); + font-size: 12px; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + + .back { display: none; } + + @media (max-width: 767px) { + header { padding: 10px 14px 10px 8px; gap: 8px; } + .crumb { gap: 8px; } + .back { + display: grid; + place-items: center; + width: 32px; + height: 32px; + flex: none; + padding: 0; + border: 0; + color: var(--light); + } + .row { flex-direction: column; align-items: stretch; gap: 10px; padding-left: 6px; } + .actions { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 6px; } + .actions :is(button, .button) { height: 36px; } + .actions .close { display: none; } + .body { padding: 14px 16px 30vh; } + } +</style> diff --git a/web/src/lib/Sidebar.svelte b/web/src/lib/Sidebar.svelte index 2bbb361..4c23004 100644 --- a/web/src/lib/Sidebar.svelte +++ b/web/src/lib/Sidebar.svelte @@ -1,9 +1,11 @@ <script lang="ts"> - import type { NodeSummary } from './api' + import { isContainer, type NodeSummary } from './api' import { workspace } from './store.svelte' - import { formatDay, formatTime, formatWeekday } from './util' + import { formatBytes, formatDay, formatTime, formatWeekday } from './util' let list = $state<HTMLUListElement>() + /** Files are being dragged over the list, which takes them into this folder. */ + let dropping = $state(false) // Keep the cursor line visible while navigating with j/k. $effect(() => { @@ -16,9 +18,23 @@ return node.name.trim() || node.excerpt.trim() || node.slug } - /* A folder is listed by what is in it; a document by when it was written. */ + /* A folder is listed by what is in it, a document by when it was written, a file by its size. */ const aside = (node: NodeSummary) => - node.kind === 'document' ? formatTime(node.created_at) : `${node.child_count}` + node.file + ? formatBytes(node.file.size) + : node.kind === 'document' + ? formatTime(node.created_at) + : `${node.child_count}` + + const carriesFiles = (event: DragEvent) => event.dataTransfer?.types.includes('Files') ?? false + + function drop(event: DragEvent) { + dropping = false + const files = Array.from(event.dataTransfer?.files ?? []) + if (files.length === 0 || workspace.query) return + event.preventDefault() + void workspace.guard(() => workspace.putFiles(files)) + } function pick(index: number) { workspace.cursor = index @@ -26,7 +42,20 @@ } </script> -<div class="sidebar"> +<!-- svelte-ignore a11y_no_static_element_interactions --> +<div + class="sidebar" + class:dropping + ondragover={(event) => { + if (!carriesFiles(event) || workspace.query) return + event.preventDefault() + dropping = true + }} + ondragleave={(event) => { + if (!(event.currentTarget as HTMLElement).contains(event.relatedTarget as Node | null)) dropping = false + }} + ondrop={drop} +> <div class="head label rule"> <span class="marker"></span> <span class="name">{workspace.space?.slug ?? 'diary'}</span> @@ -60,7 +89,7 @@ onclick={() => pick(index)} > <span class="dot"></span> - <span class="title">{label(node)}{#if node.kind !== 'document'}<span class="slash">/</span>{/if}</span> + <span class="title">{label(node)}{#if isContainer(node)}<span class="slash">/</span>{/if}</span> {#if workspace.marked?.id === node.id}<span class="badge" title="marked — p drops it in a folder">m</span>{/if} {#if node.shared}<span class="shared" title="shared"></span>{/if} <span class="aside">{aside(node)}</span> @@ -71,7 +100,7 @@ {#if workspace.query} no matches {:else} - nothing here yet — <span class="kbd">o</span> writes, <span class="kbd">⇧ O</span> makes a folder + nothing here yet — <span class="kbd">o</span> writes, <span class="kbd">⇧ O</span> makes a folder, files dropped here land here {/if} </li> {/each} @@ -86,6 +115,9 @@ min-width: 0; } + /* Files held over the list: the whole pane is the target. */ + .sidebar.dropping { outline: 1px dashed var(--light-dim); outline-offset: -6px; } + .head { flex: none; padding: 16px 16px 10px 18px; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 4ce3463..9d35b46 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,5 +1,16 @@ -/** What a node is: a top-level space, a folder inside one, or a document. */ -export type NodeKind = 'space' | 'folder' | 'document' +/** What a node is: a top-level space, a folder inside one, a document, or a file. */ +export type NodeKind = 'space' | 'folder' | 'document' | 'file' + +/** Whether a node can hold other nodes — the ones `l` walks into. */ +export const isContainer = (node: { kind: NodeKind }) => node.kind === 'space' || node.kind === 'folder' + +/** The bytes behind a file node. */ +export interface FileInfo { + media_id: string + mime: string + size: number + url: string +} export interface NodeSummary { id: number @@ -15,6 +26,8 @@ export interface NodeSummary { updated_at: number shared: boolean share_token: string | null + /** Files only. */ + file: FileInfo | null } /** One step of a node's path, the space first and the node itself last. */ @@ -268,6 +281,28 @@ export const api = { listMedia: () => request<MediaFile[]>('/media'), + /** + * Upload files into a space or folder as file nodes. XHR rather than fetch, + * because a gigabyte going up needs to say how far along it is. + */ + putFiles: (parentId: number, files: File[], progress?: (fraction: number) => void) => + new Promise<WorkspaceNode[]>((resolve, reject) => { + const form = new FormData() + for (const file of files) form.append('file', file) + const xhr = new XMLHttpRequest() + xhr.open('POST', `/api/nodes/${parentId}/files`) + xhr.responseType = 'json' + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) progress?.(event.loaded / event.total) + } + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response as WorkspaceNode[]) + else reject(new ApiError(xhr.status, xhr.response?.error ?? xhr.statusText)) + } + xhr.onerror = () => reject(new ApiError(0, 'the upload did not reach the server')) + xhr.send(form) + }), + upload: (files: File[]) => { const form = new FormData() for (const file of files) form.append('file', file) diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 3b3fc8f..fc070d8 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -1,4 +1,4 @@ -import { api, type BackupStatus } from './api' +import { api, isContainer, type BackupStatus } from './api' import { workspace } from './store.svelte' import { formatDay, parseDay, parseDue, type ScrollAmount } from './util' @@ -7,6 +7,8 @@ export type { ScrollAmount } /** Filled in by App.svelte so ex-commands can reach the DOM-bound bits. */ export const hooks = { pickFiles: () => {}, + /** Files for the folder being browsed, rather than for the document. */ + pickTreeFiles: () => {}, pickFolder: () => {}, pickArchive: () => {}, focusEditor: () => {}, @@ -56,7 +58,7 @@ const findFolder = (target: string) => { const wanted = target.toLowerCase() return workspace.nodes.find( (node) => - node.kind !== 'document' && (node.slug === wanted || node.name.toLowerCase() === wanted), + isContainer(node) && (node.slug === wanted || node.name.toLowerCase() === wanted), ) } @@ -382,6 +384,9 @@ export const commands: CommandSpec[] = [ help: 'edit the open document, or open a node by id', run: async (arg) => { const node = arg ? undefined : workspace.target + if (node?.kind === 'file') { + return workspace.say(`${node.slug} is a file — nothing to write in, :name renames it`, 'error') + } if (node && node.kind !== 'document') { return workspace.say(`${node.slug} is a ${node.kind} — l opens it`, 'error') } @@ -455,7 +460,7 @@ export const commands: CommandSpec[] = [ const node = workspace.open?.id === id ? workspace.open : workspace.nodes.find((n) => n.id === id) const name = node ? `"${node.name.trim() || node.slug}"` : `#${id}` const what = - node && node.kind !== 'document' + node && isContainer(node) ? `delete the ${node.kind} ${name} and everything in it?` : `delete ${name}?` if (!(await workspace.ask(`${what} this cannot be undone.`))) return @@ -616,8 +621,8 @@ export const commands: CommandSpec[] = [ name: 'upload', aliases: ['up'], group: 'editing', - help: 'pick files to attach at the cursor', - run: () => hooks.pickFiles(), + help: 'while writing, attach files at the cursor — otherwise put files into this folder', + run: () => (workspace.editing ? hooks.pickFiles() : hooks.pickTreeFiles()), }, { name: 'media', diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index 90fdb42..76a7c0b 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -1,6 +1,7 @@ import { api, ApiError, + isContainer, type Agenda, type AgendaCard, type Board, @@ -11,6 +12,7 @@ import { type NodeSummary, type SpaceOverview, } from './api' +import { formatBytes } from './util' export type Pane = 'list' | 'document' /** The tree of documents, or the board of the space being browsed. */ @@ -369,7 +371,7 @@ class Workspace { /** Open whatever `work/einarbeiten/01-was-ist-eebus` names. */ async openPath(path: string) { const node = await api.resolvePath(path) - if (node.kind === 'document') { + if (!isContainer(node)) { if (node.parent_id !== null) await this.goTo(node.parent_id) await this.openNode(node.id) } else { @@ -503,7 +505,7 @@ class Workspace { if (this.open?.id !== id) await this.flush() const node = await api.getNode(id) - if (node.kind !== 'document') { + if (isContainer(node)) { await this.goTo(node.id) return } @@ -511,7 +513,8 @@ class Workspace { this.open = node this.draft = { name: node.name, body: node.body, created_at: node.created_at } this.dirty = false - this.editing = edit + // A file has a name and nothing to write in. + this.editing = edit && node.kind === 'document' this.pane = 'document' this.#syncUrl(node.path) const index = this.nodes.findIndex((n) => n.id === id) @@ -529,7 +532,7 @@ class Workspace { } } - /** What Enter does: descend into a folder, open a document. */ + /** What Enter does: descend into a folder, open a document or a file. */ async openSelected(edit = false) { const selected = this.selected if (!selected) return @@ -538,8 +541,8 @@ class Workspace { const node = await api.getNode(selected.id) return this.openPath(node.path.map((crumb) => crumb.slug).join('/')) } - if (selected.kind === 'document') await this.openNode(selected.id, edit) - else await this.goTo(selected.id) + if (isContainer(selected)) await this.goTo(selected.id) + else await this.openNode(selected.id, edit) } /** A new document is created immediately so uploads have something to attach to. */ @@ -1090,6 +1093,26 @@ class Workspace { this.say(`imported ${parts.join(', ')}`) } + /** + * Upload files into the folder being browsed, as nodes of their own. The + * status line counts up while they go, because a large one takes a while. + */ + async putFiles(files: File[]) { + const parent = this.parentId + if (parent === null) return + const total = files.reduce((sum, file) => sum + file.size, 0) + const what = files.length === 1 ? files[0].name : `${files.length} files` + this.say(`uploading ${what} (${formatBytes(total)}) …`) + + const created = await api.putFiles(parent, files, (fraction) => + this.say(`uploading ${what} — ${Math.floor(fraction * 100)}%`), + ) + this.query = '' + this.globalSearch = false + await this.refresh(created[0]?.id) + this.say(created.length === 1 ? `uploaded ${this.here}/${created[0].slug}` : `uploaded ${created.length} files`) + } + async upload(files: File[]): Promise<MediaFile[]> { const uploaded = await api.upload(files) this.say(`uploaded ${uploaded.length} file${uploaded.length === 1 ? '' : 's'}`)