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.
This commit is contained in:
nvrl committed 2026-10-19 15:44:30 +02:00
1 parent 3d6944c860
commit 2103edc299
15 files changed
+808 -94

No files matched your search

+23 -4
View File
@@ -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. - 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. - 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. - 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. - 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. - 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. - 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 <name>` makes one, `:rmspace [name]` deletes one and everything in it), `:mkdir <name>`
makes a folder, `:cd <name|..|/>` walks it and `:mv <folder|..>` moves what is makes a folder, `:cd <name|..|/>` walks it and `:mv <folder|..>` moves what is
selected, and `:import` uploads a folder into the one being browsed selected, and `:import` uploads a folder into the one being browsed
(`:import!` takes a `.zip` instead). `:name <text>` 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 <text>` renames whatever is under
the cursor — a document, a folder, or a card on a board — and `:name!` renames the cursor — a document, a folder, or a card on a board — and `:name!` renames
the space itself. The board has the space itself. The board has
`:board`, `:card <title>` (`:card!` links the open document to it), `:board`, `:card <title>` (`: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 `:export` downloads the open document as markdown. `:export!` downloads the
whole workspace as a zip: the tree as real directories, one markdown file per 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 document, every file in the tree where it sits and every other embedded file in
them, so the archive reads in any markdown viewer without this application. `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 ## Importing a folder
`:import` picks a folder and uploads it whole — every file carries the path it `: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 had inside, and the tree is rebuilt on the other side: directories become
folders, `.md` and `.markdown` files become documents (titled by their first 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 a zip, which is the shape `:export!` writes, so a workspace can be poured back
into another one. into another one.
+46
View File
@@ -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';
+80 -14
View File
@@ -23,14 +23,20 @@ struct ExportNode {
name: String, name: String,
slug: String, slug: String,
body: String, body: String,
media_id: Option<String>,
} }
/// Where a document lands in the archive: the slugs of its ancestors as /// 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 /// directories, its own slug as the file. Slugs are already unique among
/// siblings, so the path is unique without adding the id to it. /// siblings, so the path is unique without adding the id to it.
fn document_path(nodes: &HashMap<i64, ExportNode>, node: &ExportNode) -> String { fn document_path(nodes: &HashMap<i64, ExportNode>, node: &ExportNode) -> String {
let mut segments = vec![format!("{}.md", node.slug)]; tree_path(nodes, node.parent_id, format!("{}.md", node.slug))
let mut parent = node.parent_id; }
/// `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 // Bounded by the number of nodes, so a parent chain that somehow loops
// cannot spin here. // cannot spin here.
for _ in 0..nodes.len() { for _ in 0..nodes.len() {
@@ -78,15 +84,19 @@ fn day_string(seconds: i64) -> String {
format!("{y:04}-{m:02}-{d:02}") 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 /// 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. /// 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 let safe: String = original
.chars() .chars()
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '_' }) .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '_' })
.collect(); .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() { if safe.is_empty() {
id.to_string() id.to_string()
} else { } 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 = "\ const README: &str = "\
This is a complete copy of a ~/workspace. This is a complete copy of a ~/workspace.
<space>/ one directory per space, holding the same tree of folders and <space>/ one directory per space, holding the same tree of folders,
documents that the workspace shows; one markdown file per document documents and files that the workspace shows; one markdown file
media/ every file the documents embed 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 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 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. /// can trust, so this is deliberately a plain archive of plain files.
pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Response> { pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Response> {
let nodes: HashMap<i64, ExportNode> = sqlx::query( 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) .fetch_all(&state.db)
.await? .await?
@@ -123,6 +153,7 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
name: row.get("name"), name: row.get("name"),
slug: row.get("slug"), slug: row.get("slug"),
body: row.get("body"), body: row.get("body"),
media_id: row.get("media_id"),
}; };
(node.id, node) (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.start_file("README.txt", text)?;
zip.write_all(README.as_bytes())?; 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 let names: Vec<(String, String)> = media
.iter() .iter()
.filter(|(id, _)| !in_tree.contains_key(id))
.map(|(id, filename)| (id.clone(), media_filename(id, filename))) .map(|(id, filename)| (id.clone(), media_filename(id, filename)))
.collect(); .collect();
@@ -176,6 +218,9 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
for (id, name) in &names { for (id, name) in &names {
body = body.replace(&format!("/api/media/{id}"), &format!("{up}media/{name}")); 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 { for (address, target) in &addresses {
// Matching the `)` or `#` that ends a markdown link is what // Matching the `)` or `#` that ends a markdown link is what
// keeps `/n/work/a` from matching inside `/n/work/ab`. // 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())?; 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. // 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; continue;
}; };
zip.start_file(format!("media/{name}"), blob)?; zip.start_file(entry, blob)?;
zip.write_all(&bytes)?; std::io::copy(&mut source, &mut zip)?;
} }
let mut file = zip.finish()?; let mut file = zip.finish()?;
@@ -229,7 +278,7 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
mod tests { mod tests {
use std::collections::HashMap; 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] #[test]
fn renders_days_without_a_date_crate() { fn renders_days_without_a_date_crate() {
@@ -251,12 +300,29 @@ mod tests {
name: (*slug).into(), name: (*slug).into(),
slug: (*slug).into(), slug: (*slug).into(),
body: String::new(), body: String::new(),
media_id: (*kind == "file").then(|| format!("m{id}")),
}, },
) )
}) })
.collect() .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] #[test]
fn a_document_keeps_its_place_in_the_tree() { fn a_document_keeps_its_place_in_the_tree() {
let nodes = tree(&[ let nodes = tree(&[
+32 -21
View File
@@ -4,7 +4,7 @@
//! `docs/`, a folder of lecture notes, an export of this very diary. Storing it //! `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 //! as opaque files would make it unsearchable and uneditable, so the importer
//! reproduces the directory as nodes: directories become folders, markdown //! 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 //! 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. //! longer reach each other is a worse copy of what was uploaded.
@@ -25,7 +25,7 @@ use crate::{
error::{AppError, AppResult}, error::{AppError, AppResult},
routes::{ routes::{
media, media,
nodes::{self, next_slug, slugify, DOCUMENT, FOLDER}, nodes::{self, next_slug, slugify, DOCUMENT, FILE, FOLDER},
}, },
state::AppState, state::AppState,
}; };
@@ -434,7 +434,7 @@ pub async fn import(
multipart: Multipart, multipart: Multipart,
) -> AppResult<Json<ImportReport>> { ) -> AppResult<Json<ImportReport>> {
let target = nodes::load(&state, id).await?; let target = nodes::load(&state, id).await?;
if target.kind == DOCUMENT { if !nodes::is_container(&target.kind) {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"a folder can only be imported into a space or a folder".into(), "a folder can only be imported into a space or a folder".into(),
)); ));
@@ -597,6 +597,35 @@ async fn build(
.execute(&mut *tx) .execute(&mut *tx)
.await?; .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( landed.insert(
upload.path.clone(), upload.path.clone(),
Landed::Media(format!("/api/media/{media_id}")), 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 // 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. // file a document embeds to it the way a hand-written document does.
let mut links = 0usize; let mut links = 0usize;
let mut attached: BTreeSet<String> = BTreeSet::new();
for (id, path, body) in &documents { for (id, path, body) in &documents {
let (rewritten, count) = rewrite_links(body, parent_of(path), &landed); let (rewritten, count) = rewrite_links(body, parent_of(path), &landed);
@@ -632,23 +660,6 @@ async fn build(
.bind(&media_id) .bind(&media_id)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .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?;
} }
} }
+104
View File
@@ -17,6 +17,7 @@ use uuid::Uuid;
use crate::{ use crate::{
auth::Session, auth::Session,
error::{AppError, AppResult}, error::{AppError, AppResult},
routes::nodes,
state::AppState, state::AppState,
}; };
@@ -213,6 +214,109 @@ pub async fn upload(
Ok(Json(uploaded)) 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>>> { pub async fn list(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<Media>>> {
let rows = sqlx::query( let rows = sqlx::query(
"SELECT m.id, m.filename, m.mime, m.size, m.created_at, "SELECT m.id, m.filename, m.mime, m.size, m.created_at,
+1
View File
@@ -38,6 +38,7 @@ pub fn api_router() -> Router<AppState> {
.route("/nodes/{id}", delete(nodes::remove)) .route("/nodes/{id}", delete(nodes::remove))
.route("/nodes/{id}/move", post(nodes::move_node)) .route("/nodes/{id}/move", post(nodes::move_node))
.route("/nodes/{id}/import", post(import::import)) .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", post(share::enable))
.route("/nodes/{id}/share", delete(share::disable)) .route("/nodes/{id}/share", delete(share::disable))
.route("/media", post(media::upload).get(media::list)) .route("/media", post(media::upload).get(media::list))
+161 -27
View File
@@ -16,10 +16,26 @@ use crate::{
/// What a node is. Spaces are the roots (`diary`, `work`, `uni`, ...), folders /// 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 /// 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 SPACE: &str = "space";
pub const FOLDER: &str = "folder"; pub const FOLDER: &str = "folder";
pub const DOCUMENT: &str = "document"; 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)] #[derive(Serialize)]
pub struct NodeSummary { pub struct NodeSummary {
@@ -36,6 +52,8 @@ pub struct NodeSummary {
pub updated_at: i64, pub updated_at: i64,
pub shared: bool, pub shared: bool,
pub share_token: Option<String>, pub share_token: Option<String>,
/// Files only.
pub file: Option<FileInfo>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -56,6 +74,7 @@ pub struct Node {
pub updated_at: i64, pub updated_at: i64,
pub shared: bool, pub shared: bool,
pub share_token: Option<String>, pub share_token: Option<String>,
pub file: Option<FileInfo>,
/// This node's ancestors, the space first and the node itself last — the /// This node's ancestors, the space first and the node itself last — the
/// breadcrumb, and everything needed to build its /n/... path. /// breadcrumb, and everything needed to build its /n/... path.
pub path: Vec<Crumb>, 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. /// 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, 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, 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 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 /// 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 /// 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 { fn row_to_summary(row: &sqlx::sqlite::SqliteRow, head: &str) -> NodeSummary {
let share_token: Option<String> = row.get("share_token"); 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 { NodeSummary {
id: row.get("id"), id: row.get("id"),
parent_id: row.get("parent_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"), updated_at: row.get("updated_at"),
shared: share_token.is_some(), shared: share_token.is_some(),
share_token, share_token,
file,
} }
} }
@@ -492,17 +522,7 @@ pub async fn create(
))); )));
} }
let parent_kind: String = sqlx::query("SELECT kind FROM nodes WHERE id = ?1") container(&state.db, input.parent_id).await?;
.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(),
));
}
let now = crate::now(); let now = crate::now();
let created_at = input.created_at.unwrap_or(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())); 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( let affected = sqlx::query(
"UPDATE nodes "UPDATE nodes
SET name = ?1, body = ?2, slug = ?3, updated_at = ?4, SET name = ?1, body = ?2, slug = ?3, updated_at = ?4,
@@ -573,7 +604,7 @@ pub async fn update(
WHERE id = ?7", WHERE id = ?7",
) )
.bind(name) .bind(name)
.bind(&input.body) .bind(body)
.bind(&slug) .bind(&slug)
.bind(crate::now()) .bind(crate::now())
.bind(input.created_at) .bind(input.created_at)
@@ -587,7 +618,9 @@ pub async fn update(
return Err(AppError::NotFound); 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(); state.backup.signal();
load(&state, id).await.map(Json) 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())); return Err(AppError::BadRequest("a node cannot contain itself".into()));
} }
let target_kind: String = sqlx::query("SELECT kind FROM nodes WHERE id = ?1") container(&state.db, input.parent_id).await?;
.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(),
));
}
// Moving a folder into its own subtree would cut that subtree loose from // Moving a folder into its own subtree would cut that subtree loose from
// every root, where nothing could reach it again. // every root, where nothing could reach it again.
@@ -688,6 +711,22 @@ pub async fn move_node(
load(&state, id).await.map(Json) 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. /// A node and everything beneath it, the node itself first.
pub async fn descendants(db: &SqlitePool, id: i64) -> AppResult<Vec<i64>> { pub async fn descendants(db: &SqlitePool, id: i64) -> AppResult<Vec<i64>> {
let rows = sqlx::query( let rows = sqlx::query(
@@ -758,6 +797,7 @@ pub async fn load(state: &AppState, id: i64) -> AppResult<Node> {
updated_at: summary.updated_at, updated_at: summary.updated_at,
shared: summary.shared, shared: summary.shared,
share_token: summary.share_token, share_token: summary.share_token,
file: summary.file,
path: path_of(&state.db, id).await?, path: path_of(&state.db, id).await?,
}) })
} }
@@ -816,6 +856,100 @@ mod tests {
assert_eq!(slugs, ["b", "c"]); 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] #[test]
fn strips_markdown_furniture() { fn strips_markdown_furniture() {
let body = "# Title\n\n![pic](/api/media/x)\n\n> a quote\n- a point"; let body = "# Title\n\n![pic](/api/media/x)\n\n> a quote\n- a point";
+4 -3
View File
@@ -1,6 +1,7 @@
//! The virtual filesystem an SFTP client sees: `/<space>/<folder>/.../<doc>.md`. //! The virtual filesystem an SFTP client sees: `/<space>/<folder>/.../<doc>.md`.
//! Directories are spaces and folders; the files that persist are documents, //! 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. //! `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 //! 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()] { for slug in [leaf, slugged.as_str()] {
let row = sqlx::query( let row = sqlx::query(
"SELECT id, kind, slug, updated_at FROM nodes "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(parent)
.bind(slug) .bind(slug)
@@ -383,7 +384,7 @@ impl SpaceFs {
let parent = self.resolve_dir(&segs).await?; let parent = self.resolve_dir(&segs).await?;
let rows = sqlx::query(&format!( let rows = sqlx::query(&format!(
"SELECT kind, slug, updated_at, {DOC_SIZE} AS size FROM nodes "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) .bind(parent)
.fetch_all(&self.db) .fetch_all(&self.db)
+15 -1
View File
@@ -6,6 +6,7 @@
import Board from './lib/Board.svelte' import Board from './lib/Board.svelte'
import CommandLine from './lib/CommandLine.svelte' import CommandLine from './lib/CommandLine.svelte'
import DocumentPane from './lib/DocumentPane.svelte' import DocumentPane from './lib/DocumentPane.svelte'
import FilePane from './lib/FilePane.svelte'
import Help from './lib/Help.svelte' import Help from './lib/Help.svelte'
import Login from './lib/Login.svelte' import Login from './lib/Login.svelte'
import Overview from './lib/Overview.svelte' import Overview from './lib/Overview.svelte'
@@ -29,6 +30,7 @@
let cmdline = $state<string | null>(null) let cmdline = $state<string | null>(null)
let cmdGlobal = $state(false) let cmdGlobal = $state(false)
let fileInput = $state<HTMLInputElement>() let fileInput = $state<HTMLInputElement>()
let treeInput = $state<HTMLInputElement>()
let folderInput = $state<HTMLInputElement>() let folderInput = $state<HTMLInputElement>()
let importInput = $state<HTMLInputElement>() let importInput = $state<HTMLInputElement>()
let shell = $state<HTMLDivElement>() let shell = $state<HTMLDivElement>()
@@ -43,6 +45,7 @@
$effect(() => { $effect(() => {
hooks.pickFiles = () => fileInput?.click() hooks.pickFiles = () => fileInput?.click()
hooks.pickTreeFiles = () => treeInput?.click()
hooks.pickFolder = () => folderInput?.click() hooks.pickFolder = () => folderInput?.click()
hooks.pickArchive = () => importInput?.click() hooks.pickArchive = () => importInput?.click()
hooks.focusList = () => shell?.focus() 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 * 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 * 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'} class:focused={!!workspace.open && workspace.pane === 'document'}
onpointerdown={() => 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 /> <DocumentPane />
{:else} {:else}
<div class="placeholder"> <div class="placeholder">
@@ -686,6 +699,7 @@
{/if} {/if}
<input class="hidden" type="file" multiple bind:this={fileInput} onchange={attach} /> <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 <!-- `webkitdirectory` is set from script: it is the one attribute browsers
agree on for picking a folder, and no typed attribute list carries it. --> agree on for picking a folder, and no typed attribute list carries it. -->
<input class="hidden" type="file" multiple bind:this={folderInput} onchange={importFolder} /> <input class="hidden" type="file" multiple bind:this={folderInput} onchange={importFolder} />
+6 -4
View File
@@ -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 * 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. * 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: inherit;
font-size: 11px; font-size: 11px;
letter-spacing: 0.12em; letter-spacing: 0.12em;
@@ -114,9 +115,10 @@ button {
color var(--t-base) var(--ease-glide), color var(--t-base) var(--ease-glide),
border-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); } a.button { display: inline-flex; align-items: center; justify-content: center; text-decoration: none; }
button:active { transform: translateY(1px); } :is(button, a.button):hover { background: var(--surface-alt); color: var(--light); border-color: var(--outline); }
button:focus-visible { background: var(--light); color: var(--ink); border-color: var(--light); outline: none; } :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; } button:disabled { color: var(--muted); cursor: default; }
input, textarea { input, textarea {
+221
View File
@@ -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>
+39 -7
View File
@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { NodeSummary } from './api' import { isContainer, type NodeSummary } from './api'
import { workspace } from './store.svelte' import { workspace } from './store.svelte'
import { formatDay, formatTime, formatWeekday } from './util' import { formatBytes, formatDay, formatTime, formatWeekday } from './util'
let list = $state<HTMLUListElement>() 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. // Keep the cursor line visible while navigating with j/k.
$effect(() => { $effect(() => {
@@ -16,9 +18,23 @@
return node.name.trim() || node.excerpt.trim() || node.slug 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) => 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) { function pick(index: number) {
workspace.cursor = index workspace.cursor = index
@@ -26,7 +42,20 @@
} }
</script> </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"> <div class="head label rule">
<span class="marker"></span> <span class="marker"></span>
<span class="name">{workspace.space?.slug ?? 'diary'}</span> <span class="name">{workspace.space?.slug ?? 'diary'}</span>
@@ -60,7 +89,7 @@
onclick={() => pick(index)} onclick={() => pick(index)}
> >
<span class="dot"></span> <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 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} {#if node.shared}<span class="shared" title="shared"></span>{/if}
<span class="aside">{aside(node)}</span> <span class="aside">{aside(node)}</span>
@@ -71,7 +100,7 @@
{#if workspace.query} {#if workspace.query}
no matches no matches
{:else} {: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} {/if}
</li> </li>
{/each} {/each}
@@ -86,6 +115,9 @@
min-width: 0; 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 { .head {
flex: none; flex: none;
padding: 16px 16px 10px 18px; padding: 16px 16px 10px 18px;
+37 -2
View File
@@ -1,5 +1,16 @@
/** What a node is: a top-level space, a folder inside one, or a document. */ /** What a node is: a top-level space, a folder inside one, a document, or a file. */
export type NodeKind = 'space' | 'folder' | 'document' 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 { export interface NodeSummary {
id: number id: number
@@ -15,6 +26,8 @@ export interface NodeSummary {
updated_at: number updated_at: number
shared: boolean shared: boolean
share_token: string | null share_token: string | null
/** Files only. */
file: FileInfo | null
} }
/** One step of a node's path, the space first and the node itself last. */ /** 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'), 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[]) => { upload: (files: File[]) => {
const form = new FormData() const form = new FormData()
for (const file of files) form.append('file', file) for (const file of files) form.append('file', file)
+10 -5
View File
@@ -1,4 +1,4 @@
import { api, type BackupStatus } from './api' import { api, isContainer, type BackupStatus } from './api'
import { workspace } from './store.svelte' import { workspace } from './store.svelte'
import { formatDay, parseDay, parseDue, type ScrollAmount } from './util' 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. */ /** Filled in by App.svelte so ex-commands can reach the DOM-bound bits. */
export const hooks = { export const hooks = {
pickFiles: () => {}, pickFiles: () => {},
/** Files for the folder being browsed, rather than for the document. */
pickTreeFiles: () => {},
pickFolder: () => {}, pickFolder: () => {},
pickArchive: () => {}, pickArchive: () => {},
focusEditor: () => {}, focusEditor: () => {},
@@ -56,7 +58,7 @@ const findFolder = (target: string) => {
const wanted = target.toLowerCase() const wanted = target.toLowerCase()
return workspace.nodes.find( return workspace.nodes.find(
(node) => (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', help: 'edit the open document, or open a node by id',
run: async (arg) => { run: async (arg) => {
const node = arg ? undefined : workspace.target 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') { if (node && node.kind !== 'document') {
return workspace.say(`${node.slug} is a ${node.kind} — l opens it`, 'error') 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 node = workspace.open?.id === id ? workspace.open : workspace.nodes.find((n) => n.id === id)
const name = node ? `"${node.name.trim() || node.slug}"` : `#${id}` const name = node ? `"${node.name.trim() || node.slug}"` : `#${id}`
const what = const what =
node && node.kind !== 'document' node && isContainer(node)
? `delete the ${node.kind} ${name} and everything in it?` ? `delete the ${node.kind} ${name} and everything in it?`
: `delete ${name}?` : `delete ${name}?`
if (!(await workspace.ask(`${what} this cannot be undone.`))) return if (!(await workspace.ask(`${what} this cannot be undone.`))) return
@@ -616,8 +621,8 @@ export const commands: CommandSpec[] = [
name: 'upload', name: 'upload',
aliases: ['up'], aliases: ['up'],
group: 'editing', group: 'editing',
help: 'pick files to attach at the cursor', help: 'while writing, attach files at the cursor — otherwise put files into this folder',
run: () => hooks.pickFiles(), run: () => (workspace.editing ? hooks.pickFiles() : hooks.pickTreeFiles()),
}, },
{ {
name: 'media', name: 'media',
+29 -6
View File
@@ -1,6 +1,7 @@
import { import {
api, api,
ApiError, ApiError,
isContainer,
type Agenda, type Agenda,
type AgendaCard, type AgendaCard,
type Board, type Board,
@@ -11,6 +12,7 @@ import {
type NodeSummary, type NodeSummary,
type SpaceOverview, type SpaceOverview,
} from './api' } from './api'
import { formatBytes } from './util'
export type Pane = 'list' | 'document' export type Pane = 'list' | 'document'
/** The tree of documents, or the board of the space being browsed. */ /** 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. */ /** Open whatever `work/einarbeiten/01-was-ist-eebus` names. */
async openPath(path: string) { async openPath(path: string) {
const node = await api.resolvePath(path) const node = await api.resolvePath(path)
if (node.kind === 'document') { if (!isContainer(node)) {
if (node.parent_id !== null) await this.goTo(node.parent_id) if (node.parent_id !== null) await this.goTo(node.parent_id)
await this.openNode(node.id) await this.openNode(node.id)
} else { } else {
@@ -503,7 +505,7 @@ class Workspace {
if (this.open?.id !== id) await this.flush() if (this.open?.id !== id) await this.flush()
const node = await api.getNode(id) const node = await api.getNode(id)
if (node.kind !== 'document') { if (isContainer(node)) {
await this.goTo(node.id) await this.goTo(node.id)
return return
} }
@@ -511,7 +513,8 @@ class Workspace {
this.open = node this.open = node
this.draft = { name: node.name, body: node.body, created_at: node.created_at } this.draft = { name: node.name, body: node.body, created_at: node.created_at }
this.dirty = false 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.pane = 'document'
this.#syncUrl(node.path) this.#syncUrl(node.path)
const index = this.nodes.findIndex((n) => n.id === id) 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) { async openSelected(edit = false) {
const selected = this.selected const selected = this.selected
if (!selected) return if (!selected) return
@@ -538,8 +541,8 @@ class Workspace {
const node = await api.getNode(selected.id) const node = await api.getNode(selected.id)
return this.openPath(node.path.map((crumb) => crumb.slug).join('/')) return this.openPath(node.path.map((crumb) => crumb.slug).join('/'))
} }
if (selected.kind === 'document') await this.openNode(selected.id, edit) if (isContainer(selected)) await this.goTo(selected.id)
else 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. */ /** A new document is created immediately so uploads have something to attach to. */
@@ -1090,6 +1093,26 @@ class Workspace {
this.say(`imported ${parts.join(', ')}`) 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[]> { async upload(files: File[]): Promise<MediaFile[]> {
const uploaded = await api.upload(files) const uploaded = await api.upload(files)
this.say(`uploaded ${uploaded.length} file${uploaded.length === 1 ? '' : 's'}`) this.say(`uploaded ${uploaded.length} file${uploaded.length === 1 ? '' : 's'}`)