files: a file belongs to a node, and a document holds its own

An upload while writing is now a file node of the document, through
POST /nodes/{id}/files, instead of a loose row of media that only the media
browser listed. It moves, seals, goes to the trash and comes back with the
document. Migration 0029 gives every embedded upload that had no node a
place under the document that embeds it (a living one before a trashed
one), keeping its media id, so no body changes; the sealed-folder triggers
now ask a document's folder for the files under it.

- nodes: `holds` and `place_for` say what may go where; a document holds
  files and nothing else, in moves, uploads and restores from the trash.
  A loaded document carries its files and whether its folder seals them.
- seal: a document moving into a sealed folder seals its files first.
- media: POST /media, GET /media and DELETE /media/{id} are gone, and with
  them MediaBrowser and :media.
- import: `plan/` beside `plan.md` comes back as the document's files, the
  shape export writes; an older export's media/ goes to the first document
  that embeds each file.
- sftp: a document's files are `<slug>/` beside its `.md`, listed once it
  has any, holding files only, moving with the `.md`.
- web: paste, drop and :upload while reading go to the document; the info
  sheet has a files section (Enter opens, i embeds, y copies how); the list
  shows +N on a document, za folds its files open, p drops marked files in.
This commit is contained in:
nvrl committed 2026-10-22 11:57:52 +02:00
1 parent 2233fa41a6
commit d8e72c688d
29 files changed
+869 -490

No files matched your search

+5 -4
View File
@@ -18,7 +18,6 @@
import Verify from './lib/Verify.svelte'
import Login from './lib/Login.svelte'
import Overview from './lib/Overview.svelte'
import MediaBrowser from './lib/MediaBrowser.svelte'
import Trash from './lib/Trash.svelte'
import CardTile from './lib/CardTile.svelte'
import Scratch from './lib/Scratch.svelte'
@@ -62,7 +61,6 @@
verify: Verify,
info: Info,
trash: Trash,
media: MediaBrowser,
}
let boardEl = $state<HTMLDivElement>()
/** The first half of `gg`, `dd`, `]]` …, shown until the second comes. */
@@ -195,8 +193,11 @@
input.value = ''
if (files.length === 0) return
await workspace.guard(async () => {
const uploaded = await workspace.upload(files)
hooks.insertText(uploaded.map(embedSnippet).join('\n\n'))
// While writing, they go in at the cursor too; while reading, the
// document only holds them.
const writing = workspace.editing
const created = await workspace.attach(files)
if (writing && created.length > 0) hooks.insertText(created.map(embedSnippet).join('\n\n'))
})
}
+6 -2
View File
@@ -153,7 +153,8 @@
<!--
The readout is the way to everything else about the document: its
headings, its history, what links to it and who can read it — gi.
headings, its history, what links to it, its files and who can read
it — gi.
-->
<button class="meta" title="info (gi)" onclick={() => openInfo()}>
<span class="field"><span class="caption">written</span>{formatStamp(tile.draft.created_at)} {formatWeekday(tile.draft.created_at)}</span>
@@ -162,6 +163,9 @@
{#if doc.backlinks > 0}
<span class="field"><span class="caption">linked</span>from {doc.backlinks}</span>
{/if}
{#if doc.files.length > 0}
<span class="field"><span class="caption">files</span>{doc.files.length}</span>
{/if}
{#if doc.shared}
<span class="field public"><span class="ring"></span>public</span>
{/if}
@@ -207,7 +211,7 @@
</div>
{#if isTouchDevice}
<div class="mobilebar">
<button onclick={() => runCommand('upload')}>+ media</button>
<button onclick={() => runCommand('upload')}>+ file</button>
<button onclick={() => runCommand('w')}>save</button>
<button onclick={() => runCommand('wq')}>done</button>
</div>
+2 -2
View File
@@ -248,8 +248,8 @@
async function uploadFiles(files: File[]) {
if (files.length === 0) return
await workspace.guard(async () => {
const uploaded = await workspace.upload(files)
insertAtCursor(uploaded.map(embedSnippet).join('\n\n'))
const created = await workspace.attach(files, tile)
if (created.length > 0) insertAtCursor(created.map(embedSnippet).join('\n\n'))
})
}
+44 -3
View File
@@ -1,5 +1,7 @@
<script lang="ts" module>
import type { AgendaCard, Backlink, NodeSummary, RevisionSummary } from './api'
import { hooks } from './commands'
import { embedSnippet } from './markdown'
import { workspace, type InfoSection } from './store.svelte'
import { formatStamp } from './util'
@@ -7,9 +9,9 @@
* What the keys in actions.ts do here too, so a key and a click are the
* same thing. The info sheet holds what is known about the open document
* besides its text: its headings, its earlier versions, what links to it,
* the cards about it, and whether it is shared.
* the cards about it, its own files, and whether it is shared.
*/
export const infoSections: InfoSection[] = ['outline', 'history', 'backlinks', 'cards', 'share']
export const infoSections: InfoSection[] = ['outline', 'history', 'backlinks', 'cards', 'files', 'share']
/** Tab and Shift-Tab: the next part along, round the ends. */
export function turnInfo(by: number) {
@@ -38,6 +40,25 @@
if (index !== workspace.historyCursor) void workspace.guard(() => workspace.showRevision(index))
}
/** Enter on one of the document's files: the file is opened. */
export function openFile(file: NodeSummary) {
workspace.closeSheet()
return workspace.guard(() => workspace.openNode(file.id))
}
/** `i`: the file embedded at the cursor, while the document is being written. */
export function embedFile(file: NodeSummary) {
if (!workspace.editing) return workspace.say('embedding works while writing — i on the document first', 'error')
hooks.insertText(embedSnippet(file))
workspace.closeSheet()
}
/** `y`: how to embed it, to paste into another document. */
export async function copyEmbed(file: NodeSummary) {
const copied = await workspace.copy(embedSnippet(file))
workspace.say(copied ? `copied how to embed ${file.name}` : 'the clipboard said no', copied ? 'info' : 'error')
}
/** Enter on a backlink: the linking document is opened. */
export function openBacklink(link: Backlink) {
workspace.closeSheet()
@@ -80,7 +101,7 @@
<script lang="ts">
import Sheet from './Sheet.svelte'
import { diffLines, folded } from './diff'
import { formatDay, relative } from './util'
import { formatBytes, formatDay, relative } from './util'
const doc = $derived(workspace.open)
@@ -116,6 +137,7 @@
['history', `history ${workspace.history.length}`],
['backlinks', `linked from ${workspace.backlinks.length}`],
['cards', `cards ${doc?.cards.filter((card) => card.done_at === null).length ?? 0}`],
['files', `files ${doc?.files.length ?? 0}`],
['share', doc?.shared ? 'shared' : 'private'],
])
@@ -125,6 +147,7 @@
history: workspace.historyCursor,
backlinks: workspace.backlinksCursor,
cards: workspace.cardsCursor,
files: workspace.filesCursor,
share: undefined,
}[section],
)
@@ -232,6 +255,24 @@
<li class="muted">no card about this document — c makes one, and a card's [[title]] counts too.</li>
{/each}
</ul>
{:else if section === 'files'}
<ul class="items">
{#each doc?.files ?? [] as file, index (file.id)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<li
class="item"
data-index={index}
class:at={index === workspace.filesCursor}
onpointerenter={() => (workspace.filesCursor = index)}
onclick={() => openFile(file)}
>
<span class="name">{file.name}</span>
<span class="muted">{file.file ? `${file.file.mime} · ${formatBytes(file.file.size)}` : ''}{file.file?.encrypted ? ' · sealed' : ''}</span>
</li>
{:else}
<li class="muted">no files of its own — a picture pasted or dropped while writing is one, and so is :upload while reading it.</li>
{/each}
</ul>
{:else}
<div class="share">
{#if sharePath}
-105
View File
@@ -1,105 +0,0 @@
<script lang="ts" module>
import { api, type MediaFile } from './api'
import { hooks } from './commands'
import { embedSnippet } from './markdown'
import { workspace } from './store.svelte'
/*
* What a row's buttons do, out here so the keys in App.svelte do the same:
* i inserts, y copies, x deletes.
*/
export function insertMedia(file: MediaFile) {
if (!workspace.editing) return workspace.say('insert works while writing — i on a document first', 'error')
hooks.insertText(embedSnippet(file))
workspace.closeSheet()
}
export async function copyMedia(file: MediaFile) {
const copied = await workspace.copy(location.origin + file.url)
workspace.say(copied ? `copied the address of ${file.filename}` : 'the clipboard said no', copied ? 'info' : 'error')
}
export async function removeMedia(file: MediaFile) {
if (file.file_node !== null) return workspace.say(`${file.filename} is a file in the tree — delete it there`, 'error')
const used = file.node_ids.length === 1 ? '1 document' : `${file.node_ids.length} documents`
const warning = file.node_ids.length > 0 ? ` it is embedded in ${used}, which will break` : ''
if (!(await workspace.ask(`delete ${file.filename}?${warning}`))) return
await workspace.guard(async () => {
await api.deleteMedia(file.id)
await workspace.loadMedia()
workspace.mediaCursor = Math.min(workspace.mediaCursor, Math.max(workspace.media.length - 1, 0))
})
}
</script>
<script lang="ts">
import Sheet from './Sheet.svelte'
import { formatBytes, formatStamp } from './util'
const usage = (nodeIds: number[], fileNode: number | null) => {
const embeds = nodeIds.map((id) => `#${id}`).join(' ')
if (fileNode !== null) return embeds ? `in the tree · ${embeds}` : 'in the tree'
return embeds || 'unattached'
}
</script>
<Sheet title="media" detail="{workspace.media.length} files" cursor={workspace.mediaCursor} keys={['media', 'sheet']} hide={workspace.editing ? [] : ['insert']}>
<ul class="items">
{#each workspace.media as file, index (file.id)}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<li class="item" class:at={index === workspace.mediaCursor} data-index={index} onpointerenter={() => (workspace.mediaCursor = index)}>
<div class="thumb">
{#if file.encrypted}
<span class="label">sealed</span>
{:else if file.mime.startsWith('image/')}
<img src={file.url} alt={file.filename} loading="lazy" />
{:else}
<span class="label">{file.mime.split('/')[0]}</span>
{/if}
</div>
<div class="info">
<span class="name">{file.filename}</span>
<span class="muted">
{formatBytes(file.size)} · {formatStamp(file.created_at)} ·
{usage(file.node_ids, file.file_node)}
</span>
</div>
<div class="actions">
{#if workspace.editing}
<button onclick={() => insertMedia(file)}>insert</button>
{/if}
<button onclick={() => copyMedia(file)}>copy url</button>
{#if file.file_node === null}
<button class="danger" onclick={() => removeMedia(file)}>rm</button>
{/if}
</div>
</li>
{:else}
<li class="muted">nothing uploaded yet — attach a file from the editor.</li>
{/each}
</ul>
</Sheet>
<style>
.items > .item { flex-direction: row; align-items: center; gap: 14px; cursor: default; }
.thumb {
width: 52px;
height: 38px;
flex: none;
display: grid;
place-items: center;
background: var(--surface);
border: 1px solid var(--rim);
border-radius: var(--radius-s);
overflow: hidden;
}
.thumb .label { font-size: 9px; letter-spacing: 0.14em; }
.thumb img { width: 100%; height: 100%; object-fit: cover; }
.info { display: flex; flex-direction: column; min-width: 0; flex: 1; font-size: 12px; }
.actions { display: flex; gap: 4px; flex: none; }
</style>
+19 -5
View File
@@ -20,14 +20,20 @@
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 file by its size. */
/*
* A folder is listed by what is in it, a document by when it was written —
* and how many files it has, folded or not — a file by its size.
*/
const aside = (node: NodeSummary) =>
node.file
? formatBytes(node.file.size)
: node.kind === 'document'
? formatTime(node.created_at)
? `${node.child_count > 0 ? `+${node.child_count} · ` : ''}${formatTime(node.created_at)}`
: `${node.child_count}`
/* The row a day heading compares with: the one above, past a document's files. */
const above = (index: number) => workspace.nodes.slice(0, index).filter((n) => !workspace.isNested(n)).at(-1)
const carriesFiles = (event: DragEvent) => event.dataTransfer?.types.includes('Files') ?? false
function drop(event: DragEvent) {
@@ -56,6 +62,9 @@
workspace.openSheet('actions')
}
/* What is in the folder itself, not the files folded open under its documents. */
const items = $derived(workspace.nodes.filter((node) => !workspace.isNested(node)).length)
/* Inside a folder, a phone has no h key and no crumbs on the bar: this is the way up. */
const nested = $derived(isTouchDevice && !workspace.query && workspace.path.length > 1)
</script>
@@ -78,7 +87,7 @@
<span class="marker"></span>
<span class="name">{workspace.space?.slug ?? 'diary'}</span>
<span class="count">
{workspace.nodes.length} {workspace.nodes.length === 1 ? 'item' : 'items'}{#if workspace.foundCards.length > 0}
{items} {items === 1 ? 'item' : 'items'}{#if workspace.foundCards.length > 0}
· {workspace.foundCards.length} {workspace.foundCards.length === 1 ? 'card' : 'cards'}{/if}{#if workspace.foundNotes.length > 0}
· {workspace.foundNotes.length} {workspace.foundNotes.length === 1 ? 'note' : 'notes'}{/if}
</span>
@@ -103,10 +112,11 @@
</li>
{/if}
{#each workspace.nodes as node, index (node.id)}
{@const previous = workspace.nodes[index - 1]}
{@const previous = above(index)}
{@const nested = workspace.isNested(node)}
<!-- Day headings only make sense in a list that is a journal: documents,
in date order, not a search and not a folder listing. -->
{#if !workspace.query && node.kind === 'document' && (!previous || previous.kind !== 'document' || formatDay(previous.created_at) !== formatDay(node.created_at))}
{#if !workspace.query && !nested && node.kind === 'document' && (!previous || previous.kind !== 'document' || formatDay(previous.created_at) !== formatDay(node.created_at))}
<li class="daybreak label rule">
<span><span class="day">{formatDay(node.created_at)}</span> {formatWeekday(node.created_at)}</span>
</li>
@@ -127,6 +137,7 @@
class:active={index === workspace.cursor}
class:open={workspace.tiles.some((tile) => tile.open?.id === node.id)}
class:marked={workspace.isMarked(node.id)}
class:nested
data-index={index}
onclick={(event) => click(event, index, node)}
>
@@ -324,6 +335,9 @@
.item { display: flex; align-items: center; }
.more { flex: none; display: grid; place-items: center; width: 40px; height: 40px; padding: 0; border: 0; background: none; color: var(--muted); }
.up .title { font-family: var(--font-mono); }
/* A document's own files, folded open under it. */
.row.nested { padding-left: 28px; }
.row.nested .title { font-size: 13px; }
/* Roomier tap targets where there is no keyboard. */
@media (pointer: coarse) {
+16 -16
View File
@@ -11,9 +11,8 @@
import { cycleTile, focusTile, followLink, hooks, openInfo, runCommand } from './commands'
import { bindings, type Context } from './keymap'
import { focusHelpFilter, scrollHelp } from './Help.svelte'
import { copyMedia, insertMedia, removeMedia } from './MediaBrowser.svelte'
import { purgeTrashed, restoreTrashed } from './Trash.svelte'
import { goHeading, moveHistory, openBacklink, renewLink, restoreShown, toggleSharing, turnInfo } from './Info.svelte'
import { copyEmbed, embedFile, goHeading, moveHistory, openBacklink, openFile, renewLink, restoreShown, toggleSharing, turnInfo } from './Info.svelte'
import { workspace } from './store.svelte'
import { dwindle, formatDay, neighbour, type Direction } from './util'
@@ -34,7 +33,7 @@ const spaceIndex = (key: string) => {
const reading = () => !!workspace.open && !workspace.editing && workspace.pane === 'document'
/** A cursor down a sheet's list, kept on it. */
function step(cursor: 'outlineCursor' | 'mediaCursor' | 'trashCursor' | 'backlinksCursor' | 'cardsCursor', length: number, by: number) {
function step(cursor: 'outlineCursor' | 'filesCursor' | 'trashCursor' | 'backlinksCursor' | 'cardsCursor', length: number, by: number) {
workspace[cursor] = Math.min(Math.max(workspace[cursor] + by, 0), Math.max(length - 1, 0))
}
@@ -163,22 +162,22 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
go: () => goHeading(),
},
/** The media list: j/k down the files, i inserts, y copies, x deletes. */
media: {
down: () => step('mediaCursor', workspace.media.length, 1),
up: () => step('mediaCursor', workspace.media.length, -1),
bottom: () => step('mediaCursor', workspace.media.length, Infinity),
/** Its files: j/k down them, Enter opens one, i embeds it while writing, y copies how. */
files: {
down: () => step('filesCursor', workspace.open?.files.length ?? 0, 1),
up: () => step('filesCursor', workspace.open?.files.length ?? 0, -1),
bottom: () => step('filesCursor', workspace.open?.files.length ?? 0, Infinity),
open: () => {
const file = workspace.open?.files[workspace.filesCursor]
if (file) void openFile(file)
},
insert: () => {
const file = workspace.media[workspace.mediaCursor]
if (file) insertMedia(file)
const file = workspace.open?.files[workspace.filesCursor]
if (file) embedFile(file)
},
copy: () => {
const file = workspace.media[workspace.mediaCursor]
if (file) void copyMedia(file)
},
delete: () => {
const file = workspace.media[workspace.mediaCursor]
if (file) void removeMedia(file)
const file = workspace.open?.files[workspace.filesCursor]
if (file) void copyEmbed(file)
},
},
@@ -386,6 +385,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
// Pick nodes up here, walk to a folder, put them down with `p`.
mark: () => workspace.mark(),
markAll: () => workspace.markAll(),
files: () => guard(() => workspace.toggleFiles()),
drop: () => guard(() => workspace.drop()),
new: () => void runCommand('new'),
// Capture: a thought goes on this space's inbox without leaving here.
+5 -24
View File
@@ -57,6 +57,8 @@ export interface WorkspaceNode extends NodeSummary {
cards: AgendaCard[]
/** Where each `[[title]]` in the body leads, as an /n/ path; a title that leads nowhere is missing. */
wikilinks: Record<string, string>
/** A document's own files, oldest first: what was pasted into it, and what was put beside it. */
files: NodeSummary[]
}
/** A document that links to the one asked about, and the line it does it on. */
@@ -256,20 +258,6 @@ export interface Board {
lists: BoardList[]
}
export interface MediaFile {
id: string
filename: string
mime: string
size: number
created_at: number
url: string
encrypted: boolean
/** Every document that embeds this file — a file may be used by several. */
node_ids: number[]
/** The file node it is, when it sits in the tree; deleted from there. */
file_node: number | null
}
/** One thing a certificate, key or keystore file holds, as `/inspect` describes it. */
export type CertItem =
| {
@@ -627,21 +615,14 @@ export const api = {
moveCard: (id: number, list_id: number, position?: number) =>
request<Moved>(`/cards/${id}/move`, { method: 'POST', body: body({ list_id, position }) }),
listMedia: () => pages((limit, offset) => request<MediaFile[]>(`/media?limit=${limit}&offset=${offset}`)),
/**
* 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.
* Upload files into a space or folder, or to a document as its own files.
* XHR rather than fetch, because a gigabyte going up needs to say how far
* along it is.
*/
putFiles: (parentId: number, files: File[], encrypt: boolean, progress?: (fraction: number) => void) =>
send<WorkspaceNode[]>(`/api/nodes/${parentId}/files${encrypt ? '?encrypt=true' : ''}`, files, progress),
/** Files to embed in a document, with the same progress as `putFiles`. */
upload: (files: File[], progress?: (fraction: number) => void) =>
send<MediaFile[]>('/api/media', files, progress),
deleteMedia: (id: string) => request<{ ok: true }>(`/media/${id}`, { method: 'DELETE' }),
vaultStatus: () => request<VaultStatus>('/vault'),
setupVault: (password: string) =>
+10 -15
View File
@@ -93,11 +93,16 @@ export async function openInfo(section?: InfoSection) {
await workspace.loadBacklinks()
// Picked afresh each time, from the first: the open card leads.
workspace.cardsCursor = 0
workspace.filesCursor = 0
if (section) workspace.infoSection = section
workspace.openSheet('info')
})
}
/** A document is open and in focus — being written or read — rather than the list. */
const readingDocument = () =>
workspace.editing || (workspace.focus === 'node' && workspace.open?.kind === 'document')
/** The board's cursor, or the card turned over beside it, has the keys. */
const onBoard = () => workspace.focus === 'board' || workspace.focus === 'card'
@@ -1127,8 +1132,8 @@ export const commands: CommandSpec[] = [
name: 'upload',
aliases: ['up'],
group: 'editing',
help: 'while writing, attach files at the cursor — otherwise put files into this folder',
run: () => (workspace.editing ? hooks.pickFiles() : hooks.pickTreeFiles(false)),
help: "files for the open document — while writing, embedded at the cursor — or, from the list, into this folder",
run: () => (readingDocument() ? hooks.pickFiles() : hooks.pickTreeFiles(false)),
bang: {
help: 'put files into this folder sealed in the vault — encrypted before they touch the disk',
run: () =>
@@ -1137,16 +1142,6 @@ export const commands: CommandSpec[] = [
}),
},
},
{
name: 'media',
group: 'editing',
help: 'browse everything you have uploaded — j/k, i inserts it while writing, y copies its address, x deletes',
run: () => workspace.guard(async () => {
await workspace.loadMedia()
workspace.mediaCursor = 0
workspace.openSheet('media')
}),
},
{
name: 'outline',
aliases: ['toc'],
@@ -1156,13 +1151,13 @@ export const commands: CommandSpec[] = [
},
{
name: 'info',
args: '[outline|history|backlinks|cards|share]',
args: '[outline|history|backlinks|cards|files|share]',
group: 'documents',
help: 'what is known about the open document: its headings, earlier versions, backlinks, cards and sharing — Tab between them (gi)',
help: 'what is known about the open document: its headings, earlier versions, backlinks, cards, files and sharing — Tab between them (gi)',
complete: () => infoSections,
run: (arg) => {
const section = arg.trim()
if (section && !infoSections.includes(section as InfoSection)) return workspace.say('usage: :info [outline|history|backlinks|cards|share]', 'error')
if (section && !infoSections.includes(section as InfoSection)) return workspace.say('usage: :info [outline|history|backlinks|cards|files|share]', 'error')
return openInfo((section || undefined) as InfoSection | undefined)
},
},
+8 -7
View File
@@ -20,7 +20,7 @@ export type Context =
| 'overview'
| 'info'
| 'outline'
| 'media'
| 'files'
| 'trash'
| 'backlinks'
| 'cards'
@@ -103,6 +103,7 @@ export const bindings = [
{ context: 'tree', keys: ['R'], action: 'rename', help: 'rename what is under the cursor — document, folder or card — starting from its name' },
{ context: 'tree', keys: ['m'], action: 'mark', label: 'm / p', help: 'pick nodes up, then drop them all into the folder you walk to' },
{ context: 'tree', keys: ['p'], action: 'drop' },
{ context: 'tree', keys: ['za'], action: 'files', help: "a document's own files, folded open under it in the list — again folds them away; p drops marked files in there" },
{ context: 'tree', keys: ['M'], action: 'markAll', help: 'pick up the whole folder — again puts it all back' },
{ context: 'tree', label: 'Ctrl / Shift-click', help: 'pick a row up · every row up to it — drag them onto a folder' },
{ context: 'global', keys: ['S'], action: 'plan', help: 'the board, the agenda and the calendar, tiled ahead of what is open with the board focused — S again puts every view away' },
@@ -245,12 +246,12 @@ export const bindings = [
{ context: 'outline', keys: ['G'], action: 'bottom' },
{ context: 'outline', keys: ['Enter', 'l'], action: 'go', caps: ['↵'], hint: 'jump there' },
{ context: 'media', keys: ['j', 'ArrowDown'], action: 'down', caps: ['j', 'k'], hint: 'move' },
{ context: 'media', keys: ['k', 'ArrowUp'], action: 'up' },
{ context: 'media', keys: ['G'], action: 'bottom' },
{ context: 'media', keys: ['Enter', 'i'], action: 'insert', caps: ['i'], hint: 'insert' },
{ context: 'media', keys: ['y'], action: 'copy', hint: 'copy url' },
{ context: 'media', keys: ['x'], action: 'delete', hint: 'delete' },
{ context: 'files', keys: ['j', 'ArrowDown'], action: 'down', caps: ['j', 'k'], hint: 'move' },
{ context: 'files', keys: ['k', 'ArrowUp'], action: 'up' },
{ context: 'files', keys: ['G'], action: 'bottom' },
{ context: 'files', keys: ['Enter', 'l'], action: 'open', caps: ['↵'], hint: 'open' },
{ context: 'files', keys: ['i'], action: 'insert', hint: 'embed' },
{ context: 'files', keys: ['y'], action: 'copy', hint: 'copy embed' },
{ context: 'trash', keys: ['j', 'ArrowDown'], action: 'down', caps: ['j', 'k'], hint: 'move' },
{ context: 'trash', keys: ['k', 'ArrowUp'], action: 'up' },
+5 -3
View File
@@ -187,9 +187,11 @@ export function renderMarkdown(
return holder.innerHTML
}
/** The markdown snippet that embeds a freshly uploaded file. */
export function embedSnippet(file: { url: string; filename: string; mime: string }): string {
const name = file.filename.replaceAll(']', '')
/** The markdown snippet that embeds a file node: a picture, a player, or a link to it. */
export function embedSnippet(node: { name: string; file: { url: string; mime: string } | null }): string {
const file = node.file
if (!file) return ''
const name = node.name.replaceAll(']', '')
if (file.mime.startsWith('image/')) return `![${name}](${file.url})`
if (file.mime.startsWith('video/')) return `<video src="${file.url}" controls playsinline></video>`
if (file.mime.startsWith('audio/')) return `<audio src="${file.url}" controls></audio>`
+1 -1
View File
@@ -21,7 +21,7 @@ import { bindings, type Binding, type Context } from './keymap'
import { workspace } from './store.svelte'
/** The sheets with keys of their own, under the same name as the sheet. */
const sheets = new Set<string>(['help', 'overview', 'media', 'trash'])
const sheets = new Set<string>(['help', 'overview', 'trash'])
/** The stack, the topmost first. */
export function stack(): Context[] {
+74 -18
View File
@@ -12,7 +12,6 @@ import {
type Crumb,
type DiaryEntry,
type WorkspaceNode,
type MediaFile,
type Moved,
type NodeSummary,
type Revision,
@@ -35,9 +34,9 @@ export type Pane = 'list' | 'document'
export type TileKind = 'node' | 'board' | 'agenda' | 'calendar' | 'card'
export type View = Exclude<TileKind, 'node'>
/** What floats over the tiles; one at a time. */
export type Sheet = 'help' | 'media' | 'overview' | 'actions' | 'verify' | 'info' | 'trash'
export type Sheet = 'help' | 'overview' | 'actions' | 'verify' | 'info' | 'trash'
/** The info sheet's parts: what the open document holds, and what is around it. */
export type InfoSection = 'outline' | 'history' | 'backlinks' | 'cards' | 'share'
export type InfoSection = 'outline' | 'history' | 'backlinks' | 'cards' | 'files' | 'share'
export type Flash = { text: string; kind: 'info' | 'error' } | null
/**
@@ -404,8 +403,6 @@ class Workspace {
secret = $state.raw<{ text: string; resolve: (value: string | null) => void } | null>(null)
/** The vault, as last heard from the server; null until asked. */
vault = $state<VaultStatus | null>(null)
media = $state<MediaFile[]>([])
mediaCursor = $state(0)
/** What `:trash` lists: everything deleted, the last first. */
trash = $state<TrashItem[]>([])
trashCursor = $state(0)
@@ -420,6 +417,10 @@ class Workspace {
backlinksCursor = $state(0)
/** The info sheet's cards: where its cursor is in the open document's. */
cardsCursor = $state(0)
/** The file picked in the info sheet's files: Enter opens it, `i` embeds it while writing. */
filesCursor = $state(0)
/** Documents whose files `za` has folded open under them in the list. */
unfolded = $state<number[]>([])
overview = $state<SpaceOverview[]>([])
overviewCursor = $state(0)
@@ -991,7 +992,7 @@ class Workspace {
this.globalSearch = false
this.path = folder.path
this.spaceId = folder.path[0]?.id ?? id
this.nodes = await api.listNodes({ parent: id })
this.nodes = await this.#withFiles(await api.listNodes({ parent: id }))
const index = this.nodes.findIndex((n) => n.id === keep)
this.cursor = index >= 0 ? index : 0
this.#syncUrl(folder.path)
@@ -1017,7 +1018,7 @@ class Workspace {
api.scratch(this.query),
])
} else {
this.nodes = await api.listNodes({ parent: this.parentId ?? undefined })
this.nodes = await this.#withFiles(await api.listNodes({ parent: this.parentId ?? undefined }))
this.foundCards = []
this.foundNotes = []
}
@@ -1025,6 +1026,34 @@ class Workspace {
this.cursor = index >= 0 ? index : Math.min(this.cursor, Math.max(this.rows - 1, 0))
}
/** The list, with the files of each document `za` folded open under it. */
async #withFiles(list: NodeSummary[]): Promise<NodeSummary[]> {
const open = list.filter((n) => n.kind === 'document' && n.child_count > 0 && this.unfolded.includes(n.id))
if (open.length === 0) return list
const files = new Map(await Promise.all(open.map(async (doc) => [doc.id, await api.listNodes({ parent: doc.id })] as const)))
return list.flatMap((n) => [n, ...(files.get(n.id) ?? [])])
}
/** `za`: a document's own files, folded open under it in the list, or away again. */
async toggleFiles() {
const node = this.selected
if (!node || this.query) return
const doc = node.kind === 'document' ? node : this.nodes.find((n) => n.id === node.parent_id && n.kind === 'document')
if (!doc) return this.say('za folds open the files of a document', 'error')
if (this.unfolded.includes(doc.id)) {
this.unfolded = this.unfolded.filter((id) => id !== doc.id)
} else {
if (doc.child_count === 0) return this.say('no files — :upload while reading it gives it some', 'error')
this.unfolded = [...this.unfolded, doc.id]
}
await this.refresh(doc.id)
}
/** A row the list shows under its document, not in the folder itself. */
isNested(node: NodeSummary): boolean {
return !this.query && node.parent_id !== this.parentId
}
/** The space a search keeps to; none for one across every space. */
get #searchSpace(): number | undefined {
return this.globalSearch ? undefined : (this.spaceId ?? undefined)
@@ -1234,7 +1263,11 @@ class Workspace {
if (this.overlay !== 'trash') await this.enterSpace(node.id)
this.say(`restored the space ${name}`)
} else {
const parent = node.path.at(-2)
// A document's file comes back to the document, shown folded open in
// the list of the folder the document is in.
const owner = node.path.at(-2)?.kind === 'document' ? node.path.at(-2) : undefined
if (owner && !this.unfolded.includes(owner.id)) this.unfolded = [...this.unfolded, owner.id]
const parent = node.path.at(owner ? -3 : -2)
if (parent && node.path[0]?.id === this.spaceId) {
if (parent.id === this.parentId && !this.query) await this.refresh(node.id)
else if (this.overlay !== 'trash') await this.goTo(parent.id, node.id)
@@ -1608,11 +1641,21 @@ class Workspace {
this.say(`marked ${what} — p drops ${count === 1 ? 'it' : 'them'} in the folder you are in`)
}
/** `p`: drop the marked nodes into the folder the list is showing. */
/**
* `p`: drop the marked nodes into the folder the list is showing — or,
* when they are files and the cursor is in a document's files folded open,
* into that document.
*/
async drop() {
const parent = this.parentId
if (this.marked.length === 0) return this.say('nothing marked — press m on a row first', 'error')
if (parent === null) return this.say('no folder open', 'error')
const at = this.selected
const doc = at && this.isNested(at) ? at.parent_id : at?.id
if (doc && this.unfolded.includes(doc) && this.marked.every((n) => n.kind === 'file')) {
const name = this.nodes.find((n) => n.id === doc)?.name.trim()
return this.moveNodes(this.marked, doc, `to ${name || 'the document'}`)
}
await this.moveNodes(this.marked, parent)
}
@@ -2409,19 +2452,32 @@ class Workspace {
this.say(created.length === 1 ? `uploaded ${this.here}/${created[0].slug}` : `uploaded ${created.length} files`)
}
async upload(files: File[]): Promise<MediaFile[]> {
/**
* Give the document in `tile` files of its own — what a picture pasted
* while writing is too. Answers the file nodes, for the editor to embed.
*/
async attach(files: File[], tile = this.tile): Promise<NodeSummary[]> {
const doc = tile.open
if (doc?.kind !== 'document') {
this.say('files go to a document — open one first', 'error')
return []
}
// In a sealed folder they are sealed on the way, which needs the vault.
if (doc.sealed && !(await this.ensureUnlocked())) return []
const what = files.length === 1 ? files[0].name : `${files.length} files`
const total = files.reduce((sum, file) => sum + file.size, 0)
this.say(`uploading ${what} (${formatBytes(total)}) …`)
const uploaded = await api.upload(files, (fraction) =>
const name = doc.name.trim() || doc.slug
this.say(`uploading ${what} (${formatBytes(total)}) to ${name} …`)
const created = await api.putFiles(doc.id, files, doc.sealed, (fraction) =>
this.say(`uploading ${what} — ${Math.floor(fraction * 100)}%`),
)
this.say(`uploaded ${uploaded.length} file${uploaded.length === 1 ? '' : 's'}`)
return uploaded
}
async loadMedia() {
this.media = await api.listMedia()
// Its list of files, without touching what is being written in it.
if (tile.open?.id === doc.id) {
tile.open = { ...tile.open, files: [...tile.open.files, ...created], child_count: tile.open.child_count + created.length }
}
await this.refresh()
this.say(`${created.length === 1 ? created[0].name : `${created.length} files`} now belong${created.length === 1 ? 's' : ''} to ${name}`)
return created
}
setVim(enabled: boolean) {