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

+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)
},
},