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

+65
View File
@@ -0,0 +1,65 @@
-- A file belongs to a node. A picture pasted into a document used to be a row
-- of `media` and nothing more: the document embedded it, but only the media
-- browser could list it, and it did not move, seal or go to the trash with
-- the document. Now an upload while writing is a file node of the document,
-- and each of the old ones becomes one too, under the document that embeds
-- it. The media id stays, so no `/api/media/<id>` in any body changes.
--
-- A file embedded in several documents goes to one of them, a document that
-- is not in the trash before one that is, and the oldest of those. The slug
-- cannot be derived from the name in SQL, so, as in 0010, it is the start of
-- the media id: unique, and renaming the file fixes it for good.
WITH RECURSIVE trashed (id) AS (
SELECT id FROM nodes WHERE role = 'trash'
UNION ALL
SELECT n.id FROM nodes n JOIN trashed t ON n.parent_id = t.id
),
owners (media_id, node_id, rank) AS (
SELECT nm.media_id, nm.node_id,
ROW_NUMBER() OVER (
PARTITION BY nm.media_id
ORDER BY nm.node_id IN (SELECT id FROM trashed), nm.node_id
)
FROM node_media nm
JOIN nodes d ON d.id = nm.node_id
WHERE d.kind = 'document'
)
INSERT INTO nodes (parent_id, kind, name, slug, created_at, updated_at, media_id)
SELECT o.node_id, 'file', m.filename, 'file-' || substr(m.id, 1, 8),
m.created_at, m.created_at, m.id
FROM media m
JOIN owners o ON o.media_id = m.id AND o.rank = 1
WHERE NOT EXISTS (SELECT 1 FROM nodes f WHERE f.media_id = m.id);
-- A file node owns its bytes the way a document owns what it embeds, so
-- purging the document still finds every file that goes with it.
INSERT OR IGNORE INTO node_media (node_id, media_id)
SELECT id, media_id FROM nodes WHERE kind = 'file';
-- A document is not sealed itself, but the folder it is in decides for its
-- files, the same as for a file beside it. The triggers of 0015 asked only
-- the parent, which for a document's file is the document.
DROP TRIGGER nodes_sealed_insert;
DROP TRIGGER nodes_sealed_update;
CREATE TRIGGER nodes_sealed_insert BEFORE INSERT ON nodes
WHEN new.kind = 'file'
AND (SELECT CASE WHEN p.kind = 'document'
THEN (SELECT g.sealed FROM nodes g WHERE g.id = p.parent_id)
ELSE p.sealed END
FROM nodes p WHERE p.id = new.parent_id) = 1
AND (SELECT encrypted FROM media WHERE id = new.media_id) = 0
BEGIN
SELECT RAISE(ABORT, 'sealed folder: a file here must be encrypted');
END;
CREATE TRIGGER nodes_sealed_update BEFORE UPDATE OF parent_id, media_id ON nodes
WHEN new.kind = 'file'
AND (SELECT CASE WHEN p.kind = 'document'
THEN (SELECT g.sealed FROM nodes g WHERE g.id = p.parent_id)
ELSE p.sealed END
FROM nodes p WHERE p.id = new.parent_id) = 1
AND (SELECT encrypted FROM media WHERE id = new.media_id) = 0
BEGIN
SELECT RAISE(ABORT, 'sealed folder: a file here must be encrypted');
END;