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

+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">
import type { NodeSummary } from './api'
import { isContainer, type NodeSummary } from './api'
import { workspace } from './store.svelte'
import { formatDay, formatTime, formatWeekday } from './util'
import { formatBytes, formatDay, formatTime, formatWeekday } from './util'
let list = $state<HTMLUListElement>()
/** Files are being dragged over the list, which takes them into this folder. */
let dropping = $state(false)
// Keep the cursor line visible while navigating with j/k.
$effect(() => {
@@ -16,9 +18,23 @@
return node.name.trim() || node.excerpt.trim() || node.slug
}
/* A folder is listed by what is in it; a document by when it was written. */
/* A folder is listed by what is in it, a document by when it was written, a file by its size. */
const aside = (node: NodeSummary) =>
node.kind === 'document' ? formatTime(node.created_at) : `${node.child_count}`
node.file
? formatBytes(node.file.size)
: node.kind === 'document'
? formatTime(node.created_at)
: `${node.child_count}`
const carriesFiles = (event: DragEvent) => event.dataTransfer?.types.includes('Files') ?? false
function drop(event: DragEvent) {
dropping = false
const files = Array.from(event.dataTransfer?.files ?? [])
if (files.length === 0 || workspace.query) return
event.preventDefault()
void workspace.guard(() => workspace.putFiles(files))
}
function pick(index: number) {
workspace.cursor = index
@@ -26,7 +42,20 @@
}
</script>
<div class="sidebar">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="sidebar"
class:dropping
ondragover={(event) => {
if (!carriesFiles(event) || workspace.query) return
event.preventDefault()
dropping = true
}}
ondragleave={(event) => {
if (!(event.currentTarget as HTMLElement).contains(event.relatedTarget as Node | null)) dropping = false
}}
ondrop={drop}
>
<div class="head label rule">
<span class="marker"></span>
<span class="name">{workspace.space?.slug ?? 'diary'}</span>
@@ -60,7 +89,7 @@
onclick={() => pick(index)}
>
<span class="dot"></span>
<span class="title">{label(node)}{#if node.kind !== 'document'}<span class="slash">/</span>{/if}</span>
<span class="title">{label(node)}{#if isContainer(node)}<span class="slash">/</span>{/if}</span>
{#if workspace.marked?.id === node.id}<span class="badge" title="marked — p drops it in a folder">m</span>{/if}
{#if node.shared}<span class="shared" title="shared"></span>{/if}
<span class="aside">{aside(node)}</span>
@@ -71,7 +100,7 @@
{#if workspace.query}
no matches
{:else}
nothing here yet — <span class="kbd">o</span> writes, <span class="kbd">⇧ O</span> makes a folder
nothing here yet — <span class="kbd">o</span> writes, <span class="kbd">⇧ O</span> makes a folder, files dropped here land here
{/if}
</li>
{/each}
@@ -86,6 +115,9 @@
min-width: 0;
}
/* Files held over the list: the whole pane is the target. */
.sidebar.dropping { outline: 1px dashed var(--light-dim); outline-offset: -6px; }
.head {
flex: none;
padding: 16px 16px 10px 18px;
+37 -2
View File
@@ -1,5 +1,16 @@
/** What a node is: a top-level space, a folder inside one, or a document. */
export type NodeKind = 'space' | 'folder' | 'document'
/** What a node is: a top-level space, a folder inside one, a document, or a file. */
export type NodeKind = 'space' | 'folder' | 'document' | 'file'
/** Whether a node can hold other nodes — the ones `l` walks into. */
export const isContainer = (node: { kind: NodeKind }) => node.kind === 'space' || node.kind === 'folder'
/** The bytes behind a file node. */
export interface FileInfo {
media_id: string
mime: string
size: number
url: string
}
export interface NodeSummary {
id: number
@@ -15,6 +26,8 @@ export interface NodeSummary {
updated_at: number
shared: boolean
share_token: string | null
/** Files only. */
file: FileInfo | null
}
/** One step of a node's path, the space first and the node itself last. */
@@ -268,6 +281,28 @@ export const api = {
listMedia: () => request<MediaFile[]>('/media'),
/**
* Upload files into a space or folder as file nodes. XHR rather than fetch,
* because a gigabyte going up needs to say how far along it is.
*/
putFiles: (parentId: number, files: File[], progress?: (fraction: number) => void) =>
new Promise<WorkspaceNode[]>((resolve, reject) => {
const form = new FormData()
for (const file of files) form.append('file', file)
const xhr = new XMLHttpRequest()
xhr.open('POST', `/api/nodes/${parentId}/files`)
xhr.responseType = 'json'
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) progress?.(event.loaded / event.total)
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response as WorkspaceNode[])
else reject(new ApiError(xhr.status, xhr.response?.error ?? xhr.statusText))
}
xhr.onerror = () => reject(new ApiError(0, 'the upload did not reach the server'))
xhr.send(form)
}),
upload: (files: File[]) => {
const form = new FormData()
for (const file of files) form.append('file', file)
+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 { formatDay, parseDay, parseDue, type ScrollAmount } from './util'
@@ -7,6 +7,8 @@ export type { ScrollAmount }
/** Filled in by App.svelte so ex-commands can reach the DOM-bound bits. */
export const hooks = {
pickFiles: () => {},
/** Files for the folder being browsed, rather than for the document. */
pickTreeFiles: () => {},
pickFolder: () => {},
pickArchive: () => {},
focusEditor: () => {},
@@ -56,7 +58,7 @@ const findFolder = (target: string) => {
const wanted = target.toLowerCase()
return workspace.nodes.find(
(node) =>
node.kind !== 'document' && (node.slug === wanted || node.name.toLowerCase() === wanted),
isContainer(node) && (node.slug === wanted || node.name.toLowerCase() === wanted),
)
}
@@ -382,6 +384,9 @@ export const commands: CommandSpec[] = [
help: 'edit the open document, or open a node by id',
run: async (arg) => {
const node = arg ? undefined : workspace.target
if (node?.kind === 'file') {
return workspace.say(`${node.slug} is a file — nothing to write in, :name renames it`, 'error')
}
if (node && node.kind !== 'document') {
return workspace.say(`${node.slug} is a ${node.kind} — l opens it`, 'error')
}
@@ -455,7 +460,7 @@ export const commands: CommandSpec[] = [
const node = workspace.open?.id === id ? workspace.open : workspace.nodes.find((n) => n.id === id)
const name = node ? `"${node.name.trim() || node.slug}"` : `#${id}`
const what =
node && node.kind !== 'document'
node && isContainer(node)
? `delete the ${node.kind} ${name} and everything in it?`
: `delete ${name}?`
if (!(await workspace.ask(`${what} this cannot be undone.`))) return
@@ -616,8 +621,8 @@ export const commands: CommandSpec[] = [
name: 'upload',
aliases: ['up'],
group: 'editing',
help: 'pick files to attach at the cursor',
run: () => hooks.pickFiles(),
help: 'while writing, attach files at the cursor — otherwise put files into this folder',
run: () => (workspace.editing ? hooks.pickFiles() : hooks.pickTreeFiles()),
},
{
name: 'media',
+29 -6
View File
@@ -1,6 +1,7 @@
import {
api,
ApiError,
isContainer,
type Agenda,
type AgendaCard,
type Board,
@@ -11,6 +12,7 @@ import {
type NodeSummary,
type SpaceOverview,
} from './api'
import { formatBytes } from './util'
export type Pane = 'list' | 'document'
/** The tree of documents, or the board of the space being browsed. */
@@ -369,7 +371,7 @@ class Workspace {
/** Open whatever `work/einarbeiten/01-was-ist-eebus` names. */
async openPath(path: string) {
const node = await api.resolvePath(path)
if (node.kind === 'document') {
if (!isContainer(node)) {
if (node.parent_id !== null) await this.goTo(node.parent_id)
await this.openNode(node.id)
} else {
@@ -503,7 +505,7 @@ class Workspace {
if (this.open?.id !== id) await this.flush()
const node = await api.getNode(id)
if (node.kind !== 'document') {
if (isContainer(node)) {
await this.goTo(node.id)
return
}
@@ -511,7 +513,8 @@ class Workspace {
this.open = node
this.draft = { name: node.name, body: node.body, created_at: node.created_at }
this.dirty = false
this.editing = edit
// A file has a name and nothing to write in.
this.editing = edit && node.kind === 'document'
this.pane = 'document'
this.#syncUrl(node.path)
const index = this.nodes.findIndex((n) => n.id === id)
@@ -529,7 +532,7 @@ class Workspace {
}
}
/** What Enter does: descend into a folder, open a document. */
/** What Enter does: descend into a folder, open a document or a file. */
async openSelected(edit = false) {
const selected = this.selected
if (!selected) return
@@ -538,8 +541,8 @@ class Workspace {
const node = await api.getNode(selected.id)
return this.openPath(node.path.map((crumb) => crumb.slug).join('/'))
}
if (selected.kind === 'document') await this.openNode(selected.id, edit)
else await this.goTo(selected.id)
if (isContainer(selected)) await this.goTo(selected.id)
else await this.openNode(selected.id, edit)
}
/** A new document is created immediately so uploads have something to attach to. */
@@ -1090,6 +1093,26 @@ class Workspace {
this.say(`imported ${parts.join(', ')}`)
}
/**
* Upload files into the folder being browsed, as nodes of their own. The
* status line counts up while they go, because a large one takes a while.
*/
async putFiles(files: File[]) {
const parent = this.parentId
if (parent === null) return
const total = files.reduce((sum, file) => sum + file.size, 0)
const what = files.length === 1 ? files[0].name : `${files.length} files`
this.say(`uploading ${what} (${formatBytes(total)}) …`)
const created = await api.putFiles(parent, files, (fraction) =>
this.say(`uploading ${what} — ${Math.floor(fraction * 100)}%`),
)
this.query = ''
this.globalSearch = false
await this.refresh(created[0]?.id)
this.say(created.length === 1 ? `uploaded ${this.here}/${created[0].slug}` : `uploaded ${created.length} files`)
}
async upload(files: File[]): Promise<MediaFile[]> {
const uploaded = await api.upload(files)
this.say(`uploaded ${uploaded.length} file${uploaded.length === 1 ? '' : 's'}`)