mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
3d6944c860
commit
2103edc299
15 files changed
+808
-94
No files matched your search
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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\n\n> a quote\n- a point";
|
let body = "# Title\n\n\n\n> a quote\n- a point";
|
||||||
|
|||||||
+4
-3
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -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
@@ -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',
|
||||||
|
|||||||
@@ -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'}`)
|
||||||
|
|||||||
Reference in new issue
Block a user