@michaelthielemann/kestrel 1.2.1
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/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +234 -0
- package/kestrel.config.ts +28 -0
- package/layers/access/nuxt.config.ts +5 -0
- package/layers/access/server/middleware/00.ip-allowlist.ts +67 -0
- package/layers/access/server/middleware/access-guard.ts +30 -0
- package/layers/access/server/types.d.ts +8 -0
- package/layers/access/server/utils/csrf.ts +39 -0
- package/layers/access/server/utils/grant-registry.ts +36 -0
- package/layers/access/server/utils/guard.ts +44 -0
- package/layers/access/server/utils/ip-allowlist.ts +76 -0
- package/layers/access/server/utils/policy.ts +98 -0
- package/layers/access/server/utils/public-resources.ts +11 -0
- package/layers/access/server/utils/render-context.ts +41 -0
- package/layers/access/server/utils/require-admin.ts +15 -0
- package/layers/admin/app/components/AdminAccount.vue +212 -0
- package/layers/admin/app/components/AdminNav.vue +44 -0
- package/layers/admin/app/components/BlockFields.vue +68 -0
- package/layers/admin/app/components/BlockPreview.vue +401 -0
- package/layers/admin/app/components/BlockTree.vue +433 -0
- package/layers/admin/app/components/BlocksBody.vue +162 -0
- package/layers/admin/app/components/CollectionDeleteDialog.vue +38 -0
- package/layers/admin/app/components/CollectionEditor.vue +203 -0
- package/layers/admin/app/components/CollectionList.vue +1091 -0
- package/layers/admin/app/components/EditorStatus.vue +199 -0
- package/layers/admin/app/components/EditorUnsupported.vue +28 -0
- package/layers/admin/app/components/FieldsBody.vue +23 -0
- package/layers/admin/app/components/LocaleBar.vue +105 -0
- package/layers/admin/app/components/PageFields.vue +101 -0
- package/layers/admin/app/components/PageFieldsPane.vue +39 -0
- package/layers/admin/app/components/SeoFields.vue +169 -0
- package/layers/admin/app/components/SingletonEditor.vue +92 -0
- package/layers/admin/app/composables/useAuth.ts +68 -0
- package/layers/admin/app/composables/useBlockTree.ts +95 -0
- package/layers/admin/app/composables/useBlocks.ts +7 -0
- package/layers/admin/app/composables/useCachedResource.ts +35 -0
- package/layers/admin/app/composables/useCollectionOps.ts +53 -0
- package/layers/admin/app/composables/useCollections.ts +7 -0
- package/layers/admin/app/composables/useContentLocales.ts +23 -0
- package/layers/admin/app/composables/useEditForm.ts +370 -0
- package/layers/admin/app/composables/useLinkResolver.ts +40 -0
- package/layers/admin/app/composables/useListColumns.ts +53 -0
- package/layers/admin/app/composables/useListUrlState.ts +53 -0
- package/layers/admin/app/composables/usePendingPublishPoll.ts +15 -0
- package/layers/admin/app/composables/usePreviewViewport.ts +30 -0
- package/layers/admin/app/composables/usePublishStatus.ts +98 -0
- package/layers/admin/app/composables/useRailCollapsed.ts +14 -0
- package/layers/admin/app/composables/useTheme.ts +36 -0
- package/layers/admin/app/composables/useUnsavedGuard.ts +22 -0
- package/layers/admin/app/layouts/admin.vue +267 -0
- package/layers/admin/app/middleware/admin-auth.ts +4 -0
- package/layers/admin/app/pages/admin/[collection]/[id].vue +202 -0
- package/layers/admin/app/pages/admin/[collection]/index.vue +62 -0
- package/layers/admin/app/pages/admin/index.vue +118 -0
- package/layers/admin/app/pages/admin/login.vue +68 -0
- package/layers/admin/app/pages/admin/media.vue +28 -0
- package/layers/admin/app/pages/admin/references.vue +146 -0
- package/layers/admin/app/plugins/admin-style-guard.client.ts +76 -0
- package/layers/admin/app/plugins/reauth.client.ts +20 -0
- package/layers/admin/app/utils/admin-style-guard.ts +20 -0
- package/layers/admin/app/utils/block-tree.ts +214 -0
- package/layers/admin/app/utils/collection-ops.ts +44 -0
- package/layers/admin/app/utils/dead-refs.ts +30 -0
- package/layers/admin/app/utils/edit-form.ts +223 -0
- package/layers/admin/app/utils/editor-expose.ts +26 -0
- package/layers/admin/app/utils/editor-form-context.ts +83 -0
- package/layers/admin/app/utils/editor-registry.ts +20 -0
- package/layers/admin/app/utils/field-keys.ts +11 -0
- package/layers/admin/app/utils/list-columns.ts +141 -0
- package/layers/admin/app/utils/list-query.ts +96 -0
- package/layers/admin/app/utils/nav-active.ts +9 -0
- package/layers/admin/app/utils/populate-blocks.ts +288 -0
- package/layers/admin/app/utils/preview-channel.ts +62 -0
- package/layers/admin/app/utils/preview-viewport.ts +80 -0
- package/layers/admin/app/utils/reauth.ts +68 -0
- package/layers/admin/app/utils/record-title.ts +15 -0
- package/layers/admin/app/utils/register-builtin-editors.ts +9 -0
- package/layers/admin/app/utils/safe-redirect.ts +12 -0
- package/layers/admin/nuxt.config.ts +17 -0
- package/layers/auth/nuxt.config.ts +1 -0
- package/layers/auth/server/api/auth/login.post.ts +38 -0
- package/layers/auth/server/api/auth/logout.post.ts +9 -0
- package/layers/auth/server/api/auth/session.get.ts +1 -0
- package/layers/auth/server/utils/admin-credential.ts +42 -0
- package/layers/auth/server/utils/client-ip.ts +88 -0
- package/layers/auth/server/utils/login-throttle.ts +126 -0
- package/layers/auth/server/utils/password.ts +52 -0
- package/layers/auth/server/utils/session-cookie.ts +44 -0
- package/layers/auth/server/utils/session-epoch.ts +96 -0
- package/layers/auth/server/utils/session.ts +85 -0
- package/layers/collections/nuxt.config.ts +1 -0
- package/layers/collections/server/collections/pages.ts +19 -0
- package/layers/collections/server/plugins/01.register.ts +32 -0
- package/layers/collections/server/plugins/02.register-relation-populate.ts +17 -0
- package/layers/collections/server/utils/populate-relations.ts +66 -0
- package/layers/core/app/utils/bulk-actions.ts +9 -0
- package/layers/core/app/utils/filter-ops.ts +98 -0
- package/layers/core/app/utils/list-limits.ts +27 -0
- package/layers/core/app/utils/locale-path.ts +12 -0
- package/layers/core/app/utils/slugify.ts +16 -0
- package/layers/core/modules/auto-discovery/extract-block.ts +176 -0
- package/layers/core/modules/auto-discovery/index.ts +44 -0
- package/layers/core/modules/auto-discovery/scan.ts +78 -0
- package/layers/core/modules/auto-discovery/virtual.d.ts +12 -0
- package/layers/core/modules/kestrel/index.ts +86 -0
- package/layers/core/nuxt.config.ts +8 -0
- package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +8 -0
- package/layers/core/server/api/[collection]/[id]/translations.get.ts +4 -0
- package/layers/core/server/api/[collection]/[id].delete.ts +5 -0
- package/layers/core/server/api/[collection]/[id].get.ts +8 -0
- package/layers/core/server/api/[collection]/[id].patch.ts +8 -0
- package/layers/core/server/api/[collection]/bulk.post.ts +37 -0
- package/layers/core/server/api/[collection]/index.get.ts +19 -0
- package/layers/core/server/api/[collection]/index.post.ts +8 -0
- package/layers/core/server/api/[collection]/index.put.ts +7 -0
- package/layers/core/server/api/[collection]/options.get.ts +18 -0
- package/layers/core/server/api/[collection]/translations.get.ts +25 -0
- package/layers/core/server/api/collections/[name].get.ts +4 -0
- package/layers/core/server/api/collections/index.get.ts +3 -0
- package/layers/core/server/api/references/broken.get.ts +3 -0
- package/layers/core/server/api/references/referrers.get.ts +24 -0
- package/layers/core/server/database/record-refs.ts +20 -0
- package/layers/core/server/plugins/00.migrate.ts +11 -0
- package/layers/core/server/plugins/02.schema-sync.ts +63 -0
- package/layers/core/server/plugins/03.record-refs.ts +12 -0
- package/layers/core/server/schema/bootstrap.ts +41 -0
- package/layers/core/server/schema/desired.ts +99 -0
- package/layers/core/server/schema/dev-sync.ts +23 -0
- package/layers/core/server/schema/dialect.ts +59 -0
- package/layers/core/server/schema/diff.ts +110 -0
- package/layers/core/server/schema/introspect.ts +67 -0
- package/layers/core/server/schema/model.ts +61 -0
- package/layers/core/server/schema/render-sqlite.ts +89 -0
- package/layers/core/server/schema/sync.ts +201 -0
- package/layers/core/server/tasks/db/migrate.ts +54 -0
- package/layers/core/server/utils/blocks.ts +6 -0
- package/layers/core/server/utils/collection-types.ts +27 -0
- package/layers/core/server/utils/crud.ts +577 -0
- package/layers/core/server/utils/db.ts +26 -0
- package/layers/core/server/utils/defineCollection.ts +207 -0
- package/layers/core/server/utils/duplicate.ts +162 -0
- package/layers/core/server/utils/field-layout.ts +125 -0
- package/layers/core/server/utils/filter-predicate.ts +101 -0
- package/layers/core/server/utils/http.ts +50 -0
- package/layers/core/server/utils/if-unmodified.ts +13 -0
- package/layers/core/server/utils/kestrel-config.ts +410 -0
- package/layers/core/server/utils/key-lock.ts +33 -0
- package/layers/core/server/utils/locale.ts +53 -0
- package/layers/core/server/utils/page-route.ts +58 -0
- package/layers/core/server/utils/page-slug.ts +127 -0
- package/layers/core/server/utils/picker.ts +82 -0
- package/layers/core/server/utils/populate.ts +48 -0
- package/layers/core/server/utils/prerender-memo.ts +23 -0
- package/layers/core/server/utils/read-capture.ts +37 -0
- package/layers/core/server/utils/record-ref-index.ts +200 -0
- package/layers/core/server/utils/registry.ts +24 -0
- package/layers/core/server/utils/resolve-scope.ts +79 -0
- package/layers/core/server/utils/seo.ts +24 -0
- package/layers/core/server/utils/serialize-collection.ts +149 -0
- package/layers/core/server/utils/server-config.ts +18 -0
- package/layers/core/server/utils/sql.ts +2 -0
- package/layers/core/server/utils/storage.local.ts +117 -0
- package/layers/core/server/utils/storage.s3.ts +197 -0
- package/layers/core/server/utils/storage.ts +90 -0
- package/layers/core/server/utils/write-events.ts +31 -0
- package/layers/fields/app/utils/condition.ts +71 -0
- package/layers/fields/app/utils/define-block.ts +13 -0
- package/layers/fields/app/utils/field-constraints.ts +17 -0
- package/layers/fields/app/utils/field-factories.ts +129 -0
- package/layers/fields/app/utils/field-meta.ts +33 -0
- package/layers/fields/app/utils/field-validate.ts +75 -0
- package/layers/fields/app/utils/field-value.ts +7 -0
- package/layers/fields/app/utils/richtext-links.ts +48 -0
- package/layers/fields/nuxt.config.ts +1 -0
- package/layers/fields/server/api/blocks/index.get.ts +9 -0
- package/layers/fields/server/field-registry/index.ts +248 -0
- package/layers/fields/server/field-registry/naming.ts +10 -0
- package/layers/fields/server/field-registry/sanitize.ts +40 -0
- package/layers/fields/server/field-registry/types.ts +19 -0
- package/layers/fields/server/plugins/01.register-field-populate.ts +11 -0
- package/layers/fields/server/utils/block-ids.ts +43 -0
- package/layers/fields/server/utils/block-populate.ts +41 -0
- package/layers/fields/server/utils/buildCollection.ts +88 -0
- package/layers/fields/server/utils/buildFieldSchema.ts +22 -0
- package/layers/fields/server/utils/buildTable.ts +107 -0
- package/layers/fields/server/utils/conditional-required.ts +25 -0
- package/layers/fields/server/utils/defineBlock.ts +86 -0
- package/layers/fields/server/utils/defineFieldType.ts +33 -0
- package/layers/fields/server/utils/extract-refs.ts +152 -0
- package/layers/fields/server/utils/field-populate.ts +68 -0
- package/layers/media/app/components/KestrelImg.vue +65 -0
- package/layers/media/app/components/MediaConflictDialog.vue +35 -0
- package/layers/media/app/components/MediaDeleteDialog.vue +52 -0
- package/layers/media/app/components/MediaGrid.vue +113 -0
- package/layers/media/app/components/MediaImage.vue +70 -0
- package/layers/media/app/components/MediaLibrary.vue +451 -0
- package/layers/media/app/components/MediaNewFolderDialog.vue +32 -0
- package/layers/media/app/components/MediaPathBar.vue +163 -0
- package/layers/media/app/components/MediaPicker.vue +19 -0
- package/layers/media/app/components/MediaRenameDialog.vue +32 -0
- package/layers/media/app/components/MediaTable.vue +75 -0
- package/layers/media/app/components/MediaThumb.vue +25 -0
- package/layers/media/app/components/MediaToolbar.vue +90 -0
- package/layers/media/app/components/MediaUploadDialog.vue +87 -0
- package/layers/media/app/components/MediaViewer.vue +88 -0
- package/layers/media/app/components/field/Media.vue +261 -0
- package/layers/media/app/composables/useBlurUp.ts +55 -0
- package/layers/media/app/composables/useMediaClipboard.ts +14 -0
- package/layers/media/app/composables/useMediaContextMenu.ts +56 -0
- package/layers/media/app/composables/useMediaDnd.ts +56 -0
- package/layers/media/app/composables/useMediaLibrary.ts +151 -0
- package/layers/media/app/composables/useMediaOps.ts +50 -0
- package/layers/media/app/composables/useMediaRelocate.ts +39 -0
- package/layers/media/app/composables/useMediaResolver.ts +50 -0
- package/layers/media/app/composables/useMediaUpload.ts +93 -0
- package/layers/media/app/composables/useMediaVariant.ts +34 -0
- package/layers/media/app/plugins/field-media.client.ts +6 -0
- package/layers/media/app/utils/dnd.ts +69 -0
- package/layers/media/app/utils/library.ts +79 -0
- package/layers/media/app/utils/ops.ts +62 -0
- package/layers/media/app/utils/picture-model.ts +125 -0
- package/layers/media/app/utils/thumbhash.ts +19 -0
- package/layers/media/app/utils/variant-name.ts +20 -0
- package/layers/media/nuxt.config.ts +3 -0
- package/layers/media/server/api/media/[id]/usages.get.ts +8 -0
- package/layers/media/server/api/media/[id].delete.ts +18 -0
- package/layers/media/server/api/media/[id].get.ts +10 -0
- package/layers/media/server/api/media/[id].patch.ts +41 -0
- package/layers/media/server/api/media/backfill.post.ts +18 -0
- package/layers/media/server/api/media/copy.post.ts +7 -0
- package/layers/media/server/api/media/delete.post.ts +13 -0
- package/layers/media/server/api/media/folders.post.ts +18 -0
- package/layers/media/server/api/media/index.get.ts +15 -0
- package/layers/media/server/api/media/index.post.ts +157 -0
- package/layers/media/server/api/media/library.get.ts +17 -0
- package/layers/media/server/api/media/move.post.ts +7 -0
- package/layers/media/server/api/media/rename.post.ts +7 -0
- package/layers/media/server/api/media/resolve.get.ts +13 -0
- package/layers/media/server/collections/media-settings.ts +25 -0
- package/layers/media/server/collections/media.ts +29 -0
- package/layers/media/server/database/folders.ts +10 -0
- package/layers/media/server/middleware/ondemand-variants.ts +37 -0
- package/layers/media/server/plugins/02.register-media.ts +19 -0
- package/layers/media/server/plugins/03.media-cleanup.ts +20 -0
- package/layers/media/server/plugins/04.variant-capture.ts +23 -0
- package/layers/media/server/schema-tables/folders.ts +4 -0
- package/layers/media/server/tasks/media/backfill.ts +18 -0
- package/layers/media/server/utils/backfill.ts +132 -0
- package/layers/media/server/utils/derive.ts +59 -0
- package/layers/media/server/utils/folder-paths.ts +42 -0
- package/layers/media/server/utils/folders.ts +12 -0
- package/layers/media/server/utils/library.ts +90 -0
- package/layers/media/server/utils/media-cleanup.ts +27 -0
- package/layers/media/server/utils/media-enabled.ts +23 -0
- package/layers/media/server/utils/media-ops.ts +118 -0
- package/layers/media/server/utils/media-write.ts +13 -0
- package/layers/media/server/utils/naming.ts +52 -0
- package/layers/media/server/utils/ondemand.ts +69 -0
- package/layers/media/server/utils/persist-upload.ts +62 -0
- package/layers/media/server/utils/populate.ts +47 -0
- package/layers/media/server/utils/record.ts +63 -0
- package/layers/media/server/utils/relocate-ops.ts +341 -0
- package/layers/media/server/utils/relocate-plan.ts +63 -0
- package/layers/media/server/utils/resolve.ts +86 -0
- package/layers/media/server/utils/sanitize-svg.ts +12 -0
- package/layers/media/server/utils/sniff.ts +68 -0
- package/layers/media/server/utils/storage-relocate.ts +139 -0
- package/layers/media/server/utils/translations.ts +19 -0
- package/layers/media/server/utils/usages.ts +66 -0
- package/layers/media/server/utils/variant-capture.ts +70 -0
- package/layers/media/server/utils/variants.ts +108 -0
- package/layers/public/app/app.vue +5 -0
- package/layers/public/app/components/BlockRenderer.vue +56 -0
- package/layers/public/app/components/KestrelLink.vue +26 -0
- package/layers/public/app/components/KestrelPreviewBridge.vue +133 -0
- package/layers/public/app/composables/public-page.ts +24 -0
- package/layers/public/app/error.vue +12 -0
- package/layers/public/app/layouts/default.vue +5 -0
- package/layers/public/app/pages/[...slug].vue +157 -0
- package/layers/public/app/pages/__kestrel/preview.vue +34 -0
- package/layers/public/app/utils/block-edit-context.ts +16 -0
- package/layers/public/app/utils/link-href.ts +46 -0
- package/layers/public/app/utils/page-head.ts +84 -0
- package/layers/public/app/utils/preview-protocol.ts +81 -0
- package/layers/public/app/utils/public-route.ts +46 -0
- package/layers/public/modules/deploy-output/deploy-output.ts +370 -0
- package/layers/public/modules/deploy-output/index.ts +82 -0
- package/layers/public/modules/prerender-routes/discover.ts +37 -0
- package/layers/public/modules/prerender-routes/index.ts +85 -0
- package/layers/public/modules/prerender-routes/routes.ts +16 -0
- package/layers/public/modules/prune-media/index.ts +48 -0
- package/layers/public/modules/prune-media/prune-media.ts +81 -0
- package/layers/public/nuxt.config.ts +11 -0
- package/layers/public/server/api/links/resolve.get.ts +19 -0
- package/layers/public/server/api/publish-status.get.ts +44 -0
- package/layers/public/server/api/route.get.ts +18 -0
- package/layers/public/server/database/publish-deps.ts +12 -0
- package/layers/public/server/database/publish-status.ts +19 -0
- package/layers/public/server/plugins/02.register-links.ts +17 -0
- package/layers/public/server/plugins/zz.publish.ts +63 -0
- package/layers/public/server/routes/llms.txt.get.ts +72 -0
- package/layers/public/server/routes/robots.txt.get.ts +8 -0
- package/layers/public/server/routes/sitemap.xml.get.ts +66 -0
- package/layers/public/server/schema-tables/publish-deps.ts +4 -0
- package/layers/public/server/schema-tables/publish-status.ts +4 -0
- package/layers/public/server/tasks/publish/run.ts +26 -0
- package/layers/public/server/utils/link-resolve.ts +35 -0
- package/layers/public/server/utils/llms.ts +39 -0
- package/layers/public/server/utils/page-resolve.ts +86 -0
- package/layers/public/server/utils/populate-links.ts +66 -0
- package/layers/public/server/utils/publish/coalesce.ts +22 -0
- package/layers/public/server/utils/publish/deps-persistence.ts +43 -0
- package/layers/public/server/utils/publish/deps.ts +87 -0
- package/layers/public/server/utils/publish/invalidation.ts +141 -0
- package/layers/public/server/utils/publish/publish-status.ts +54 -0
- package/layers/public/server/utils/publish/publisher.ts +291 -0
- package/layers/public/server/utils/publish/queue.ts +67 -0
- package/layers/public/server/utils/publish/route-for-record.ts +16 -0
- package/layers/public/server/utils/publish/route-keys.ts +13 -0
- package/layers/public/server/utils/site-url.ts +32 -0
- package/layers/public/server/utils/sitemap.ts +97 -0
- package/layers/ui/app/assets/scss/_base.scss +16 -0
- package/layers/ui/app/assets/scss/_datepicker.scss +82 -0
- package/layers/ui/app/assets/scss/_mixins.scss +50 -0
- package/layers/ui/app/assets/scss/_reset.scss +65 -0
- package/layers/ui/app/assets/scss/_tokens.scss +183 -0
- package/layers/ui/app/assets/scss/main.scss +4 -0
- package/layers/ui/app/components/field/Boolean.vue +30 -0
- package/layers/ui/app/components/field/Choice.vue +68 -0
- package/layers/ui/app/components/field/Datetime.vue +96 -0
- package/layers/ui/app/components/field/DeadRefNote.vue +28 -0
- package/layers/ui/app/components/field/Json.vue +63 -0
- package/layers/ui/app/components/field/Layout.vue +140 -0
- package/layers/ui/app/components/field/Link.vue +112 -0
- package/layers/ui/app/components/field/LinkInternalPicker.vue +95 -0
- package/layers/ui/app/components/field/Number.vue +30 -0
- package/layers/ui/app/components/field/Relation.vue +49 -0
- package/layers/ui/app/components/field/Renderer.vue +30 -0
- package/layers/ui/app/components/field/Repeater.vue +460 -0
- package/layers/ui/app/components/field/Richtext.vue +26 -0
- package/layers/ui/app/components/field/Slug.vue +66 -0
- package/layers/ui/app/components/field/Text.vue +35 -0
- package/layers/ui/app/components/field/Unsupported.vue +34 -0
- package/layers/ui/app/components/ui/Alert.vue +50 -0
- package/layers/ui/app/components/ui/Brand.vue +22 -0
- package/layers/ui/app/components/ui/Button.vue +116 -0
- package/layers/ui/app/components/ui/ButtonGroup.vue +96 -0
- package/layers/ui/app/components/ui/Checkbox.vue +52 -0
- package/layers/ui/app/components/ui/CheckboxGroup.vue +50 -0
- package/layers/ui/app/components/ui/Combobox.vue +343 -0
- package/layers/ui/app/components/ui/DatePicker.vue +92 -0
- package/layers/ui/app/components/ui/DateRangePicker.vue +100 -0
- package/layers/ui/app/components/ui/Dialog.vue +107 -0
- package/layers/ui/app/components/ui/EmptyState.vue +46 -0
- package/layers/ui/app/components/ui/Field.vue +57 -0
- package/layers/ui/app/components/ui/Fieldset.vue +64 -0
- package/layers/ui/app/components/ui/Icon.vue +44 -0
- package/layers/ui/app/components/ui/Menu.vue +54 -0
- package/layers/ui/app/components/ui/NumberInput.vue +173 -0
- package/layers/ui/app/components/ui/Richtext.vue +119 -0
- package/layers/ui/app/components/ui/RichtextToolbar.vue +253 -0
- package/layers/ui/app/components/ui/Select.vue +51 -0
- package/layers/ui/app/components/ui/TextInput.vue +110 -0
- package/layers/ui/app/components/ui/Textarea.vue +38 -0
- package/layers/ui/app/components/ui/TimeInput.vue +17 -0
- package/layers/ui/app/components/ui/Toasts.vue +103 -0
- package/layers/ui/app/components/ui/Tooltip.vue +45 -0
- package/layers/ui/app/composables/useAdminLang.ts +8 -0
- package/layers/ui/app/composables/useDragReorder.ts +41 -0
- package/layers/ui/app/composables/useEchoGuard.ts +28 -0
- package/layers/ui/app/composables/useLinkField.ts +70 -0
- package/layers/ui/app/composables/useRecordOptions.ts +84 -0
- package/layers/ui/app/composables/useRepeater.ts +99 -0
- package/layers/ui/app/composables/useT.ts +32 -0
- package/layers/ui/app/composables/useToast.ts +46 -0
- package/layers/ui/app/i18n/de.ts +406 -0
- package/layers/ui/app/i18n/en.ts +440 -0
- package/layers/ui/app/utils/boolean-field.ts +10 -0
- package/layers/ui/app/utils/field-component.ts +22 -0
- package/layers/ui/app/utils/field-empty.ts +21 -0
- package/layers/ui/app/utils/field-registry.ts +36 -0
- package/layers/ui/app/utils/humanize.ts +14 -0
- package/layers/ui/app/utils/icons.ts +147 -0
- package/layers/ui/app/utils/iso-date.ts +18 -0
- package/layers/ui/app/utils/localized.ts +13 -0
- package/layers/ui/app/utils/record-context.ts +15 -0
- package/layers/ui/app/utils/reorder.ts +8 -0
- package/layers/ui/app/utils/richtext-preserve-class.ts +31 -0
- package/layers/ui/app/utils/useFieldA11y.ts +40 -0
- package/layers/ui/nuxt.config.ts +5 -0
- package/nuxt.config.ts +105 -0
- package/package.json +122 -0
- package/scripts/hash-password.mjs +26 -0
- package/scripts/typecheck.mjs +51 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { eq, getTableColumns } from 'drizzle-orm'
|
|
2
|
+
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
3
|
+
import { media } from '../collections/media'
|
|
4
|
+
import { derivativeKey, withPreservedTranslations, type DerivativeManifest } from './record'
|
|
5
|
+
import type { DerivedImage } from './derive'
|
|
6
|
+
import type { StorageDriver } from '../../../core/server/utils/storage'
|
|
7
|
+
import type { Translations } from './translations'
|
|
8
|
+
|
|
9
|
+
// Long-lived (non-immutable, so a replace isn't pinned) cache for originals + derivatives — effective on
|
|
10
|
+
// the S3 driver, ignored by the local driver (NGINX owns its cache). Mirrors the header in backfill.ts.
|
|
11
|
+
export const MEDIA_CACHE_CONTROL = 'public, max-age=31536000'
|
|
12
|
+
|
|
13
|
+
export interface PersistArgs {
|
|
14
|
+
storageKey: string
|
|
15
|
+
bytes: Buffer
|
|
16
|
+
mime: string
|
|
17
|
+
derived?: DerivedImage
|
|
18
|
+
values: Record<string, unknown>
|
|
19
|
+
existing?: { id: number; derivatives?: DerivativeManifest; translations?: Translations }
|
|
20
|
+
overwrite: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Write the original + derivative objects and insert/update the media row. On the CREATE path (no
|
|
25
|
+
* pre-existing row) a failure at ANY step removes every blob written during the attempt — otherwise a
|
|
26
|
+
* partial write (an S3 hiccup mid-derivative, a lost INSERT race) strands orphan blobs that permanently
|
|
27
|
+
* 409-block the filename (overwrite can't reclaim a row-less key, and no reconcile removes them). The
|
|
28
|
+
* OVERWRITE path leaves the (pre-existing) original in place on failure — it was there before this upload.
|
|
29
|
+
*/
|
|
30
|
+
export async function persistUpload(db: BetterSQLite3Database, driver: StorageDriver, args: PersistArgs): Promise<Record<string, unknown>> {
|
|
31
|
+
const { storageKey, bytes, mime, derived, values, existing, overwrite } = args
|
|
32
|
+
const cols = getTableColumns(media) as Record<string, never>
|
|
33
|
+
const isOverwrite = !!(existing && overwrite)
|
|
34
|
+
const derivKeys = (derived?.variants ?? []).map((v) => derivativeKey(storageKey, v.name, v.format))
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
await driver.put(storageKey, bytes, mime, { cacheControl: MEDIA_CACHE_CONTROL })
|
|
38
|
+
for (const v of derived?.variants ?? []) {
|
|
39
|
+
await driver.put(derivativeKey(storageKey, v.name, v.format), v.bytes, v.mime, { cacheControl: MEDIA_CACHE_CONTROL })
|
|
40
|
+
}
|
|
41
|
+
if (isOverwrite) {
|
|
42
|
+
// Drop any old derivative objects the new manifest no longer references, then update the row.
|
|
43
|
+
const newKeys = new Set(Object.values(values.derivatives as DerivativeManifest).map((d) => d.key))
|
|
44
|
+
for (const old of Object.values(existing!.derivatives ?? {})) {
|
|
45
|
+
if (!newKeys.has(old.key)) await driver.delete(old.key)
|
|
46
|
+
}
|
|
47
|
+
// Re-read translations fresh, right before the merge — `existing` was captured by the caller BEFORE
|
|
48
|
+
// the (potentially slow) driver.put calls above; a concurrent alt-text PATCH landing during that
|
|
49
|
+
// window would otherwise be silently reverted by merging the pre-I/O snapshot.
|
|
50
|
+
const fresh = db.select({ translations: cols.translations }).from(media)
|
|
51
|
+
.where(eq(cols.storageKey, storageKey)).get() as { translations?: Translations } | undefined
|
|
52
|
+
return db.update(media).set({ ...withPreservedTranslations(values, fresh ?? existing), updatedAt: new Date() })
|
|
53
|
+
.where(eq(cols.storageKey, storageKey)).returning().get() as Record<string, unknown>
|
|
54
|
+
}
|
|
55
|
+
return db.insert(media).values(values as never).returning().get() as Record<string, unknown>
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (!isOverwrite) {
|
|
58
|
+
for (const k of [storageKey, ...derivKeys]) await driver.delete(k).catch(() => {})
|
|
59
|
+
}
|
|
60
|
+
throw error
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { fieldIs } from '../../../core/server/utils/defineCollection'
|
|
2
|
+
import type { FieldPopulator } from '../../../core/server/utils/populate'
|
|
3
|
+
import { memoDuringPrerender } from '../../../core/server/utils/prerender-memo'
|
|
4
|
+
import { memoResolver } from '../../../core/server/utils/resolve-scope'
|
|
5
|
+
import { captureRead } from '../../../core/server/utils/read-capture'
|
|
6
|
+
import type { ResolvedMedia } from './resolve'
|
|
7
|
+
|
|
8
|
+
export type ResolveById = (id: number, locale: string) => ResolvedMedia | null
|
|
9
|
+
|
|
10
|
+
function attach(target: Record<string, unknown>, key: string, value: unknown): void {
|
|
11
|
+
// Clone the existing $media bag rather than mutate it in place: if `target` is a shallow clone that
|
|
12
|
+
// happens to share a pre-existing $media sidecar with the stored row, in-place mutation would leak into
|
|
13
|
+
// the original. No current caller feeds a $media-bearing bag into the walker, but this keeps the
|
|
14
|
+
// populator unconditionally non-destructive (future-proof against a double-populate / pre-seeded bag).
|
|
15
|
+
target.$media = { ...((target.$media as Record<string, unknown> | undefined) ?? {}), [key]: value }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The `media` field populator: resolves a media id (single, key `${name}Id` in columns / bare `name` in
|
|
20
|
+
* props) or id array (multiple, always bare) to a `ResolvedMedia` under the row/entry's `$media` bag,
|
|
21
|
+
* leaving the raw id column untouched. Registered per-type via `registerFieldPopulator('media', …)`; the
|
|
22
|
+
* shared field-tree walker drives it over top-level fields, block props, and (now) repeater entries.
|
|
23
|
+
*/
|
|
24
|
+
export function buildMediaFieldPopulator(resolveById: ResolveById): FieldPopulator {
|
|
25
|
+
// The same media id+locale is resolved once — build-wide during a generate run, request-/publish-run-
|
|
26
|
+
// wide via the resolve scope (which also budgets one live request's distinct fan-out).
|
|
27
|
+
// memoResolver OUTERMOST so a per-scope budget-skip null is never cached build-wide by the prerender memo.
|
|
28
|
+
const key = (id: number, locale: string) => `media:${id}:${locale}`
|
|
29
|
+
const resolve = memoResolver(memoDuringPrerender(resolveById, key), key)
|
|
30
|
+
return (bag, key, field, ctx, keyMode) => {
|
|
31
|
+
if (!fieldIs(field, 'media')) return
|
|
32
|
+
// captureRead runs even on a memo hit (each embedding page must record the dep), so it sits here, not
|
|
33
|
+
// inside `resolve`: it durably ties this render's route to `media:<id>`, so a later media change
|
|
34
|
+
// (rename/move/alt-edit) re-renders the page instead of leaving a stale/broken image in the static output.
|
|
35
|
+
if (!field.options?.multiple) {
|
|
36
|
+
const id = bag[keyMode === 'columns' ? `${key}Id` : key]
|
|
37
|
+
if (typeof id === 'number') { captureRead('media', id); attach(bag, key, resolve(id, ctx.locale)) }
|
|
38
|
+
} else {
|
|
39
|
+
const ids = bag[key]
|
|
40
|
+
if (Array.isArray(ids)) {
|
|
41
|
+
const nums = ids.filter((x): x is number => typeof x === 'number')
|
|
42
|
+
for (const id of nums) captureRead('media', id)
|
|
43
|
+
attach(bag, key, nums.map((id) => resolve(id, ctx.locale)))
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { DerivedImage } from './derive'
|
|
2
|
+
import { mergeTranslations, type Translations } from './translations'
|
|
3
|
+
|
|
4
|
+
export interface DerivativeEntry { key: string; width: number; height: number; mime: string }
|
|
5
|
+
export type DerivativeManifest = Record<string, DerivativeEntry>
|
|
6
|
+
|
|
7
|
+
export interface MediaInput {
|
|
8
|
+
storageKey: string
|
|
9
|
+
folder: string
|
|
10
|
+
filename: string
|
|
11
|
+
mime: string
|
|
12
|
+
ext: string
|
|
13
|
+
size: number
|
|
14
|
+
checksum: string
|
|
15
|
+
derived?: DerivedImage
|
|
16
|
+
translations?: Record<string, { alt?: string; title?: string; description?: string }>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Object key for a resized/reformatted variant of an original. The stem is the FULL original key
|
|
21
|
+
* (extension included), so two same-stem uploads that differ only in extension — `logo.png` and
|
|
22
|
+
* `logo.jpg`, a designer exporting both formats — never derive the same key and overwrite/cross-delete
|
|
23
|
+
* each other's variants. `storageKey` is unique, so this stays injective per original.
|
|
24
|
+
*/
|
|
25
|
+
export function derivativeKey(originalKey: string, name: string, format: string): string {
|
|
26
|
+
return `${originalKey}-${name}.${format}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildMediaValues(input: MediaInput): Record<string, unknown> {
|
|
30
|
+
const manifest: DerivativeManifest = {}
|
|
31
|
+
const src = input.derived
|
|
32
|
+
for (const v of src?.variants ?? []) {
|
|
33
|
+
// Name-keyed (`<name>.<format>`); height is the variant's REAL output dim (crops break the aspect ratio).
|
|
34
|
+
manifest[`${v.name}.${v.format}`] = { key: derivativeKey(input.storageKey, v.name, v.format), width: v.width, height: v.height, mime: v.mime }
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
storageKey: input.storageKey,
|
|
38
|
+
folder: input.folder || null,
|
|
39
|
+
filename: input.filename,
|
|
40
|
+
mime: input.mime,
|
|
41
|
+
ext: input.ext,
|
|
42
|
+
size: input.size,
|
|
43
|
+
width: input.derived?.width ?? null,
|
|
44
|
+
height: input.derived?.height ?? null,
|
|
45
|
+
checksum: input.checksum,
|
|
46
|
+
thumbhash: input.derived?.thumbhash ?? null,
|
|
47
|
+
derivatives: manifest,
|
|
48
|
+
translations: input.translations ?? {},
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* On overwrite (re-upload of an existing storageKey) the multipart request rarely re-sends
|
|
54
|
+
* alt/title/description — the conflict-dialog overwrite path never does — so a bare re-upload must not
|
|
55
|
+
* wipe the per-locale metadata editors maintain via the viewer. Merge any incoming fields over the
|
|
56
|
+
* existing map (same semantics as the PATCH endpoint); an empty incoming map leaves the existing intact.
|
|
57
|
+
*/
|
|
58
|
+
export function withPreservedTranslations(
|
|
59
|
+
values: Record<string, unknown>,
|
|
60
|
+
existing: { translations?: Translations } | undefined,
|
|
61
|
+
): Record<string, unknown> {
|
|
62
|
+
return { ...values, translations: mergeTranslations(existing?.translations, (values.translations ?? {}) as Translations) }
|
|
63
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { eq, inArray, getTableColumns, sql } from 'drizzle-orm'
|
|
2
|
+
import { createError, type H3Event } from 'h3'
|
|
3
|
+
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
4
|
+
import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
|
|
5
|
+
import { media } from '../collections/media'
|
|
6
|
+
import { folders } from '../database/folders'
|
|
7
|
+
import { isUnder, parentOf, childName, rewritePrefix } from './folder-paths'
|
|
8
|
+
import { sanitizeFolder, buildKey, suggestFreeName } from './naming'
|
|
9
|
+
import { planRelocateInto, planRename, type ItemPlan } from './relocate-plan'
|
|
10
|
+
import { relocateMedia, duplicateMedia } from './storage-relocate'
|
|
11
|
+
import { ensureFolder } from './folders'
|
|
12
|
+
import { collectAffected, deleteAffected, type OpItem, type AffectedSet } from './media-ops'
|
|
13
|
+
import { useStorageDriver, type StorageDriver } from '../../../core/server/utils/storage'
|
|
14
|
+
import { escapeLike } from '../../../core/server/utils/sql'
|
|
15
|
+
|
|
16
|
+
export type OpType = 'move' | 'copy' | 'rename'
|
|
17
|
+
export interface MediaOp { type: OpType; items: OpItem[]; dest?: string; name?: string }
|
|
18
|
+
export interface Conflict { item: OpItem; targetPath: string; type: 'file-exists' | 'folder-exists' }
|
|
19
|
+
export interface RelocationReport { summary: { files: number; folders: number; totalBytes: number }; conflicts: Conflict[] }
|
|
20
|
+
|
|
21
|
+
/** Parse untrusted request input into well-formed op items (drops malformed entries). */
|
|
22
|
+
export function coerceOpItems(raw: unknown): OpItem[] {
|
|
23
|
+
if (!Array.isArray(raw)) return []
|
|
24
|
+
const out: OpItem[] = []
|
|
25
|
+
for (const it of raw) {
|
|
26
|
+
if (!it || typeof it !== 'object') continue
|
|
27
|
+
const o = it as Record<string, unknown>
|
|
28
|
+
if (o.type === 'file' && Number.isInteger(o.id)) out.push({ type: 'file', id: o.id as number })
|
|
29
|
+
else if (o.type === 'folder' && typeof o.path === 'string') {
|
|
30
|
+
// Sanitize at the boundary so the disk cascade (removeDir) targets the same normalized path
|
|
31
|
+
// the DB cascade matches — a raw `..` would diverge them. Empty == root, not a valid item.
|
|
32
|
+
const path = sanitizeFolder(o.path)
|
|
33
|
+
if (path) out.push({ type: 'folder', path })
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Resolve one op item into its affected set + relocation plan (shared by preview and execute). */
|
|
40
|
+
export function planItem(db: BetterSQLite3Database, op: MediaOp, item: OpItem): { affected: AffectedSet; plan: ItemPlan } {
|
|
41
|
+
const affected = collectAffected(db, [item])
|
|
42
|
+
const plan = op.type === 'rename'
|
|
43
|
+
? planRename(item, affected, op.name ?? '')
|
|
44
|
+
: planRelocateInto(item, affected, op.dest ?? '')
|
|
45
|
+
return { affected, plan }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Reject malformed ops before any work: empty items, bad rename args, folder-into-itself moves. */
|
|
49
|
+
export function validateOp(op: MediaOp): void {
|
|
50
|
+
if (!op.items.length) throw createError({ statusCode: 400, statusMessage: 'No items' })
|
|
51
|
+
if (op.type === 'rename') {
|
|
52
|
+
if (op.items.length !== 1 || !op.name) throw createError({ statusCode: 400, statusMessage: 'Rename needs exactly one item and a name' })
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
const dest = sanitizeFolder(op.dest ?? '')
|
|
56
|
+
for (const item of op.items) {
|
|
57
|
+
if (item.type === 'folder' && isUnder(item.path, dest)) {
|
|
58
|
+
throw createError({ statusCode: 400, statusMessage: 'Cannot move a folder into itself' })
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Wave 1 (dryRun): plan the op, then report impact + target-occupancy conflicts. Mutates nothing. */
|
|
64
|
+
export function previewRelocation(db: BetterSQLite3Database, op: MediaOp): RelocationReport {
|
|
65
|
+
validateOp(op)
|
|
66
|
+
const planned = op.items.map((item) => ({ item, ...planItem(db, op, item) }))
|
|
67
|
+
|
|
68
|
+
const summary = {
|
|
69
|
+
files: planned.reduce((n, p) => n + p.affected.media.length, 0),
|
|
70
|
+
folders: planned.reduce((n, p) => n + p.affected.folders.length, 0),
|
|
71
|
+
totalBytes: planned.reduce((n, p) => n + p.affected.media.reduce((s, m) => s + (m.size || 0), 0), 0),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Bounded index lookups instead of materializing the whole media/folders tables: this runs once per
|
|
75
|
+
// item during execute for any onConflict other than 'abort' (applyStrategy re-previews in isolation),
|
|
76
|
+
// so a full scan here is O(items × table size) on a bulk move/copy. Both probed columns are uniquely
|
|
77
|
+
// indexed, so only the keys/paths THIS op's own items could possibly land on ever need checking —
|
|
78
|
+
// the loop below never queries either Set outside that set.
|
|
79
|
+
const probeKeys = new Set<string>()
|
|
80
|
+
const probeRoots = new Set<string>()
|
|
81
|
+
for (const { item, plan } of planned) {
|
|
82
|
+
if (item.type === 'file') {
|
|
83
|
+
const k = plan.media[0]?.toKey
|
|
84
|
+
if (k) probeKeys.add(k)
|
|
85
|
+
} else {
|
|
86
|
+
probeRoots.add(plan.toRoot)
|
|
87
|
+
probeKeys.add(plan.toRoot)
|
|
88
|
+
for (const m of plan.media) probeKeys.add(m.toKey)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const allKeys = probeKeys.size
|
|
92
|
+
? new Set((db.select({ k: getTableColumns(media).storageKey }).from(media as AnySQLiteTable)
|
|
93
|
+
.where(inArray(getTableColumns(media).storageKey, [...probeKeys])).all() as { k: string }[]).map((r) => r.k))
|
|
94
|
+
: new Set<string>()
|
|
95
|
+
const allFolderPaths = probeRoots.size
|
|
96
|
+
? new Set(db.select({ path: folders.path }).from(folders).where(inArray(folders.path, [...probeRoots])).all().map((f) => f.path))
|
|
97
|
+
: new Set<string>()
|
|
98
|
+
|
|
99
|
+
// Preview frees all source keys set-wise (order-independent), but execute vacates them one at a time — so a key-rotation batch (move A onto B's key while moving B) can pass preview yet hit relocateMedia's per-key 409 mid-execute; acceptable under the per-item compensating model.
|
|
100
|
+
const frees = op.type === 'copy' ? { keys: new Set<string>(), folders: new Set<string>() } : {
|
|
101
|
+
keys: new Set(planned.flatMap((p) => p.plan.media.map((m) => m.fromKey))),
|
|
102
|
+
folders: new Set(planned.flatMap((p) => p.affected.folders)),
|
|
103
|
+
}
|
|
104
|
+
const isOccupiedKey = (key: string) => allKeys.has(key) && !frees.keys.has(key)
|
|
105
|
+
const isOccupiedFolder = (path: string) => allFolderPaths.has(path) && !frees.folders.has(path)
|
|
106
|
+
|
|
107
|
+
// Targets claimed by earlier items in this same op, so two items whose new
|
|
108
|
+
// targets land on the same slot collide (the later one is flagged).
|
|
109
|
+
const claimedKeys = new Set<string>()
|
|
110
|
+
const claimedRoots = new Set<string>()
|
|
111
|
+
|
|
112
|
+
const conflicts: Conflict[] = []
|
|
113
|
+
for (const { item, plan } of planned) {
|
|
114
|
+
if (item.type === 'file') {
|
|
115
|
+
const toKey = plan.media[0]?.toKey
|
|
116
|
+
if (toKey && (isOccupiedKey(toKey) || claimedKeys.has(toKey))) conflicts.push({ item, targetPath: toKey, type: 'file-exists' })
|
|
117
|
+
// folder-exists ⇒ the destination subtree is occupied: an existing folder row, a FILE whose key equals the
|
|
118
|
+
// target root (a folder onto a file would mkdir over a file → 500 / DB-vs-disk desync), a descendant file
|
|
119
|
+
// key, or an intra-batch claim.
|
|
120
|
+
} else if (isOccupiedFolder(plan.toRoot) || isOccupiedKey(plan.toRoot) || claimedRoots.has(plan.toRoot) || claimedKeys.has(plan.toRoot) || plan.media.some((m) => isOccupiedKey(m.toKey) || claimedKeys.has(m.toKey))) {
|
|
121
|
+
conflicts.push({ item, targetPath: plan.toRoot, type: 'folder-exists' })
|
|
122
|
+
}
|
|
123
|
+
if (item.type === 'folder') claimedRoots.add(plan.toRoot)
|
|
124
|
+
for (const m of plan.media) claimedKeys.add(m.toKey)
|
|
125
|
+
}
|
|
126
|
+
return { summary, conflicts }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface OpResult {
|
|
130
|
+
item: OpItem
|
|
131
|
+
status: 'moved' | 'copied' | 'renamed' | 'skipped' | 'overwritten' | 'renamed-auto'
|
|
132
|
+
newPath?: string
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Move/rename folder-item branch: repoint the folder rows in one transaction. */
|
|
136
|
+
function relocateFolderRows(db: BetterSQLite3Database, plan: ItemPlan): void {
|
|
137
|
+
db.transaction(() => {
|
|
138
|
+
// Ensure the destination's PARENT chain (not toRoot) first: toRoot is created by re-pathing
|
|
139
|
+
// the source root row, so pre-inserting it would clash on the folders UNIQUE(path).
|
|
140
|
+
ensureFolder(db, parentOf(plan.toRoot) ?? '')
|
|
141
|
+
for (const f of plan.folders) db.update(folders).set({ path: f.to, updatedAt: new Date() }).where(eq(folders.path, f.from)).run()
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Copy folder-item branch: create the destination folder rows. */
|
|
146
|
+
function createCopiedFolderRows(db: BetterSQLite3Database, plan: ItemPlan): void {
|
|
147
|
+
for (const f of plan.folders) ensureFolder(db, f.to)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** An item's result plus the media ids it actually LANDED on the target: for a copy those are the rows
|
|
151
|
+
* `duplicateMedia` created (the source rows never move), for a move/rename the source rows themselves.
|
|
152
|
+
* Internal to the batch loop — the wire shape stays `OpResult`. */
|
|
153
|
+
type ExecutedItem = OpResult & { landedIds: number[] }
|
|
154
|
+
|
|
155
|
+
/** Execute one already-planned item: relocate/duplicate its media rows and repoint/create its
|
|
156
|
+
* folder rows. No-op items short-circuit to 'skipped'. */
|
|
157
|
+
async function executeItem(
|
|
158
|
+
db: BetterSQLite3Database, driver: StorageDriver, op: MediaOp, item: OpItem, plan: ItemPlan,
|
|
159
|
+
): Promise<ExecutedItem> {
|
|
160
|
+
if (plan.media.every((m) => m.toKey === m.fromKey) && plan.folders.every((f) => f.from === f.to)) {
|
|
161
|
+
return { item, status: 'skipped', landedIds: [] }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (op.type === 'copy') {
|
|
165
|
+
const landedIds: number[] = []
|
|
166
|
+
for (const m of plan.media) landedIds.push((await duplicateMedia(db, driver, m.id, { folder: m.toFolder, filename: m.toFilename })).id)
|
|
167
|
+
if (item.type === 'folder') {
|
|
168
|
+
createCopiedFolderRows(db, plan)
|
|
169
|
+
for (const f of plan.folders) await driver.ensureDir?.(f.to)
|
|
170
|
+
} else ensureFolder(db, plan.toRoot)
|
|
171
|
+
return { item, status: 'copied', newPath: item.type === 'folder' ? plan.toRoot : plan.media[0].toKey, landedIds }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const m of plan.media) await relocateMedia(db, driver, m.id, { folder: m.toFolder, filename: m.toFilename })
|
|
175
|
+
if (item.type === 'folder') {
|
|
176
|
+
relocateFolderRows(db, plan)
|
|
177
|
+
for (const f of plan.folders) await driver.ensureDir?.(f.to) // dest dirs incl. empty subfolders
|
|
178
|
+
// Clean the vacated source subtree ONLY when nothing unmanaged remains — removeDir is a recursive wipe,
|
|
179
|
+
// so a blind call would destroy non-media objects (extension/consumer blobs) living under the same path.
|
|
180
|
+
const remaining = driver.listPrefix ? await driver.listPrefix(item.path) : ['guard']
|
|
181
|
+
if (remaining.length === 0) await driver.removeDir?.(item.path)
|
|
182
|
+
} else ensureFolder(db, plan.toRoot)
|
|
183
|
+
return {
|
|
184
|
+
item, status: op.type === 'rename' ? 'renamed' : 'moved',
|
|
185
|
+
newPath: item.type === 'folder' ? plan.toRoot : plan.media[0].toKey,
|
|
186
|
+
landedIds: plan.media.map((m) => m.id),
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Wave 2 (execute): re-validate (TOCTOU), then move/copy/rename each item in order, re-planning
|
|
191
|
+
* per item against the now-mutated db. `abort` rejects 409 up front if any conflict exists. */
|
|
192
|
+
export async function executeRelocation(
|
|
193
|
+
db: BetterSQLite3Database, driver: StorageDriver, op: MediaOp,
|
|
194
|
+
onConflict: 'abort' | 'skip' | 'overwrite' | 'rename' = 'abort',
|
|
195
|
+
): Promise<OpResult[]> {
|
|
196
|
+
validateOp(op)
|
|
197
|
+
const report = previewRelocation(db, op)
|
|
198
|
+
if (onConflict === 'abort' && report.conflicts.length) {
|
|
199
|
+
throw createError({ statusCode: 409, statusMessage: 'Conflicts prevent the operation', data: report })
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Ids this batch has already relocated ONTO their target — so a later item's 'overwrite' never treats
|
|
203
|
+
// an earlier item's freshly-landed row as a stale occupant to destroy (see deleteOccupant below).
|
|
204
|
+
const relocatedIds = new Set<number>()
|
|
205
|
+
const results: OpResult[] = []
|
|
206
|
+
for (const item of op.items) {
|
|
207
|
+
const { landedIds, ...result } = await applyStrategy(db, driver, op, item, onConflict, relocatedIds)
|
|
208
|
+
results.push(result)
|
|
209
|
+
for (const id of landedIds) relocatedIds.add(id)
|
|
210
|
+
}
|
|
211
|
+
return results
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Shared body for the move/copy/rename POST handlers (they differ only in the op shape): auth backstop,
|
|
216
|
+
* parse + non-empty-check the items, build the op via `buildOp`, parse `onConflict`, then dispatch a
|
|
217
|
+
* dry-run preview vs a real execute. Each route becomes a one-liner supplying only its op.
|
|
218
|
+
*/
|
|
219
|
+
export async function runRelocation(event: H3Event, buildOp: (items: OpItem[], body: Record<string, unknown>) => MediaOp) {
|
|
220
|
+
requireAdmin(event)
|
|
221
|
+
const body = (await readBody(event)) as Record<string, unknown> | null
|
|
222
|
+
const items = coerceOpItems(body?.items)
|
|
223
|
+
if (!items.length) throw createError({ statusCode: 400, statusMessage: 'No items' })
|
|
224
|
+
const op = buildOp(items, body ?? {})
|
|
225
|
+
const oc = body?.onConflict
|
|
226
|
+
const onConflict = oc === 'skip' || oc === 'overwrite' || oc === 'rename' ? oc : 'abort'
|
|
227
|
+
const db = useDb()
|
|
228
|
+
if (body?.dryRun === true) return previewRelocation(db, op)
|
|
229
|
+
return executeRelocation(db, useStorageDriver(), op, onConflict)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Per-item conflict dispatch. Re-checks this item's status against the live db (earlier items in
|
|
233
|
+
* the batch are already committed), then resolves per `onConflict`. No-Merge: a folder conflict is
|
|
234
|
+
* handled at the subtree root (skip/overwrite/rename the whole subtree, never per file). */
|
|
235
|
+
async function applyStrategy(
|
|
236
|
+
db: BetterSQLite3Database, driver: StorageDriver, op: MediaOp, item: OpItem,
|
|
237
|
+
onConflict: 'abort' | 'skip' | 'overwrite' | 'rename', relocatedIds: Set<number>,
|
|
238
|
+
): Promise<ExecutedItem> {
|
|
239
|
+
const conflicts = onConflict === 'abort' ? [] : previewRelocation(db, { ...op, items: [item] }).conflicts
|
|
240
|
+
const { plan } = planItem(db, op, item)
|
|
241
|
+
|
|
242
|
+
if (conflicts.length) {
|
|
243
|
+
if (onConflict === 'skip') return { item, status: 'skipped', landedIds: [] }
|
|
244
|
+
if (onConflict === 'overwrite') {
|
|
245
|
+
// Copy/paste into the file's own folder: the "occupant" of the target key IS the source.
|
|
246
|
+
// Overwriting a file with a copy of itself is a no-op — deleting the occupant here would
|
|
247
|
+
// destroy the only copy (executeItem then short-circuits as a no-op and copies nothing).
|
|
248
|
+
const isNoop = plan.media.every((m) => m.toKey === m.fromKey) && plan.folders.every((f) => f.from === f.to)
|
|
249
|
+
if (isNoop) return { item, status: 'skipped', landedIds: [] }
|
|
250
|
+
// The per-item re-preview above sees only the LIVE db, so it can't tell a genuine pre-existing
|
|
251
|
+
// occupant from an earlier item in THIS SAME batch that already landed there — deleting the latter
|
|
252
|
+
// would silently destroy real data this very operation just placed, one item after reporting it
|
|
253
|
+
// as a success. Refuse and skip this item instead (its source is left untouched).
|
|
254
|
+
if (!(await deleteOccupant(db, driver, item, plan, relocatedIds))) return { item, status: 'skipped', landedIds: [] }
|
|
255
|
+
const r = await executeItem(db, driver, op, item, plan)
|
|
256
|
+
return { ...r, status: 'overwritten' }
|
|
257
|
+
}
|
|
258
|
+
const freePlan = freeRenamePlan(db, item, plan)
|
|
259
|
+
const r = await executeItem(db, driver, op, item, freePlan)
|
|
260
|
+
const newPath = item.type === 'file' ? freePlan.media[0]?.toKey : freePlan.toRoot
|
|
261
|
+
return { ...r, status: 'renamed-auto', newPath }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return executeItem(db, driver, op, item, plan)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** True when no media row currently occupies this storage key. */
|
|
268
|
+
function keyIsFree(db: BetterSQLite3Database, key: string): boolean {
|
|
269
|
+
const cols = getTableColumns(media)
|
|
270
|
+
const row = db.select({ id: cols.id }).from(media as AnySQLiteTable).where(eq(cols.storageKey, key)).get()
|
|
271
|
+
return !row
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** True when no folders row has this path, no FILE sits exactly at it, AND no media key sits under it. */
|
|
275
|
+
function folderRootIsFree(db: BetterSQLite3Database, path: string): boolean {
|
|
276
|
+
const cols = getTableColumns(media)
|
|
277
|
+
const folderRow = db.select({ path: folders.path }).from(folders).where(eq(folders.path, path)).get()
|
|
278
|
+
if (folderRow) return false
|
|
279
|
+
// A file whose storageKey EQUALS this path blocks the folder root too — a folder can't live where a file
|
|
280
|
+
// already is (mkdir over a file 500s; the per-media guard only checks descendant keys, never the root).
|
|
281
|
+
const fileAtRoot = db.select({ id: cols.id }).from(media as AnySQLiteTable).where(eq(cols.storageKey, path)).get()
|
|
282
|
+
if (fileAtRoot) return false
|
|
283
|
+
const descendant = db.select({ id: cols.id }).from(media as AnySQLiteTable)
|
|
284
|
+
.where(sql`${cols.storageKey} like ${`${escapeLike(path)}/%`} escape '\\'`).get()
|
|
285
|
+
return !descendant
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Delete whatever currently occupies the item's target so the move/copy can proceed. No-op if free.
|
|
289
|
+
* Returns false (refuses to delete) when the occupant is itself a media id THIS SAME BATCH already
|
|
290
|
+
* relocated onto that target — an intra-batch name collision, not a stale pre-existing occupant. */
|
|
291
|
+
async function deleteOccupant(
|
|
292
|
+
db: BetterSQLite3Database, driver: StorageDriver, item: OpItem, plan: ItemPlan, relocatedIds: Set<number>,
|
|
293
|
+
): Promise<boolean> {
|
|
294
|
+
// Occupant is deleted before the move commits, so a runtime fault mid-move loses it — inherent to overwrite.
|
|
295
|
+
if (item.type === 'file') {
|
|
296
|
+
const key = plan.media[0]?.toKey
|
|
297
|
+
if (!key) return true
|
|
298
|
+
const cols = getTableColumns(media)
|
|
299
|
+
const row = db.select({ id: cols.id }).from(media as AnySQLiteTable).where(eq(cols.storageKey, key)).get() as { id: number } | undefined
|
|
300
|
+
if (row) {
|
|
301
|
+
if (relocatedIds.has(row.id)) return false
|
|
302
|
+
await deleteAffected(db, driver, [{ type: 'file', id: row.id }], false)
|
|
303
|
+
}
|
|
304
|
+
return true
|
|
305
|
+
}
|
|
306
|
+
const occupant = collectAffected(db, [{ type: 'folder', path: plan.toRoot }])
|
|
307
|
+
if (occupant.media.some((m) => relocatedIds.has(m.id))) return false
|
|
308
|
+
await deleteAffected(db, driver, [{ type: 'folder', path: plan.toRoot }], false)
|
|
309
|
+
return true
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Build a plan whose target is free, for the rename strategy (file → freed filename; folder → freed root). */
|
|
313
|
+
function freeRenamePlan(db: BetterSQLite3Database, item: OpItem, plan: ItemPlan): ItemPlan {
|
|
314
|
+
if (item.type === 'file') {
|
|
315
|
+
const m = plan.media[0]
|
|
316
|
+
const name = suggestFreeName(m.toFilename, (n) => keyIsFree(db, buildKey(m.toFolder, n)))
|
|
317
|
+
return { ...plan, media: [{ ...m, toFilename: name, toKey: buildKey(m.toFolder, name) }] }
|
|
318
|
+
}
|
|
319
|
+
const freeRoot = freeFolderRoot(db, plan.toRoot)
|
|
320
|
+
return rebasePlan(plan, plan.toRoot, freeRoot)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Pick a fresh sibling segment for a colliding folder root: `archive/sub` → `archive/sub-2`. */
|
|
324
|
+
function freeFolderRoot(db: BetterSQLite3Database, toRoot: string): string {
|
|
325
|
+
const parent = parentOf(toRoot) ?? ''
|
|
326
|
+
const seg = childName(toRoot)
|
|
327
|
+
const freeSeg = suggestFreeName(seg, (s) => folderRootIsFree(db, parent === '' ? s : `${parent}/${s}`))
|
|
328
|
+
return parent === '' ? freeSeg : `${parent}/${freeSeg}`
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Re-base every target in a folder plan from `from` onto `to` (the whole subtree moves under the new root). */
|
|
332
|
+
function rebasePlan(plan: ItemPlan, from: string, to: string): ItemPlan {
|
|
333
|
+
return {
|
|
334
|
+
toRoot: to,
|
|
335
|
+
media: plan.media.map((m) => {
|
|
336
|
+
const toFolder = rewritePrefix(m.toFolder, from, to)
|
|
337
|
+
return { ...m, toFolder, toKey: buildKey(toFolder, m.toFilename) }
|
|
338
|
+
}),
|
|
339
|
+
folders: plan.folders.map((f) => ({ from: f.from, to: rewritePrefix(f.to, from, to) })),
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createError } from 'h3'
|
|
2
|
+
import { sanitizeFolder, buildKey, withExtension, extensionOf } from './naming'
|
|
3
|
+
import { childName, parentOf, rewritePrefix } from './folder-paths'
|
|
4
|
+
import type { OpItem, AffectedSet, AffectedMedia } from './media-ops'
|
|
5
|
+
|
|
6
|
+
export interface PlannedMedia {
|
|
7
|
+
id: number
|
|
8
|
+
fromKey: string
|
|
9
|
+
toFolder: string
|
|
10
|
+
toFilename: string
|
|
11
|
+
toKey: string
|
|
12
|
+
}
|
|
13
|
+
export interface PlannedFolder { from: string; to: string }
|
|
14
|
+
export interface ItemPlan { toRoot: string; media: PlannedMedia[]; folders: PlannedFolder[] }
|
|
15
|
+
|
|
16
|
+
/** A rename must not introduce a path separator: take the last clean segment, never empty. */
|
|
17
|
+
function sanitizeSegment(name: string): string {
|
|
18
|
+
const seg = childName(sanitizeFolder(name))
|
|
19
|
+
if (!seg) throw createError({ statusCode: 400, statusMessage: 'Invalid name' })
|
|
20
|
+
return seg
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const planMedia = (m: AffectedMedia, toFolder: string): PlannedMedia => ({
|
|
24
|
+
id: m.id, fromKey: m.storageKey, toFolder, toFilename: m.filename, toKey: buildKey(toFolder, m.filename),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
/** Re-base every affected media row + folder path from the `from` subtree onto `to`. */
|
|
28
|
+
function cascade(affected: AffectedSet, from: string, to: string): Pick<ItemPlan, 'media' | 'folders'> {
|
|
29
|
+
return {
|
|
30
|
+
media: affected.media.map((m) => planMedia(m, rewritePrefix(m.folder ?? '', from, to))),
|
|
31
|
+
folders: affected.folders.map((fp) => ({ from: fp, to: rewritePrefix(fp, from, to) })),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Targets for moving/copying an item into `dest` (file → dest unchanged; folder → dest/<name>). */
|
|
36
|
+
export function planRelocateInto(item: OpItem, affected: AffectedSet, dest: string): ItemPlan {
|
|
37
|
+
const to = sanitizeFolder(dest)
|
|
38
|
+
if (item.type === 'file') {
|
|
39
|
+
const m = affected.media[0]
|
|
40
|
+
if (!m) throw createError({ statusCode: 404, statusMessage: 'Media not found' })
|
|
41
|
+
return { toRoot: to, media: [planMedia(m, to)], folders: [] }
|
|
42
|
+
}
|
|
43
|
+
const toRoot = to === '' ? childName(item.path) : `${to}/${childName(item.path)}`
|
|
44
|
+
return { toRoot, ...cascade(affected, item.path, toRoot) }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Targets for renaming an item in place (file → new filename; folder → new last segment). */
|
|
48
|
+
export function planRename(item: OpItem, affected: AffectedSet, name: string): ItemPlan {
|
|
49
|
+
if (item.type === 'file') {
|
|
50
|
+
const m = affected.media[0]
|
|
51
|
+
if (!m) throw createError({ statusCode: 404, statusMessage: 'Media not found' })
|
|
52
|
+
const toFolder = m.folder ?? ''
|
|
53
|
+
const toFilename = withExtension(name, extensionOf(m.filename))
|
|
54
|
+
return {
|
|
55
|
+
toRoot: toFolder, folders: [],
|
|
56
|
+
media: [{ id: m.id, fromKey: m.storageKey, toFolder, toFilename, toKey: buildKey(toFolder, toFilename) }],
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const parent = parentOf(item.path) ?? ''
|
|
60
|
+
const seg = sanitizeSegment(name)
|
|
61
|
+
const toRoot = parent === '' ? seg : `${parent}/${seg}`
|
|
62
|
+
return { toRoot, ...cascade(affected, item.path, toRoot) }
|
|
63
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { inArray, getTableColumns } from 'drizzle-orm'
|
|
2
|
+
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
3
|
+
import { primaryLocale } from '../../../core/server/utils/locale'
|
|
4
|
+
import { media } from '../collections/media'
|
|
5
|
+
import type { DerivativeManifest } from './record'
|
|
6
|
+
|
|
7
|
+
/** One derivative, tagged with its parsed name + format so `<picture>` rendering can group by format. */
|
|
8
|
+
export interface MediaVariant { name: string; format: string; url: string; width: number; height: number }
|
|
9
|
+
|
|
10
|
+
export interface ResolvedMedia {
|
|
11
|
+
id: number
|
|
12
|
+
/** The media's storage folder ('' at the media root) — lets the field open the picker there. */
|
|
13
|
+
folder: string
|
|
14
|
+
alt: string | null
|
|
15
|
+
title: string | null
|
|
16
|
+
description: string | null
|
|
17
|
+
mime: string
|
|
18
|
+
width: number | null
|
|
19
|
+
height: number | null
|
|
20
|
+
thumbhash: string | null
|
|
21
|
+
src: string
|
|
22
|
+
/** WebP-only responsive srcset (kept for the admin thumb/grid/field/viewer `<img>`s). */
|
|
23
|
+
srcset: { url: string; width: number }[]
|
|
24
|
+
/** Every derivative, name+format-tagged — the source `KestrelImg`/`useMediaVariant` build `<picture>` from. */
|
|
25
|
+
variants: MediaVariant[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface MediaRow {
|
|
29
|
+
id: number
|
|
30
|
+
folder: string | null
|
|
31
|
+
storageKey: string
|
|
32
|
+
mime: string
|
|
33
|
+
width: number | null
|
|
34
|
+
height: number | null
|
|
35
|
+
thumbhash: string | null
|
|
36
|
+
derivatives: DerivativeManifest | null
|
|
37
|
+
translations: Record<string, { alt?: string; title?: string; description?: string }> | null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resolveMedia(row: MediaRow, locale: string, publicUrl: (key: string) => string): ResolvedMedia {
|
|
41
|
+
// Per-FIELD locale fallback, not whole-locale: a locale entry that carries e.g. only `title` (the PATCH
|
|
42
|
+
// API creates partial per-locale objects) must not suppress the primary's `alt` — that silently drops
|
|
43
|
+
// accessibility text on that locale's pages. Each field falls back to the primary locale independently.
|
|
44
|
+
const loc = row.translations?.[locale] ?? {}
|
|
45
|
+
const prim = row.translations?.[primaryLocale()] ?? {}
|
|
46
|
+
const t = { alt: loc.alt ?? prim.alt, title: loc.title ?? prim.title, description: loc.description ?? prim.description }
|
|
47
|
+
// Every derivative, name+format-tagged (the manifest key is `<name>.<format>`; names are dot-free so the
|
|
48
|
+
// last dot splits cleanly). `srcset` is the webp slice, kept for the admin `<img>`s.
|
|
49
|
+
const variants = Object.entries(row.derivatives ?? {}).map(([mk, d]) => {
|
|
50
|
+
const dot = mk.lastIndexOf('.')
|
|
51
|
+
const name = dot > 0 ? mk.slice(0, dot) : mk
|
|
52
|
+
const format = dot > 0 ? mk.slice(dot + 1) : (d.mime.split('/').pop() ?? '')
|
|
53
|
+
return { name, format, url: publicUrl(d.key), width: d.width, height: d.height }
|
|
54
|
+
})
|
|
55
|
+
const srcset = variants
|
|
56
|
+
.filter((v) => v.format === 'webp')
|
|
57
|
+
.map((v) => ({ url: v.url, width: v.width }))
|
|
58
|
+
.sort((a, b) => a.width - b.width)
|
|
59
|
+
return {
|
|
60
|
+
id: row.id,
|
|
61
|
+
folder: row.folder ?? '', // root files store folder NULL → ''
|
|
62
|
+
alt: t.alt ?? null,
|
|
63
|
+
title: t.title ?? null,
|
|
64
|
+
description: t.description ?? null,
|
|
65
|
+
mime: row.mime,
|
|
66
|
+
width: row.width,
|
|
67
|
+
height: row.height,
|
|
68
|
+
thumbhash: row.thumbhash,
|
|
69
|
+
src: publicUrl(row.storageKey),
|
|
70
|
+
srcset,
|
|
71
|
+
variants,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Reorder rows to match the requested id order, dropping ids that resolved to nothing. */
|
|
76
|
+
export function orderById<T extends { id: number }>(ids: number[], rows: T[]): T[] {
|
|
77
|
+
const byId = new Map(rows.map((r) => [r.id, r]))
|
|
78
|
+
return ids.map((id) => byId.get(id)).filter((r): r is T => !!r)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function resolveManyByIds(db: BetterSQLite3Database, ids: number[], locale: string, publicUrl: (key: string) => string): ResolvedMedia[] {
|
|
82
|
+
if (!ids.length) return []
|
|
83
|
+
const cols = getTableColumns(media) as Record<string, never>
|
|
84
|
+
const rows = db.select().from(media).where(inArray(cols.id, ids)).all() as MediaRow[]
|
|
85
|
+
return orderById(ids, rows.map((r) => resolveMedia(r, locale, publicUrl)))
|
|
86
|
+
}
|