fundus 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -0
- package/build/client/_app/immutable/assets/0.DLhIsYA_.css.br +0 -0
- package/build/client/_app/immutable/assets/0.DLhIsYA_.css.gz +0 -0
- package/build/client/_app/immutable/assets/2.wavcNhPC.css +1 -0
- package/build/client/_app/immutable/assets/2.wavcNhPC.css.br +0 -0
- package/build/client/_app/immutable/assets/2.wavcNhPC.css.gz +0 -0
- package/build/client/_app/immutable/assets/3.Cjh_fXPY.css +1 -0
- package/build/client/_app/immutable/assets/3.Cjh_fXPY.css.br +0 -0
- package/build/client/_app/immutable/assets/3.Cjh_fXPY.css.gz +0 -0
- package/build/client/_app/immutable/assets/4.p004hmEN.css +1 -0
- package/build/client/_app/immutable/assets/4.p004hmEN.css.br +0 -0
- package/build/client/_app/immutable/assets/4.p004hmEN.css.gz +0 -0
- package/build/client/_app/immutable/assets/ConfirmDialog.BdKlitwi.css +1 -0
- package/build/client/_app/immutable/assets/ConfirmDialog.BdKlitwi.css.br +0 -0
- package/build/client/_app/immutable/assets/ConfirmDialog.BdKlitwi.css.gz +0 -0
- package/build/client/_app/immutable/assets/LibraryContextMenu.zyZtpKzC.css +1 -0
- package/build/client/_app/immutable/assets/LibraryContextMenu.zyZtpKzC.css.br +0 -0
- package/build/client/_app/immutable/assets/LibraryContextMenu.zyZtpKzC.css.gz +0 -0
- package/build/client/_app/immutable/chunks/BiIto6ft.js +1 -0
- package/build/client/_app/immutable/chunks/BiIto6ft.js.br +0 -0
- package/build/client/_app/immutable/chunks/BiIto6ft.js.gz +0 -0
- package/build/client/_app/immutable/chunks/Bjy-W4x2.js.br +0 -0
- package/build/client/_app/immutable/chunks/Bjy-W4x2.js.gz +0 -0
- package/build/client/_app/immutable/chunks/CNRz3sSw.js.br +0 -0
- package/build/client/_app/immutable/chunks/CNRz3sSw.js.gz +0 -0
- package/build/client/_app/immutable/chunks/CO4RBJfa.js.br +0 -0
- package/build/client/_app/immutable/chunks/CO4RBJfa.js.gz +0 -0
- package/{dist/editor/_app/immutable/chunks/DjvOiDmq.js → build/client/_app/immutable/chunks/CvY6YsFE.js} +1 -1
- package/build/client/_app/immutable/chunks/CvY6YsFE.js.br +0 -0
- package/build/client/_app/immutable/chunks/CvY6YsFE.js.gz +0 -0
- package/build/client/_app/immutable/chunks/DL0_39Ys.js +5 -0
- package/build/client/_app/immutable/chunks/DL0_39Ys.js.br +0 -0
- package/build/client/_app/immutable/chunks/DL0_39Ys.js.gz +0 -0
- package/{dist/editor/_app/immutable/chunks/BfPE5wdt.js → build/client/_app/immutable/chunks/DQ8SXFaX.js} +1 -1
- package/build/client/_app/immutable/chunks/DQ8SXFaX.js.br +0 -0
- package/build/client/_app/immutable/chunks/DQ8SXFaX.js.gz +0 -0
- package/build/client/_app/immutable/chunks/DjdrdRzT.js.br +0 -0
- package/build/client/_app/immutable/chunks/DjdrdRzT.js.gz +0 -0
- package/build/client/_app/immutable/chunks/xihTtKlq.js.br +1 -0
- package/build/client/_app/immutable/chunks/xihTtKlq.js.gz +0 -0
- package/{dist/editor/_app/immutable/entry/app.BkqbfI8n.js → build/client/_app/immutable/entry/app.ubN0v9bN.js} +2 -2
- package/build/client/_app/immutable/entry/app.ubN0v9bN.js.br +0 -0
- package/build/client/_app/immutable/entry/app.ubN0v9bN.js.gz +0 -0
- package/build/client/_app/immutable/entry/start.BW8dBDh6.js +1 -0
- package/build/client/_app/immutable/entry/start.BW8dBDh6.js.br +0 -0
- package/build/client/_app/immutable/entry/start.BW8dBDh6.js.gz +0 -0
- package/{dist/editor/_app/immutable/nodes/0.xdMI5GEU.js → build/client/_app/immutable/nodes/0.Dr5ijCIl.js} +1 -1
- package/build/client/_app/immutable/nodes/0.Dr5ijCIl.js.br +0 -0
- package/build/client/_app/immutable/nodes/0.Dr5ijCIl.js.gz +0 -0
- package/{dist/editor/_app/immutable/nodes/1.D9p2tfoJ.js → build/client/_app/immutable/nodes/1.DiIGIRAb.js} +1 -1
- package/build/client/_app/immutable/nodes/1.DiIGIRAb.js.br +3 -0
- package/build/client/_app/immutable/nodes/1.DiIGIRAb.js.gz +0 -0
- package/{dist/editor/_app/immutable/nodes/2.BrA0Hyzu.js → build/client/_app/immutable/nodes/2.B8KYmQcV.js} +3 -3
- package/build/client/_app/immutable/nodes/2.B8KYmQcV.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.B8KYmQcV.js.gz +0 -0
- package/build/client/_app/immutable/nodes/3.B1jc7arT.js +104 -0
- package/build/client/_app/immutable/nodes/3.B1jc7arT.js.br +0 -0
- package/build/client/_app/immutable/nodes/3.B1jc7arT.js.gz +0 -0
- package/build/client/_app/immutable/nodes/4.BPK9n1mH.js +2 -0
- package/build/client/_app/immutable/nodes/4.BPK9n1mH.js.br +0 -0
- package/build/client/_app/immutable/nodes/4.BPK9n1mH.js.gz +0 -0
- package/build/client/_app/version.json +1 -0
- package/build/client/_app/version.json.br +0 -0
- package/build/client/_app/version.json.gz +0 -0
- package/build/env.js +100 -0
- package/build/env.js.map +1 -0
- package/build/handler.js +22 -0
- package/build/handler.js.map +1 -0
- package/build/index.js +145 -0
- package/build/index.js.map +1 -0
- package/build/server/chunks/chunks/context.js-Bw4WWLhG.js +443 -0
- package/build/server/chunks/chunks/context.js-Bw4WWLhG.js.map +1 -0
- package/build/server/chunks/chunks/core.js-DDntpb72.js +409 -0
- package/build/server/chunks/chunks/core.js-DDntpb72.js.map +1 -0
- package/build/server/chunks/chunks/env.js-l0sNRNKZ.js +2 -0
- package/build/server/chunks/chunks/env.js-l0sNRNKZ.js.map +1 -0
- package/build/server/chunks/chunks/exports.js-CLTzaOxn.js +152 -0
- package/build/server/chunks/chunks/exports.js-CLTzaOxn.js.map +1 -0
- package/build/server/chunks/chunks/folder-path.js-ytx9ucgg.js +64 -0
- package/build/server/chunks/chunks/folder-path.js-ytx9ucgg.js.map +1 -0
- package/build/server/chunks/chunks/http.js-Dxoa-jtP.js +100 -0
- package/build/server/chunks/chunks/http.js-Dxoa-jtP.js.map +1 -0
- package/build/server/chunks/chunks/internal.js-BDBSzJId.js +5753 -0
- package/build/server/chunks/chunks/internal.js-BDBSzJId.js.map +1 -0
- package/build/server/chunks/chunks/internal2.js-BlXt2rfE.js +22 -0
- package/build/server/chunks/chunks/internal2.js-BlXt2rfE.js.map +1 -0
- package/{dist/operations-DYM-5KVl.js → build/server/chunks/chunks/library-store.js-BP8cL-qD.js} +344 -444
- package/build/server/chunks/chunks/library-store.js-BP8cL-qD.js.map +1 -0
- package/build/server/chunks/chunks/manifest-appearance.js-DTk355LQ.js +146 -0
- package/build/server/chunks/chunks/manifest-appearance.js-DTk355LQ.js.map +1 -0
- package/build/server/chunks/chunks/routing.js-y-tryuip.js +213 -0
- package/build/server/chunks/chunks/routing.js-y-tryuip.js.map +1 -0
- package/build/server/chunks/chunks/shared.js-BWXgl1Xf.js +255 -0
- package/build/server/chunks/chunks/shared.js-BWXgl1Xf.js.map +1 -0
- package/build/server/chunks/chunks/utils.js-9oI5Oz0w.js +1048 -0
- package/build/server/chunks/chunks/utils.js-9oI5Oz0w.js.map +1 -0
- package/build/server/chunks/entries/endpoints/api/_...path_/_server.ts.js-WknZVW_S.js +27 -0
- package/build/server/chunks/entries/endpoints/api/_...path_/_server.ts.js-WknZVW_S.js.map +1 -0
- package/build/server/chunks/entries/endpoints/api/assets/_id_/_server.ts.js-BrZ__-HZ.js +24 -0
- package/build/server/chunks/entries/endpoints/api/assets/_id_/_server.ts.js-BrZ__-HZ.js.map +1 -0
- package/build/server/chunks/entries/endpoints/api/assets/_server.ts.js-PqADGPXc.js +45 -0
- package/build/server/chunks/entries/endpoints/api/assets/_server.ts.js-PqADGPXc.js.map +1 -0
- package/build/server/chunks/entries/endpoints/api/assets/delete/_server.ts.js-OqoE5PN-.js +31 -0
- package/build/server/chunks/entries/endpoints/api/assets/delete/_server.ts.js-OqoE5PN-.js.map +1 -0
- package/build/server/chunks/entries/endpoints/api/assets/move/_server.ts.js-CJ08e1ho.js +31 -0
- package/build/server/chunks/entries/endpoints/api/assets/move/_server.ts.js-CJ08e1ho.js.map +1 -0
- package/build/server/chunks/entries/endpoints/api/folders/_path_/_server.ts.js-DdF3eK2I.js +38 -0
- package/build/server/chunks/entries/endpoints/api/folders/_path_/_server.ts.js-DdF3eK2I.js.map +1 -0
- package/build/server/chunks/entries/endpoints/api/folders/_server.ts.js-Cxt6jsJ5.js +31 -0
- package/build/server/chunks/entries/endpoints/api/folders/_server.ts.js-Cxt6jsJ5.js.map +1 -0
- package/build/server/chunks/entries/endpoints/api/manifests/_name_/_server.ts.js-BWVM2Ntl.js +24 -0
- package/build/server/chunks/entries/endpoints/api/manifests/_name_/_server.ts.js-BWVM2Ntl.js.map +1 -0
- package/build/server/chunks/entries/endpoints/api/manifests/_server.ts.js-Z2bM-qHM.js +31 -0
- package/build/server/chunks/entries/endpoints/api/manifests/_server.ts.js-Z2bM-qHM.js.map +1 -0
- package/build/server/chunks/entries/endpoints/api/state/_server.ts.js-BX9H2Z34.js +24 -0
- package/build/server/chunks/entries/endpoints/api/state/_server.ts.js-BX9H2Z34.js.map +1 -0
- package/build/server/chunks/entries/endpoints/files/_...path_/_server.ts.js-BWHDZ8mz.js +27 -0
- package/build/server/chunks/entries/endpoints/files/_...path_/_server.ts.js-BWHDZ8mz.js.map +1 -0
- package/build/server/chunks/entries/endpoints/files/proxy/_...path_/_server.ts.js-CR_7mndO.js +24 -0
- package/build/server/chunks/entries/endpoints/files/proxy/_...path_/_server.ts.js-CR_7mndO.js.map +1 -0
- package/build/server/chunks/entries/endpoints/files/raw/_...path_/_server.ts.js-Cw1PDBvb.js +24 -0
- package/build/server/chunks/entries/endpoints/files/raw/_...path_/_server.ts.js-Cw1PDBvb.js.map +1 -0
- package/build/server/chunks/entries/hooks.server.js-BHoE0yBi.js +23 -0
- package/build/server/chunks/entries/hooks.server.js-BHoE0yBi.js.map +1 -0
- package/build/server/chunks/handler-10cx4zHn.js +1475 -0
- package/build/server/chunks/handler-10cx4zHn.js.map +1 -0
- package/build/server/chunks/index.js-DvPfiC00.js +3789 -0
- package/build/server/chunks/index.js-DvPfiC00.js.map +1 -0
- package/build/server/chunks/manifest.js-DoPjUnzD.js +149 -0
- package/build/server/chunks/manifest.js-DoPjUnzD.js.map +1 -0
- package/build/server/chunks/nodes/0.js-Di3qgAtW.js +11 -0
- package/build/server/chunks/nodes/0.js-Di3qgAtW.js.map +1 -0
- package/build/server/chunks/nodes/1.js-BlWSFIA8.js +7 -0
- package/build/server/chunks/nodes/1.js-BlWSFIA8.js.map +1 -0
- package/build/server/chunks/nodes/2.js-CO9nSPUN.js +7 -0
- package/build/server/chunks/nodes/2.js-CO9nSPUN.js.map +1 -0
- package/build/server/chunks/nodes/3.js-C2D6xpEO.js +7 -0
- package/build/server/chunks/nodes/3.js-C2D6xpEO.js.map +1 -0
- package/build/server/chunks/nodes/4.js-B3IuagJW.js +7 -0
- package/build/server/chunks/nodes/4.js-B3IuagJW.js.map +1 -0
- package/build/shims.js +33 -0
- package/build/shims.js.map +1 -0
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +109 -1332
- package/dist/config.d.ts +3 -2
- package/dist/config.js +2 -2
- package/dist/core/api-types.d.ts +141 -0
- package/dist/core/api-types.js +1 -0
- package/dist/core/asset-id.d.ts +28 -0
- package/dist/core/asset-id.js +59 -0
- package/dist/core/asset-reference.d.ts +23 -0
- package/dist/core/asset-reference.js +15 -0
- package/dist/core/canonicalize.d.ts +9 -0
- package/dist/core/canonicalize.js +36 -0
- package/dist/core/chroma-key.d.ts +30 -0
- package/dist/core/chroma-key.js +55 -0
- package/dist/core/config.d.ts +35 -0
- package/dist/core/config.js +4 -0
- package/dist/core/folder-path.d.ts +19 -0
- package/dist/core/folder-path.js +86 -0
- package/dist/core/index.d.ts +33 -499
- package/dist/core/index.js +17 -962
- package/dist/core/ingest.d.ts +18 -0
- package/dist/core/ingest.js +58 -0
- package/dist/core/library.d.ts +54 -0
- package/dist/core/library.js +138 -0
- package/dist/core/manifest-appearance.d.ts +35 -0
- package/dist/core/manifest-appearance.js +123 -0
- package/dist/core/manifest-entry.d.ts +104 -0
- package/dist/core/manifest-entry.js +1 -0
- package/dist/core/manifest-membership.d.ts +56 -0
- package/dist/core/manifest-membership.js +54 -0
- package/dist/core/plugins/audio.d.ts +26 -0
- package/dist/core/plugins/audio.js +44 -0
- package/dist/core/plugins/chroma-key-video.d.ts +29 -0
- package/dist/core/plugins/chroma-key-video.js +69 -0
- package/dist/core/plugins/contract.d.ts +35 -0
- package/dist/core/plugins/contract.js +1 -0
- package/dist/core/plugins/image.d.ts +30 -0
- package/dist/core/plugins/image.js +45 -0
- package/dist/core/plugins/slice.d.ts +22 -0
- package/dist/core/plugins/slice.js +60 -0
- package/dist/core/plugins/video.d.ts +45 -0
- package/dist/core/plugins/video.js +77 -0
- package/dist/core/reference-graph.d.ts +41 -0
- package/dist/core/reference-graph.js +138 -0
- package/dist/core/slice-data.d.ts +61 -0
- package/dist/core/slice-data.js +49 -0
- package/dist/runtime/chroma-key-gl.d.ts +40 -22
- package/dist/runtime/chroma-key-gl.js +189 -183
- package/dist/runtime/components/ChromaKeyVideo.svelte +3 -3
- package/dist/runtime/components/ChromaKeyVideo.svelte.d.ts +21 -0
- package/dist/runtime/components/Image.svelte +1 -1
- package/dist/runtime/components/Image.svelte.d.ts +9 -0
- package/dist/runtime/components/Slice.svelte +1 -1
- package/dist/runtime/components/Slice.svelte.d.ts +19 -0
- package/dist/runtime/components/SliceCanvas.svelte +3 -3
- package/dist/runtime/components/SliceCanvas.svelte.d.ts +10 -0
- package/dist/runtime/components/SliceDom.svelte +3 -3
- package/dist/runtime/components/SliceDom.svelte.d.ts +10 -0
- package/dist/runtime/components/Video.svelte +2 -2
- package/dist/runtime/components/Video.svelte.d.ts +18 -0
- package/dist/runtime/geometry.d.ts +55 -35
- package/dist/runtime/geometry.js +186 -191
- package/dist/runtime/index.d.ts +9 -9
- package/dist/runtime/index.js +9 -9
- package/dist/runtime/kind-handlers.d.ts +33 -24
- package/dist/runtime/kind-handlers.js +74 -65
- package/dist/runtime/manifest.d.ts +53 -47
- package/dist/runtime/manifest.js +133 -113
- package/dist/runtime/observe-border-box-size.d.ts +1 -4
- package/dist/runtime/observe-border-box-size.js +9 -10
- package/dist/runtime/preload-registry.d.ts +22 -8
- package/dist/runtime/preload-registry.js +65 -62
- package/dist/server/build.d.ts +8 -0
- package/dist/server/build.js +25 -0
- package/dist/server/check.d.ts +24 -0
- package/dist/server/check.js +199 -0
- package/dist/server/cli-commands.d.ts +25 -0
- package/dist/server/cli-commands.js +1178 -0
- package/dist/server/cli-init.d.ts +3 -0
- package/dist/server/cli-init.js +53 -0
- package/dist/server/cli-io.d.ts +46 -0
- package/dist/server/cli-io.js +105 -0
- package/dist/server/codegen.d.ts +58 -0
- package/dist/server/codegen.js +185 -0
- package/dist/server/config-loader.d.ts +34 -0
- package/dist/server/config-loader.js +103 -0
- package/dist/server/context.d.ts +8 -0
- package/dist/server/context.js +19 -0
- package/dist/server/folder-filesystem.d.ts +7 -0
- package/dist/server/folder-filesystem.js +39 -0
- package/dist/server/http.d.ts +12 -0
- package/dist/server/http.js +100 -0
- package/dist/server/index.d.ts +16 -0
- package/dist/server/index.js +9 -0
- package/dist/server/job-hash.d.ts +10 -0
- package/dist/server/job-hash.js +23 -0
- package/dist/server/library-store.d.ts +82 -0
- package/dist/server/library-store.js +763 -0
- package/dist/server/operations.d.ts +87 -0
- package/dist/server/operations.js +427 -0
- package/dist/server/plugins/audio.d.ts +7 -0
- package/dist/server/plugins/audio.js +27 -0
- package/dist/server/plugins/chroma-key-video.d.ts +9 -0
- package/dist/server/plugins/chroma-key-video.js +38 -0
- package/dist/server/plugins/contract.d.ts +100 -0
- package/dist/server/plugins/contract.js +16 -0
- package/dist/server/plugins/image.d.ts +8 -0
- package/dist/server/plugins/image.js +30 -0
- package/dist/server/plugins/slice.d.ts +14 -0
- package/dist/server/plugins/slice.js +63 -0
- package/dist/server/plugins/video.d.ts +6 -0
- package/dist/server/plugins/video.js +29 -0
- package/dist/server/plugins.d.ts +23 -0
- package/dist/server/plugins.js +33 -0
- package/dist/server/processing/audio-processing.d.ts +17 -0
- package/dist/server/processing/audio-processing.js +46 -0
- package/dist/server/processing/ffmpeg.d.ts +9 -0
- package/dist/server/processing/ffmpeg.js +50 -0
- package/dist/server/processing/image-processing.d.ts +16 -0
- package/dist/server/processing/image-processing.js +26 -0
- package/dist/server/processing/media-probe.d.ts +15 -0
- package/dist/server/processing/media-probe.js +27 -0
- package/dist/server/processing/processor.d.ts +50 -0
- package/dist/server/processing/processor.js +158 -0
- package/dist/server/processing/video-processing.d.ts +35 -0
- package/dist/server/processing/video-processing.js +87 -0
- package/dist/server/server.d.ts +13 -0
- package/dist/server/server.js +57 -0
- package/dist/server/start.d.ts +1 -0
- package/dist/server/start.js +33 -0
- package/package.json +29 -16
- package/dist/build-CZYFLLvl.js +0 -191
- package/dist/core/image-Dqmz8FXS.js +0 -54
- package/dist/core/reference-graph-CjuHbLDw.d.ts +0 -280
- package/dist/core/testing.d.ts +0 -27
- package/dist/core/testing.js +0 -80
- package/dist/editor/200.html +0 -37
- package/dist/editor/_app/immutable/assets/2.DIbbW7U1.css +0 -1
- package/dist/editor/_app/immutable/assets/3.C-9vFQ9A.css +0 -1
- package/dist/editor/_app/immutable/assets/4.BS8ElpOV.css +0 -1
- package/dist/editor/_app/immutable/assets/ConfirmDialog.CHEISE-L.css +0 -1
- package/dist/editor/_app/immutable/assets/LibraryContextMenu.CDjCmdV-.css +0 -1
- package/dist/editor/_app/immutable/chunks/Cx6qtxJg.js +0 -1
- package/dist/editor/_app/immutable/chunks/n7mvYc42.js +0 -5
- package/dist/editor/_app/immutable/entry/start.C8HHOLly.js +0 -1
- package/dist/editor/_app/immutable/nodes/3.WO7tZH_d.js +0 -104
- package/dist/editor/_app/immutable/nodes/4.DQtx17aW.js +0 -2
- package/dist/editor/_app/version.json +0 -1
- package/dist/index.d.ts +0 -370
- package/dist/index.js +0 -4
- package/dist/start-Bhnqt9Dc.js +0 -279
- package/dist/start-D2IXOrni.js +0 -2
- /package/{dist/editor → build/client}/_app/immutable/assets/0.DLhIsYA_.css +0 -0
- /package/{dist/editor → build/client}/_app/immutable/chunks/Bjy-W4x2.js +0 -0
- /package/{dist/editor → build/client}/_app/immutable/chunks/CNRz3sSw.js +0 -0
- /package/{dist/editor → build/client}/_app/immutable/chunks/CO4RBJfa.js +0 -0
- /package/{dist/editor → build/client}/_app/immutable/chunks/DjdrdRzT.js +0 -0
- /package/{dist/editor → build/client}/_app/immutable/chunks/xihTtKlq.js +0 -0
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fs-backed store over the library file (SSOT) and the raw directory.
|
|
3
|
+
* Every mutation is load → modify → deterministic write, so the file on disk
|
|
4
|
+
* is always the current truth. Proxy files are the pipeline's business — this
|
|
5
|
+
* store only removes them on asset deletion (the pipeline also prunes orphans).
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { mkdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { dirname, extname, join } from 'node:path';
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { assetIdError, collectAllReferences, createEmptyLibrary, folderNameError, folderNameOf, folderOfRawPath, folderPathError, isFolderOrDescendant, joinFolderPath, manifestColorError, manifestEmojiError, parseLibrary, referenceErrors, normalizeFolderPath, parentFolderPath, portableFolderPathKey, rawFileNameOfPath, replaceFolderPrefix, manifestNameError, serializeLibrary } from '../core/index.js';
|
|
12
|
+
import { manifestModuleFileName } from './codegen.js';
|
|
13
|
+
import { descriptorsByType, serverPluginForType, serverPluginsByType } from './plugins.js';
|
|
14
|
+
import { isProxyFileNameFor } from './processing/processor.js';
|
|
15
|
+
import { ensureManagedFolder, listRelativeFiles, syncManagedFolders } from './folder-filesystem.js';
|
|
16
|
+
/** A single path segment with no traversal — guards against `../` escapes. */
|
|
17
|
+
const SAFE_SEGMENT = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
18
|
+
export function assertSafeFileName(fileName) {
|
|
19
|
+
if (!SAFE_SEGMENT.test(fileName) || fileName.includes('/') || fileName.includes('\\')) {
|
|
20
|
+
throw new Error(`Unsafe file name "${fileName}".`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Validate a persisted/served file path without allowing traversal outside its root. */
|
|
24
|
+
export function assertSafeRelativeFilePath(filePath) {
|
|
25
|
+
if (filePath.startsWith('/') || filePath.endsWith('/') || filePath.includes('\\')) {
|
|
26
|
+
throw new Error(`Unsafe file path "${filePath}".`);
|
|
27
|
+
}
|
|
28
|
+
const segments = filePath.split('/');
|
|
29
|
+
if (segments.length === 0)
|
|
30
|
+
throw new Error(`Unsafe file path "${filePath}".`);
|
|
31
|
+
assertSafeFileName(segments.at(-1) ?? '');
|
|
32
|
+
const folder = segments.slice(0, -1).join('/');
|
|
33
|
+
if (folder !== '') {
|
|
34
|
+
const problem = folderPathError(folder);
|
|
35
|
+
if (problem)
|
|
36
|
+
throw new Error(problem);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Turn an arbitrary uploaded file name (spaces, umlauts, parentheses, nested
|
|
41
|
+
* paths) into a safe single segment: strip the directory, transliterate to
|
|
42
|
+
* ASCII-ish, replace runs of disallowed characters with `-`, and lower-case
|
|
43
|
+
* the extension. Never throws for a normal image name.
|
|
44
|
+
*/
|
|
45
|
+
export function sanitizeFileName(original) {
|
|
46
|
+
const base = original.split(/[\\/]/).pop() ?? original;
|
|
47
|
+
const dot = base.lastIndexOf('.');
|
|
48
|
+
const rawStem = dot > 0 ? base.slice(0, dot) : base;
|
|
49
|
+
const rawExtension = dot > 0 ? base.slice(dot + 1) : '';
|
|
50
|
+
let stem = rawStem
|
|
51
|
+
.normalize('NFKD')
|
|
52
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
53
|
+
.replace(/-{2,}/g, '-')
|
|
54
|
+
.replace(/^[-.]+|[-.]+$/g, '');
|
|
55
|
+
if (!stem)
|
|
56
|
+
stem = 'asset';
|
|
57
|
+
if (!/^[a-zA-Z0-9]/.test(stem))
|
|
58
|
+
stem = `raw-${stem}`;
|
|
59
|
+
const extension = rawExtension.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
60
|
+
return extension ? `${stem}.${extension}` : stem;
|
|
61
|
+
}
|
|
62
|
+
export function sha256Hex(bytes) {
|
|
63
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
64
|
+
}
|
|
65
|
+
export class LibraryStore {
|
|
66
|
+
#config;
|
|
67
|
+
#registry;
|
|
68
|
+
// `read()` bypasses the queue and separate calls are not transactionally
|
|
69
|
+
// atomic; that tradeoff is intentional for this single-user development tool.
|
|
70
|
+
// Serializes mutations: every mutating operation chains onto the previous
|
|
71
|
+
// one's completion, so two concurrently handled requests can't interleave
|
|
72
|
+
// their read → modify → write cycles and lose one of the updates.
|
|
73
|
+
#queue = Promise.resolve();
|
|
74
|
+
constructor(config, registry = serverPluginsByType) {
|
|
75
|
+
this.#config = config;
|
|
76
|
+
this.#registry = registry;
|
|
77
|
+
}
|
|
78
|
+
get config() {
|
|
79
|
+
return this.#config;
|
|
80
|
+
}
|
|
81
|
+
/** Run a mutation after every previously enqueued one has settled. */
|
|
82
|
+
#enqueue(operation) {
|
|
83
|
+
const run = this.#queue.then(operation, operation);
|
|
84
|
+
this.#queue = run.then(() => { }, () => { });
|
|
85
|
+
return run;
|
|
86
|
+
}
|
|
87
|
+
/** Read + parse the library file; a missing file is an empty library. */
|
|
88
|
+
async read() {
|
|
89
|
+
if (!existsSync(this.#config.library))
|
|
90
|
+
return createEmptyLibrary();
|
|
91
|
+
return parseLibrary(await readFile(this.#config.library, 'utf8'));
|
|
92
|
+
}
|
|
93
|
+
/** Deterministically serialize + write the library file. */
|
|
94
|
+
async write(library) {
|
|
95
|
+
await mkdir(join(this.#config.library, '..'), { recursive: true });
|
|
96
|
+
const temporaryPath = `${this.#config.library}.tmp`;
|
|
97
|
+
await writeFile(temporaryPath, serializeLibrary(library));
|
|
98
|
+
await rename(temporaryPath, this.#config.library);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Ingest a new asset: write the raw file into `rawDir` (under a sanitized,
|
|
102
|
+
* non-colliding name), hash its content, and create the library record with
|
|
103
|
+
* the owning plugin's default parameters. Returns the updated library.
|
|
104
|
+
*/
|
|
105
|
+
async ingest(options) {
|
|
106
|
+
return this.#enqueue(() => this.#ingest(options));
|
|
107
|
+
}
|
|
108
|
+
async #ingest(options) {
|
|
109
|
+
const idProblem = assetIdError(options.id);
|
|
110
|
+
if (idProblem)
|
|
111
|
+
throw new Error(idProblem);
|
|
112
|
+
const plugin = serverPluginForType(options.type, this.#registry);
|
|
113
|
+
const extension = extname(sanitizeFileName(options.fileName)).slice(1).toLowerCase();
|
|
114
|
+
if (!plugin.descriptor.accepts.includes(extension)) {
|
|
115
|
+
throw new Error(`The ${options.type} type does not accept ".${extension}" files (accepts: ${plugin.descriptor.accepts.join(', ')}).`);
|
|
116
|
+
}
|
|
117
|
+
const library = await this.read();
|
|
118
|
+
if (Object.hasOwn(library.assets, options.id)) {
|
|
119
|
+
throw new Error(`An asset with id "${options.id}" already exists.`);
|
|
120
|
+
}
|
|
121
|
+
const folder = options.folder === undefined ? '' : normalizeFolderPath(options.folder);
|
|
122
|
+
this.#requireFolder(library, folder);
|
|
123
|
+
const rawDirectory = join(this.#config.rawDir, folder);
|
|
124
|
+
await mkdir(rawDirectory, { recursive: true });
|
|
125
|
+
const storedName = this.#uniqueRawName(sanitizeFileName(options.fileName), folder);
|
|
126
|
+
const storedPath = folder === '' ? storedName : `${folder}/${storedName}`;
|
|
127
|
+
await writeFile(join(this.#config.rawDir, storedPath), options.bytes);
|
|
128
|
+
const record = {
|
|
129
|
+
type: options.type,
|
|
130
|
+
raw: storedPath,
|
|
131
|
+
rawHash: sha256Hex(options.bytes),
|
|
132
|
+
parameters: plugin.descriptor.defaultParameters(plugin.pluginConfig(this.#config)),
|
|
133
|
+
manifests: []
|
|
134
|
+
};
|
|
135
|
+
library.assets[options.id] = record;
|
|
136
|
+
await this.write(library);
|
|
137
|
+
return library;
|
|
138
|
+
}
|
|
139
|
+
/** Replace an asset's parameters (validated by the owning plugin). */
|
|
140
|
+
async updateParameters(id, parameters) {
|
|
141
|
+
return this.#enqueue(() => this.#updateParameters(id, parameters));
|
|
142
|
+
}
|
|
143
|
+
async #updateParameters(id, parameters) {
|
|
144
|
+
const library = await this.read();
|
|
145
|
+
const record = this.#requireAsset(library, id);
|
|
146
|
+
record.parameters = serverPluginForType(record.type, this.#registry).descriptor.validateParameters(parameters);
|
|
147
|
+
await this.write(library);
|
|
148
|
+
return library;
|
|
149
|
+
}
|
|
150
|
+
/** Replace an asset's manifest membership (every name must exist). */
|
|
151
|
+
async setManifests(id, manifests) {
|
|
152
|
+
return this.#enqueue(() => this.#setManifests(id, manifests));
|
|
153
|
+
}
|
|
154
|
+
async #setManifests(id, manifests) {
|
|
155
|
+
const library = await this.read();
|
|
156
|
+
const record = this.#requireAsset(library, id);
|
|
157
|
+
const unique = [...new Set(manifests)];
|
|
158
|
+
for (const name of unique) {
|
|
159
|
+
if (!Object.hasOwn(library.manifests, name)) {
|
|
160
|
+
throw new Error(`Unknown manifest "${name}".`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
record.manifests = unique.sort();
|
|
164
|
+
await this.write(library);
|
|
165
|
+
return library;
|
|
166
|
+
}
|
|
167
|
+
/** Rename an asset id (the raw file keeps its name — ids are the stable key). */
|
|
168
|
+
async renameAsset(id, newId) {
|
|
169
|
+
return this.#enqueue(() => this.#renameAsset(id, newId));
|
|
170
|
+
}
|
|
171
|
+
async #renameAsset(id, newId) {
|
|
172
|
+
const idProblem = assetIdError(newId);
|
|
173
|
+
if (idProblem)
|
|
174
|
+
throw new Error(idProblem);
|
|
175
|
+
const library = await this.read();
|
|
176
|
+
const record = this.#requireAsset(library, id);
|
|
177
|
+
if (newId === id)
|
|
178
|
+
return library;
|
|
179
|
+
if (Object.hasOwn(library.assets, newId)) {
|
|
180
|
+
throw new Error(`An asset with id "${newId}" already exists.`);
|
|
181
|
+
}
|
|
182
|
+
delete library.assets[id];
|
|
183
|
+
library.assets[newId] = record;
|
|
184
|
+
await this.write(library);
|
|
185
|
+
// The old id's proxies are orphans now; delete eagerly (pruning also covers it).
|
|
186
|
+
await this.#deleteProxiesOf(id);
|
|
187
|
+
return library;
|
|
188
|
+
}
|
|
189
|
+
/** Move an asset's raw file and current proxies without changing its stable id. */
|
|
190
|
+
async moveAsset(id, folder) {
|
|
191
|
+
return this.#enqueue(() => this.#moveAsset(id, folder));
|
|
192
|
+
}
|
|
193
|
+
async #moveAsset(id, requestedFolder) {
|
|
194
|
+
const folder = normalizeFolderPath(requestedFolder);
|
|
195
|
+
const library = await this.read();
|
|
196
|
+
const record = this.#requireAsset(library, id);
|
|
197
|
+
this.#requireFolder(library, folder);
|
|
198
|
+
const currentFolder = folderOfRawPath(record.raw);
|
|
199
|
+
if (folder === currentFolder)
|
|
200
|
+
return library;
|
|
201
|
+
const fileName = rawFileNameOfPath(record.raw);
|
|
202
|
+
const nextRaw = folder === '' ? fileName : `${folder}/${fileName}`;
|
|
203
|
+
const nextRawPath = join(this.#config.rawDir, nextRaw);
|
|
204
|
+
if (existsSync(nextRawPath)) {
|
|
205
|
+
throw new Error(`Raw file "${nextRaw}" already exists.`);
|
|
206
|
+
}
|
|
207
|
+
await ensureManagedFolder(this.#config.rawDir, folder);
|
|
208
|
+
await ensureManagedFolder(this.#config.proxyDir, folder);
|
|
209
|
+
const movedProxies = [];
|
|
210
|
+
let rawMoved = false;
|
|
211
|
+
try {
|
|
212
|
+
await rename(join(this.#config.rawDir, record.raw), nextRawPath);
|
|
213
|
+
rawMoved = true;
|
|
214
|
+
for (const relativePath of await listRelativeFiles(this.#config.proxyDir, currentFolder)) {
|
|
215
|
+
if (folderOfRawPath(relativePath) !== currentFolder)
|
|
216
|
+
continue;
|
|
217
|
+
const proxyFileName = rawFileNameOfPath(relativePath);
|
|
218
|
+
if (!isProxyFileNameFor(id, proxyFileName))
|
|
219
|
+
continue;
|
|
220
|
+
const nextRelativePath = folder === '' ? proxyFileName : `${folder}/${proxyFileName}`;
|
|
221
|
+
await rename(join(this.#config.proxyDir, relativePath), join(this.#config.proxyDir, nextRelativePath));
|
|
222
|
+
movedProxies.push({ from: relativePath, to: nextRelativePath });
|
|
223
|
+
}
|
|
224
|
+
record.raw = nextRaw;
|
|
225
|
+
await this.write(library);
|
|
226
|
+
}
|
|
227
|
+
catch (cause) {
|
|
228
|
+
record.raw = currentFolder === '' ? fileName : `${currentFolder}/${fileName}`;
|
|
229
|
+
for (const proxy of movedProxies.reverse()) {
|
|
230
|
+
await rename(join(this.#config.proxyDir, proxy.to), join(this.#config.proxyDir, proxy.from));
|
|
231
|
+
}
|
|
232
|
+
if (rawMoved)
|
|
233
|
+
await rename(nextRawPath, join(this.#config.rawDir, record.raw));
|
|
234
|
+
throw cause;
|
|
235
|
+
}
|
|
236
|
+
return library;
|
|
237
|
+
}
|
|
238
|
+
async createFolder(path) {
|
|
239
|
+
return this.#enqueue(() => this.#createFolder(path));
|
|
240
|
+
}
|
|
241
|
+
async #createFolder(requestedPath) {
|
|
242
|
+
const path = normalizeFolderPath(requestedPath);
|
|
243
|
+
const problem = folderPathError(path);
|
|
244
|
+
if (problem)
|
|
245
|
+
throw new Error(problem);
|
|
246
|
+
const library = await this.read();
|
|
247
|
+
const parent = parentFolderPath(path);
|
|
248
|
+
this.#requireFolder(library, parent);
|
|
249
|
+
this.#assertFolderAvailable(library, path);
|
|
250
|
+
library.folders.push(path);
|
|
251
|
+
library.folders.sort();
|
|
252
|
+
await Promise.all([
|
|
253
|
+
ensureManagedFolder(this.#config.rawDir, path),
|
|
254
|
+
ensureManagedFolder(this.#config.proxyDir, path)
|
|
255
|
+
]);
|
|
256
|
+
await this.write(library);
|
|
257
|
+
return library;
|
|
258
|
+
}
|
|
259
|
+
async renameFolder(path, newName) {
|
|
260
|
+
return this.#enqueue(() => this.#renameFolder(path, newName));
|
|
261
|
+
}
|
|
262
|
+
async #renameFolder(requestedPath, requestedName) {
|
|
263
|
+
const path = normalizeFolderPath(requestedPath);
|
|
264
|
+
const newName = requestedName.normalize('NFC');
|
|
265
|
+
const nameProblem = folderNameError(newName);
|
|
266
|
+
if (nameProblem)
|
|
267
|
+
throw new Error(nameProblem);
|
|
268
|
+
const parent = parentFolderPath(path);
|
|
269
|
+
const nextPath = joinFolderPath(parent, newName);
|
|
270
|
+
return this.#relocateFolder(path, nextPath);
|
|
271
|
+
}
|
|
272
|
+
/** Move a complete folder subtree below another folder (or the implicit root). */
|
|
273
|
+
async moveFolder(path, parent) {
|
|
274
|
+
return this.#enqueue(() => this.#moveFolder(path, parent));
|
|
275
|
+
}
|
|
276
|
+
async #moveFolder(requestedPath, requestedParent) {
|
|
277
|
+
const path = normalizeFolderPath(requestedPath);
|
|
278
|
+
const parent = normalizeFolderPath(requestedParent);
|
|
279
|
+
const nextPath = joinFolderPath(parent, folderNameOf(path));
|
|
280
|
+
return this.#relocateFolder(path, nextPath);
|
|
281
|
+
}
|
|
282
|
+
async #relocateFolder(path, nextPath) {
|
|
283
|
+
const library = await this.read();
|
|
284
|
+
this.#requireFolder(library, path, false);
|
|
285
|
+
const nextParent = parentFolderPath(nextPath);
|
|
286
|
+
this.#requireFolder(library, nextParent);
|
|
287
|
+
if (isFolderOrDescendant(nextParent, path)) {
|
|
288
|
+
throw new Error(`Folder "${path}" cannot be moved into itself or one of its descendants.`);
|
|
289
|
+
}
|
|
290
|
+
if (nextPath === path)
|
|
291
|
+
return library;
|
|
292
|
+
this.#assertFolderAvailable(library, nextPath, path);
|
|
293
|
+
const renamedFolders = library.folders.map((folder) => isFolderOrDescendant(folder, path) ? replaceFolderPrefix(folder, path, nextPath) : folder);
|
|
294
|
+
const renamedKeys = new Set();
|
|
295
|
+
for (const folder of renamedFolders) {
|
|
296
|
+
const key = portableFolderPathKey(folder);
|
|
297
|
+
if (renamedKeys.has(key))
|
|
298
|
+
throw new Error(`Folder rename would collide at "${folder}".`);
|
|
299
|
+
renamedKeys.add(key);
|
|
300
|
+
}
|
|
301
|
+
const rawFrom = join(this.#config.rawDir, path);
|
|
302
|
+
const rawTo = join(this.#config.rawDir, nextPath);
|
|
303
|
+
const proxyFrom = join(this.#config.proxyDir, path);
|
|
304
|
+
const proxyTo = join(this.#config.proxyDir, nextPath);
|
|
305
|
+
await mkdir(join(rawTo, '..'), { recursive: true });
|
|
306
|
+
await mkdir(join(proxyTo, '..'), { recursive: true });
|
|
307
|
+
const caseOnly = portableFolderPathKey(path) === portableFolderPathKey(nextPath);
|
|
308
|
+
const rawTemporary = caseOnly ? `${rawFrom}.fundus-${crypto.randomUUID()}` : rawFrom;
|
|
309
|
+
const proxyTemporary = caseOnly ? `${proxyFrom}.fundus-${crypto.randomUUID()}` : proxyFrom;
|
|
310
|
+
if (caseOnly) {
|
|
311
|
+
await rename(rawFrom, rawTemporary);
|
|
312
|
+
try {
|
|
313
|
+
await rename(proxyFrom, proxyTemporary);
|
|
314
|
+
}
|
|
315
|
+
catch (cause) {
|
|
316
|
+
await rename(rawTemporary, rawFrom);
|
|
317
|
+
throw cause;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
await rename(rawTemporary, rawTo);
|
|
322
|
+
try {
|
|
323
|
+
await rename(proxyTemporary, proxyTo);
|
|
324
|
+
}
|
|
325
|
+
catch (cause) {
|
|
326
|
+
if (caseOnly) {
|
|
327
|
+
await rename(rawTo, rawTemporary);
|
|
328
|
+
await rename(rawTemporary, rawFrom);
|
|
329
|
+
await rename(proxyTemporary, proxyFrom);
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
await rename(rawTo, rawFrom);
|
|
333
|
+
}
|
|
334
|
+
throw cause;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
catch (cause) {
|
|
338
|
+
if (caseOnly) {
|
|
339
|
+
if (existsSync(rawTemporary))
|
|
340
|
+
await rename(rawTemporary, rawFrom);
|
|
341
|
+
if (existsSync(proxyTemporary))
|
|
342
|
+
await rename(proxyTemporary, proxyFrom);
|
|
343
|
+
}
|
|
344
|
+
throw cause;
|
|
345
|
+
}
|
|
346
|
+
library.folders = renamedFolders.sort();
|
|
347
|
+
for (const record of Object.values(library.assets)) {
|
|
348
|
+
const assetFolder = folderOfRawPath(record.raw);
|
|
349
|
+
if (!isFolderOrDescendant(assetFolder, path))
|
|
350
|
+
continue;
|
|
351
|
+
const movedFolder = replaceFolderPrefix(assetFolder, path, nextPath);
|
|
352
|
+
record.raw = `${movedFolder}/${rawFileNameOfPath(record.raw)}`;
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
await this.write(library);
|
|
356
|
+
}
|
|
357
|
+
catch (cause) {
|
|
358
|
+
if (caseOnly) {
|
|
359
|
+
await rename(rawTo, rawTemporary);
|
|
360
|
+
await rename(proxyTo, proxyTemporary);
|
|
361
|
+
await rename(rawTemporary, rawFrom);
|
|
362
|
+
await rename(proxyTemporary, proxyFrom);
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
await rename(rawTo, rawFrom);
|
|
366
|
+
await rename(proxyTo, proxyFrom);
|
|
367
|
+
}
|
|
368
|
+
throw cause;
|
|
369
|
+
}
|
|
370
|
+
return library;
|
|
371
|
+
}
|
|
372
|
+
/** Recursively delete a folder and every asset stored below it. */
|
|
373
|
+
async deleteFolder(path) {
|
|
374
|
+
return this.#enqueue(() => this.#deleteFolder(path));
|
|
375
|
+
}
|
|
376
|
+
async #deleteFolder(requestedPath) {
|
|
377
|
+
const path = normalizeFolderPath(requestedPath);
|
|
378
|
+
const library = await this.read();
|
|
379
|
+
this.#requireFolder(library, path, false);
|
|
380
|
+
const deletedAssetIds = Object.entries(library.assets)
|
|
381
|
+
.filter(([, record]) => isFolderOrDescendant(folderOfRawPath(record.raw), path))
|
|
382
|
+
.map(([id]) => id)
|
|
383
|
+
.sort();
|
|
384
|
+
for (const id of deletedAssetIds)
|
|
385
|
+
delete library.assets[id];
|
|
386
|
+
library.folders = library.folders.filter((folder) => !isFolderOrDescendant(folder, path));
|
|
387
|
+
const token = crypto.randomUUID();
|
|
388
|
+
const rawPath = join(this.#config.rawDir, path);
|
|
389
|
+
const proxyPath = join(this.#config.proxyDir, path);
|
|
390
|
+
const rawTemporary = join(this.#config.rawDir, `.fundus-delete-${token}`);
|
|
391
|
+
const proxyTemporary = join(this.#config.proxyDir, `.fundus-delete-${token}`);
|
|
392
|
+
await rename(rawPath, rawTemporary);
|
|
393
|
+
try {
|
|
394
|
+
await rename(proxyPath, proxyTemporary);
|
|
395
|
+
}
|
|
396
|
+
catch (cause) {
|
|
397
|
+
await rename(rawTemporary, rawPath);
|
|
398
|
+
throw cause;
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
await this.write(library);
|
|
402
|
+
}
|
|
403
|
+
catch (cause) {
|
|
404
|
+
await rename(rawTemporary, rawPath);
|
|
405
|
+
await rename(proxyTemporary, proxyPath);
|
|
406
|
+
throw cause;
|
|
407
|
+
}
|
|
408
|
+
await Promise.all([
|
|
409
|
+
rm(rawTemporary, { recursive: true, force: true }),
|
|
410
|
+
rm(proxyTemporary, { recursive: true, force: true })
|
|
411
|
+
]);
|
|
412
|
+
return { library, deletedAssetIds };
|
|
413
|
+
}
|
|
414
|
+
/** Delete an asset: remove the record, its raw file, and its proxies. */
|
|
415
|
+
async deleteAsset(id) {
|
|
416
|
+
return this.#enqueue(() => this.#deleteAsset(id));
|
|
417
|
+
}
|
|
418
|
+
async #deleteAsset(id) {
|
|
419
|
+
return this.#deleteAssets([id]);
|
|
420
|
+
}
|
|
421
|
+
/** Delete several assets as one library/file transaction. */
|
|
422
|
+
async deleteAssets(ids) {
|
|
423
|
+
return this.#enqueue(() => this.#deleteAssets(ids));
|
|
424
|
+
}
|
|
425
|
+
async #deleteAssets(ids) {
|
|
426
|
+
const uniqueIds = [...new Set(ids)];
|
|
427
|
+
if (uniqueIds.length === 0)
|
|
428
|
+
throw new Error('At least one asset id is required.');
|
|
429
|
+
const library = await this.read();
|
|
430
|
+
const records = uniqueIds.map((id) => ({ id, record: this.#requireAsset(library, id) }));
|
|
431
|
+
const deletedIds = new Set(uniqueIds);
|
|
432
|
+
const token = crypto.randomUUID();
|
|
433
|
+
const rawTemporaryRoot = join(this.#config.rawDir, `.fundus-delete-assets-${token}`);
|
|
434
|
+
const proxyTemporaryRoot = join(this.#config.proxyDir, `.fundus-delete-assets-${token}`);
|
|
435
|
+
const movedRawPaths = [];
|
|
436
|
+
const movedProxyPaths = [];
|
|
437
|
+
const stageFile = async (root, temporaryRoot, relativePath) => {
|
|
438
|
+
const source = join(root, relativePath);
|
|
439
|
+
if (!existsSync(source))
|
|
440
|
+
return;
|
|
441
|
+
const target = join(temporaryRoot, relativePath);
|
|
442
|
+
await mkdir(dirname(target), { recursive: true });
|
|
443
|
+
await rename(source, target);
|
|
444
|
+
};
|
|
445
|
+
const restoreFiles = async (root, temporaryRoot, relativePaths) => {
|
|
446
|
+
for (const relativePath of [...relativePaths].reverse()) {
|
|
447
|
+
const source = join(temporaryRoot, relativePath);
|
|
448
|
+
if (!existsSync(source))
|
|
449
|
+
continue;
|
|
450
|
+
const target = join(root, relativePath);
|
|
451
|
+
await mkdir(dirname(target), { recursive: true });
|
|
452
|
+
await rename(source, target);
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
try {
|
|
456
|
+
for (const { record } of records) {
|
|
457
|
+
await stageFile(this.#config.rawDir, rawTemporaryRoot, record.raw);
|
|
458
|
+
movedRawPaths.push(record.raw);
|
|
459
|
+
}
|
|
460
|
+
if (existsSync(this.#config.proxyDir)) {
|
|
461
|
+
for (const relativePath of await listRelativeFiles(this.#config.proxyDir)) {
|
|
462
|
+
if ([...deletedIds].some((id) => isProxyFileNameFor(id, rawFileNameOfPath(relativePath)))) {
|
|
463
|
+
await stageFile(this.#config.proxyDir, proxyTemporaryRoot, relativePath);
|
|
464
|
+
movedProxyPaths.push(relativePath);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
for (const id of uniqueIds)
|
|
469
|
+
delete library.assets[id];
|
|
470
|
+
await this.write(library);
|
|
471
|
+
}
|
|
472
|
+
catch (cause) {
|
|
473
|
+
await restoreFiles(this.#config.proxyDir, proxyTemporaryRoot, movedProxyPaths);
|
|
474
|
+
await restoreFiles(this.#config.rawDir, rawTemporaryRoot, movedRawPaths);
|
|
475
|
+
await Promise.all([
|
|
476
|
+
rm(rawTemporaryRoot, { recursive: true, force: true }),
|
|
477
|
+
rm(proxyTemporaryRoot, { recursive: true, force: true })
|
|
478
|
+
]);
|
|
479
|
+
throw cause;
|
|
480
|
+
}
|
|
481
|
+
await Promise.all([
|
|
482
|
+
rm(rawTemporaryRoot, { recursive: true, force: true }),
|
|
483
|
+
rm(proxyTemporaryRoot, { recursive: true, force: true })
|
|
484
|
+
]);
|
|
485
|
+
return library;
|
|
486
|
+
}
|
|
487
|
+
async createManifest(name) {
|
|
488
|
+
return this.#enqueue(() => this.#createManifest(name));
|
|
489
|
+
}
|
|
490
|
+
async #createManifest(name) {
|
|
491
|
+
const nameProblem = manifestNameError(name);
|
|
492
|
+
if (nameProblem) {
|
|
493
|
+
throw new Error(nameProblem);
|
|
494
|
+
}
|
|
495
|
+
const library = await this.read();
|
|
496
|
+
if (Object.hasOwn(library.manifests, name)) {
|
|
497
|
+
throw new Error(`A manifest named "${name}" already exists.`);
|
|
498
|
+
}
|
|
499
|
+
// Module file names are the kebab form of the manifest name, so two names
|
|
500
|
+
// sharing a kebab form (`mainCatalog` / `mainCATALOG` → `main-catalog`) would write
|
|
501
|
+
// the same generated module — reject the collision at the source.
|
|
502
|
+
const fileName = manifestModuleFileName(name);
|
|
503
|
+
for (const existingName of Object.keys(library.manifests)) {
|
|
504
|
+
if (manifestModuleFileName(existingName) === fileName) {
|
|
505
|
+
throw new Error(`Manifest "${name}" would generate the same module ("${fileName}") as the existing ` +
|
|
506
|
+
`manifest "${existingName}" — pick a name with a distinct kebab-case form.`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
library.manifests[name] = {};
|
|
510
|
+
await this.write(library);
|
|
511
|
+
return library;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Set (or clear) a manifest's editor appearance — its color and/or emoji.
|
|
515
|
+
* An `undefined` field is left unchanged; a `null` field clears it. Values
|
|
516
|
+
* are validated against the fixed palettes.
|
|
517
|
+
*/
|
|
518
|
+
async setManifestAppearance(name, appearance) {
|
|
519
|
+
return this.#enqueue(() => this.#setManifestAppearance(name, appearance));
|
|
520
|
+
}
|
|
521
|
+
async #setManifestAppearance(name, appearance) {
|
|
522
|
+
const library = await this.read();
|
|
523
|
+
if (!Object.hasOwn(library.manifests, name)) {
|
|
524
|
+
throw new Error(`Unknown manifest "${name}".`);
|
|
525
|
+
}
|
|
526
|
+
const record = library.manifests[name];
|
|
527
|
+
if (appearance.color !== undefined) {
|
|
528
|
+
if (appearance.color === null) {
|
|
529
|
+
delete record.color;
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
const problem = manifestColorError(appearance.color);
|
|
533
|
+
if (problem)
|
|
534
|
+
throw new Error(problem);
|
|
535
|
+
record.color = appearance.color;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (appearance.emoji !== undefined) {
|
|
539
|
+
if (appearance.emoji === null) {
|
|
540
|
+
delete record.emoji;
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
const problem = manifestEmojiError(appearance.emoji);
|
|
544
|
+
if (problem)
|
|
545
|
+
throw new Error(problem);
|
|
546
|
+
record.emoji = appearance.emoji;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
await this.write(library);
|
|
550
|
+
return library;
|
|
551
|
+
}
|
|
552
|
+
/** Rename a manifest and rewrite every asset's membership to the new name. */
|
|
553
|
+
async renameManifest(name, newName) {
|
|
554
|
+
return this.#enqueue(() => this.#renameManifest(name, newName));
|
|
555
|
+
}
|
|
556
|
+
async #renameManifest(name, newName) {
|
|
557
|
+
const nameProblem = manifestNameError(newName);
|
|
558
|
+
if (nameProblem) {
|
|
559
|
+
throw new Error(nameProblem);
|
|
560
|
+
}
|
|
561
|
+
const library = await this.read();
|
|
562
|
+
if (!Object.hasOwn(library.manifests, name)) {
|
|
563
|
+
throw new Error(`Unknown manifest "${name}".`);
|
|
564
|
+
}
|
|
565
|
+
if (newName === name)
|
|
566
|
+
return library;
|
|
567
|
+
if (Object.hasOwn(library.manifests, newName)) {
|
|
568
|
+
throw new Error(`A manifest named "${newName}" already exists.`);
|
|
569
|
+
}
|
|
570
|
+
// Same kebab-collision guard as creation — the renamed manifest itself is
|
|
571
|
+
// exempt (a pure case change keeps the module file name and is fine).
|
|
572
|
+
const fileName = manifestModuleFileName(newName);
|
|
573
|
+
for (const existingName of Object.keys(library.manifests)) {
|
|
574
|
+
if (existingName !== name && manifestModuleFileName(existingName) === fileName) {
|
|
575
|
+
throw new Error(`Manifest "${newName}" would generate the same module ("${fileName}") as the existing ` +
|
|
576
|
+
`manifest "${existingName}" — pick a name with a distinct kebab-case form.`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const record = library.manifests[name];
|
|
580
|
+
delete library.manifests[name];
|
|
581
|
+
library.manifests[newName] = record;
|
|
582
|
+
for (const assetRecord of Object.values(library.assets)) {
|
|
583
|
+
if (assetRecord.manifests.includes(name)) {
|
|
584
|
+
assetRecord.manifests = assetRecord.manifests
|
|
585
|
+
.map((manifest) => (manifest === name ? newName : manifest))
|
|
586
|
+
.sort();
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
await this.write(library);
|
|
590
|
+
return library;
|
|
591
|
+
}
|
|
592
|
+
/** Delete a manifest and strip it from every asset's membership. */
|
|
593
|
+
async deleteManifest(name) {
|
|
594
|
+
return this.#enqueue(() => this.#deleteManifest(name));
|
|
595
|
+
}
|
|
596
|
+
async #deleteManifest(name) {
|
|
597
|
+
const library = await this.read();
|
|
598
|
+
if (!Object.hasOwn(library.manifests, name)) {
|
|
599
|
+
throw new Error(`Unknown manifest "${name}".`);
|
|
600
|
+
}
|
|
601
|
+
delete library.manifests[name];
|
|
602
|
+
for (const record of Object.values(library.assets)) {
|
|
603
|
+
record.manifests = record.manifests.filter((manifest) => manifest !== name);
|
|
604
|
+
}
|
|
605
|
+
await this.write(library);
|
|
606
|
+
return library;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Structural + referential validation, listed per asset: invalid id, unknown
|
|
610
|
+
* type, dangling raw pointer, invalid parameters, unknown manifest names,
|
|
611
|
+
* and reference errors (dangling target, type mismatch, cycles).
|
|
612
|
+
*/
|
|
613
|
+
async validate(library) {
|
|
614
|
+
const issues = [];
|
|
615
|
+
const folderKeys = new Map();
|
|
616
|
+
for (const folder of library.folders) {
|
|
617
|
+
const problem = folderPathError(folder);
|
|
618
|
+
if (problem)
|
|
619
|
+
issues.push({ message: problem });
|
|
620
|
+
if (folder !== normalizeFolderPath(folder)) {
|
|
621
|
+
issues.push({ message: `Folder path "${folder}" is not Unicode-normalized.` });
|
|
622
|
+
}
|
|
623
|
+
const key = portableFolderPathKey(folder);
|
|
624
|
+
const collision = folderKeys.get(key);
|
|
625
|
+
if (collision !== undefined) {
|
|
626
|
+
issues.push({ message: `Folders "${collision}" and "${folder}" collide on disk.` });
|
|
627
|
+
}
|
|
628
|
+
else {
|
|
629
|
+
folderKeys.set(key, folder);
|
|
630
|
+
}
|
|
631
|
+
const parent = parentFolderPath(folder);
|
|
632
|
+
if (parent !== '' && !library.folders.includes(parent)) {
|
|
633
|
+
issues.push({ message: `Folder "${folder}" has missing parent "${parent}".` });
|
|
634
|
+
}
|
|
635
|
+
for (const root of [this.#config.rawDir, this.#config.proxyDir]) {
|
|
636
|
+
if (!existsSync(join(root, folder, '.gitkeep'))) {
|
|
637
|
+
issues.push({
|
|
638
|
+
message: `Folder "${folder}" is not mirrored with a .gitkeep in ${root}.`
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
for (const [name, record] of Object.entries(library.manifests)) {
|
|
644
|
+
const nameProblem = manifestNameError(name);
|
|
645
|
+
if (nameProblem)
|
|
646
|
+
issues.push({ message: nameProblem });
|
|
647
|
+
if (record.color !== undefined) {
|
|
648
|
+
const colorProblem = manifestColorError(record.color);
|
|
649
|
+
if (colorProblem)
|
|
650
|
+
issues.push({ message: `Manifest "${name}": ${colorProblem}` });
|
|
651
|
+
}
|
|
652
|
+
if (record.emoji !== undefined) {
|
|
653
|
+
const emojiProblem = manifestEmojiError(record.emoji);
|
|
654
|
+
if (emojiProblem)
|
|
655
|
+
issues.push({ message: `Manifest "${name}": ${emojiProblem}` });
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
const namesByModuleFile = new Map();
|
|
659
|
+
for (const name of Object.keys(library.manifests)) {
|
|
660
|
+
const fileName = manifestModuleFileName(name);
|
|
661
|
+
const existingName = namesByModuleFile.get(fileName);
|
|
662
|
+
if (existingName !== undefined) {
|
|
663
|
+
issues.push({
|
|
664
|
+
message: `Manifests "${existingName}" and "${name}" would both generate "${fileName}".`
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
else {
|
|
668
|
+
namesByModuleFile.set(fileName, name);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
for (const [assetId, record] of Object.entries(library.assets)) {
|
|
672
|
+
const idProblem = assetIdError(assetId);
|
|
673
|
+
if (idProblem)
|
|
674
|
+
issues.push({ assetId, message: idProblem });
|
|
675
|
+
const plugin = this.#registry[record.type];
|
|
676
|
+
if (!plugin) {
|
|
677
|
+
issues.push({ assetId, message: `Unknown asset type "${record.type}".` });
|
|
678
|
+
}
|
|
679
|
+
else {
|
|
680
|
+
try {
|
|
681
|
+
plugin.descriptor.validateParameters(record.parameters);
|
|
682
|
+
}
|
|
683
|
+
catch (cause) {
|
|
684
|
+
issues.push({
|
|
685
|
+
assetId,
|
|
686
|
+
message: `Invalid parameters: ${cause instanceof Error ? cause.message : String(cause)}`
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
try {
|
|
691
|
+
assertSafeRelativeFilePath(record.raw);
|
|
692
|
+
}
|
|
693
|
+
catch (cause) {
|
|
694
|
+
issues.push({
|
|
695
|
+
assetId,
|
|
696
|
+
message: cause instanceof Error ? cause.message : String(cause)
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
const assetFolder = folderOfRawPath(record.raw);
|
|
700
|
+
if (assetFolder !== '' && !library.folders.includes(assetFolder)) {
|
|
701
|
+
issues.push({ assetId, message: `Unknown asset folder "${assetFolder}".` });
|
|
702
|
+
}
|
|
703
|
+
if (!existsSync(join(this.#config.rawDir, record.raw))) {
|
|
704
|
+
issues.push({
|
|
705
|
+
assetId,
|
|
706
|
+
message: `Raw file "${record.raw}" is missing from the raw directory.`
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
for (const manifest of record.manifests) {
|
|
710
|
+
if (!library.manifests[manifest]) {
|
|
711
|
+
issues.push({ assetId, message: `Unknown manifest "${manifest}".` });
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
issues.push(...referenceErrors(library, collectAllReferences(library, descriptorsByType(this.#registry))));
|
|
716
|
+
return issues;
|
|
717
|
+
}
|
|
718
|
+
async #deleteProxiesOf(assetId) {
|
|
719
|
+
if (!existsSync(this.#config.proxyDir))
|
|
720
|
+
return;
|
|
721
|
+
for (const relativePath of await listRelativeFiles(this.#config.proxyDir)) {
|
|
722
|
+
if (isProxyFileNameFor(assetId, rawFileNameOfPath(relativePath))) {
|
|
723
|
+
await unlink(join(this.#config.proxyDir, relativePath));
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
async syncFolders(library) {
|
|
728
|
+
await syncManagedFolders(this.#config, library);
|
|
729
|
+
}
|
|
730
|
+
#requireAsset(library, id) {
|
|
731
|
+
// Own-property check: an inherited key ("constructor", …) must not
|
|
732
|
+
// resolve to an Object.prototype member and pass as a record.
|
|
733
|
+
if (!Object.hasOwn(library.assets, id)) {
|
|
734
|
+
throw new Error(`Unknown asset "${id}".`);
|
|
735
|
+
}
|
|
736
|
+
return library.assets[id];
|
|
737
|
+
}
|
|
738
|
+
/** Resolve a non-colliding raw file name, appending `-1`, `-2`, … before the extension. */
|
|
739
|
+
#uniqueRawName(fileName, folder) {
|
|
740
|
+
if (!existsSync(join(this.#config.rawDir, folder, fileName)))
|
|
741
|
+
return fileName;
|
|
742
|
+
const extension = extname(fileName);
|
|
743
|
+
const stem = fileName.slice(0, fileName.length - extension.length);
|
|
744
|
+
for (let index = 1;; index++) {
|
|
745
|
+
const candidate = `${stem}-${index}${extension}`;
|
|
746
|
+
if (!existsSync(join(this.#config.rawDir, folder, candidate)))
|
|
747
|
+
return candidate;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
#requireFolder(library, folder, allowRoot = true) {
|
|
751
|
+
if (folder === '' && allowRoot)
|
|
752
|
+
return;
|
|
753
|
+
if (!library.folders.includes(folder))
|
|
754
|
+
throw new Error(`Unknown folder "${folder}".`);
|
|
755
|
+
}
|
|
756
|
+
#assertFolderAvailable(library, path, except) {
|
|
757
|
+
const key = portableFolderPathKey(path);
|
|
758
|
+
const collision = library.folders.find((folder) => folder !== except && portableFolderPathKey(folder) === key);
|
|
759
|
+
if (collision !== undefined) {
|
|
760
|
+
throw new Error(`Folder "${path}" collides with existing folder "${collision}".`);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|