@michaelthielemann/kestrel 3.0.1 → 4.0.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/README.md +35 -110
- package/kestrel.config.ts +1 -1
- package/layers/access/server/middleware/00.ip-allowlist.ts +3 -18
- package/layers/access/server/middleware/access-guard.ts +25 -23
- package/layers/access/server/types.d.ts +1 -2
- package/layers/admin/app/components/AdminAccount.vue +1 -1
- package/layers/admin/app/components/BlockFields.vue +1 -2
- package/layers/admin/app/components/BlockPreview.vue +17 -5
- package/layers/admin/app/components/BlocksBody.vue +1 -1
- package/layers/admin/app/components/CollectionEditor.vue +35 -16
- package/layers/admin/app/components/CollectionList.vue +34 -3
- package/layers/admin/app/components/CollectionListBulkBar.vue +17 -3
- package/layers/admin/app/components/CollectionListFilterPanel.vue +1 -1
- package/layers/admin/app/components/CollectionListPager.vue +1 -1
- package/layers/admin/app/components/CollectionListTable.vue +41 -2
- package/layers/admin/app/components/EditorStatus.vue +8 -7
- package/layers/admin/app/components/LocaleBar.vue +3 -1
- package/layers/admin/app/components/PageFields.vue +2 -4
- package/layers/admin/app/components/SeoFields.vue +2 -4
- package/layers/admin/app/components/SingletonEditor.vue +3 -2
- package/layers/admin/app/composables/useAuth.ts +3 -3
- package/layers/admin/app/composables/useBlockTree.ts +1 -1
- package/layers/admin/app/composables/useBlocks.ts +1 -1
- package/layers/admin/app/composables/useCollectionOps.ts +45 -8
- package/layers/admin/app/composables/useCollections.ts +1 -2
- package/layers/admin/app/composables/useEditForm.ts +35 -17
- package/layers/admin/app/composables/useLinkResolver.ts +1 -1
- package/layers/admin/app/composables/useListBatchActions.ts +20 -3
- package/layers/admin/app/composables/useListColumns.ts +1 -1
- package/layers/admin/app/composables/useListFilterDraft.ts +3 -2
- package/layers/admin/app/composables/useListRows.ts +7 -2
- package/layers/admin/app/composables/useListUrlState.ts +1 -1
- package/layers/admin/app/composables/usePublishStatus.ts +2 -2
- package/layers/admin/app/layouts/admin.vue +0 -1
- package/layers/admin/app/pages/admin/[collection]/[id].vue +5 -4
- package/layers/admin/app/pages/admin/index.vue +1 -1
- package/layers/admin/app/pages/admin/references.vue +2 -2
- package/layers/admin/app/utils/block-tree.ts +1 -1
- package/layers/admin/app/utils/collection-ops.ts +28 -3
- package/layers/admin/app/utils/edit-form.ts +34 -2
- package/layers/admin/app/utils/editor-expose.ts +2 -0
- package/layers/admin/app/utils/editor-form-context.ts +1 -2
- package/layers/admin/app/utils/field-keys.ts +1 -1
- package/layers/admin/app/utils/list-columns.ts +3 -2
- package/layers/admin/app/utils/list-query.ts +2 -2
- package/layers/admin/app/utils/populate-blocks.ts +2 -2
- package/layers/admin/app/utils/reauth.ts +5 -6
- package/layers/auth/server/plugins/01.register-auth-pipelines.ts +7 -0
- package/layers/collections/server/plugins/01.register.ts +9 -12
- package/layers/collections/server/plugins/02.register-relation-populate.ts +5 -7
- package/layers/{fields → core}/app/utils/field-factories.ts +1 -1
- package/layers/core/modules/auto-discovery/extract-block.ts +3 -3
- package/layers/core/modules/auto-discovery/index.ts +69 -11
- package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
- package/layers/core/modules/auto-discovery/scan.ts +92 -0
- package/layers/core/modules/auto-discovery/virtual.d.ts +5 -0
- package/layers/core/modules/component-namespace/index.ts +24 -0
- package/layers/core/modules/kestrel/index.ts +6 -1
- package/layers/core/modules/plugin-order/index.ts +69 -0
- package/layers/core/modules/plugin-order/plugin-order.ts +273 -0
- package/layers/core/nuxt.config.ts +1 -0
- package/layers/core/server/api/[...path].ts +98 -0
- package/layers/core/server/plugins/00.config.ts +12 -0
- package/layers/core/server/plugins/00.migrate.ts +1 -0
- package/layers/core/server/plugins/01.register-introspection-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-openapi-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-tooling-pipelines.ts +5 -0
- package/layers/core/server/plugins/02.schema-sync.ts +2 -7
- package/layers/core/server/plugins/04.outbox-worker.ts +31 -0
- package/layers/core/server/plugins/05.reindex-refs.ts +5 -0
- package/layers/core/server/routes/__kestrel/dashboard.get.ts +29 -0
- package/layers/core/server/tasks/db/migrate-module.ts +83 -0
- package/layers/core/server/tasks/db/migrate-revisions.ts +20 -0
- package/layers/core/server/tasks/db/migrate.ts +2 -6
- package/layers/core/server/utils/server-config.ts +66 -3
- package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +7 -0
- package/layers/fields/server/plugins/01.register-field-populate.ts +2 -2
- package/layers/media/app/components/KestrelImg.vue +3 -3
- package/layers/media/app/components/MediaLibrary.vue +2 -2
- package/layers/media/app/components/field/Media.vue +1 -1
- package/layers/media/app/composables/useMediaResolver.ts +1 -1
- package/layers/media/app/composables/useMediaUpload.ts +1 -1
- package/layers/media/app/composables/useMediaVariant.ts +2 -2
- package/layers/media/app/utils/ai-disclosure.ts +1 -1
- package/layers/media/app/utils/library.ts +1 -1
- package/layers/media/app/utils/ops.ts +1 -3
- package/layers/media/app/utils/picture-model.ts +2 -2
- package/layers/media/app/utils/variant-name.ts +1 -1
- package/layers/media/server/middleware/ondemand-variants.ts +6 -5
- package/layers/media/server/plugins/01.register-media-pipelines.ts +7 -0
- package/layers/media/server/plugins/02.register-media.ts +10 -7
- package/layers/media/server/plugins/04.variant-capture.ts +12 -8
- package/layers/media/server/plugins/05.media-cleanup.ts +5 -0
- package/layers/media/server/tasks/media/backfill.ts +3 -4
- package/layers/public/app/components/KestrelLink.vue +1 -1
- package/layers/public/app/pages/[...slug].vue +1 -1
- package/layers/public/app/pages/__kestrel/preview.vue +2 -2
- package/layers/public/app/utils/json-ld.ts +1 -1
- package/layers/public/app/utils/link-href.ts +1 -1
- package/layers/public/app/utils/page-head.ts +1 -1
- package/layers/public/modules/deploy-output/index.ts +4 -6
- package/layers/public/modules/prerender-routes/index.ts +4 -4
- package/layers/public/modules/prerender-routes/routes.ts +1 -1
- package/layers/public/modules/prune-media/index.ts +2 -2
- package/layers/public/server/delivery-live/serve.ts +111 -0
- package/layers/public/server/middleware/delivery-live-catchall.ts +7 -0
- package/layers/public/server/pipelines/index.ts +23 -0
- package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +14 -0
- package/layers/public/server/plugins/01.register-public-pipelines.ts +7 -0
- package/layers/public/server/plugins/02.register-links.ts +2 -3
- package/layers/public/server/plugins/03.redirects.ts +41 -23
- package/layers/public/server/plugins/05.plan-publish.ts +5 -0
- package/layers/public/server/plugins/zz.publish.ts +79 -24
- package/layers/public/server/routes/llms-full.txt.get.ts +5 -5
- package/layers/public/server/routes/llms.txt.get.ts +4 -2
- package/layers/public/server/routes/redirects.json.get.ts +2 -2
- package/layers/public/server/routes/robots.txt.get.ts +2 -0
- package/layers/public/server/routes/sitemap.xml.get.ts +16 -6
- package/layers/public/server/tasks/publish/run.ts +9 -4
- package/layers/public/server/utils/publish/render-live.ts +26 -0
- package/layers/ui/app/components/field/Choice.vue +1 -1
- package/layers/ui/app/components/field/Datetime.vue +1 -1
- package/layers/ui/app/components/field/Json.vue +1 -2
- package/layers/ui/app/components/field/Layout.vue +2 -4
- package/layers/ui/app/components/field/Link.vue +1 -1
- package/layers/ui/app/components/field/LinkInternalPicker.vue +3 -3
- package/layers/ui/app/components/field/Number.vue +2 -2
- package/layers/ui/app/components/field/Relation.vue +1 -1
- package/layers/ui/app/components/field/Repeater.vue +1 -3
- package/layers/ui/app/components/field/Slug.vue +2 -3
- package/layers/ui/app/components/field/Text.vue +1 -1
- package/layers/ui/app/components/ui/Richtext.vue +1 -1
- package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
- package/layers/ui/app/composables/useLinkField.ts +1 -1
- package/layers/ui/app/composables/useRepeater.ts +1 -1
- package/layers/ui/app/i18n/de.ts +7 -7
- package/layers/ui/app/i18n/en.ts +7 -39
- package/layers/ui/app/utils/field-component.ts +1 -1
- package/layers/ui/app/utils/field-registry.ts +1 -1
- package/layers/ui/app/utils/icons.ts +2 -6
- package/layers/ui/app/utils/localized.ts +1 -1
- package/nuxt.config.ts +1 -1
- package/package.json +53 -19
- package/scripts/consumer-template-ci.mjs +160 -0
- package/scripts/coverage-all.mjs +163 -0
- package/scripts/dashboard.mjs +89 -0
- package/scripts/hash-password.mjs +1 -1
- package/scripts/lib/coverage-model.mjs +51 -0
- package/scripts/port-graph.mjs +350 -0
- package/scripts/typecheck.mjs +11 -0
- package/templates/starter/app/blocks/Prose.vue +1 -1
- package/layers/access/server/utils/csrf.ts +0 -39
- package/layers/access/server/utils/grant-registry.ts +0 -36
- package/layers/access/server/utils/guard.ts +0 -44
- package/layers/access/server/utils/ip-allowlist.ts +0 -79
- package/layers/access/server/utils/policy.ts +0 -98
- package/layers/access/server/utils/public-resources.ts +0 -11
- package/layers/access/server/utils/render-context.ts +0 -41
- package/layers/access/server/utils/require-admin.ts +0 -15
- package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
- package/layers/auth/server/api/auth/login.post.ts +0 -38
- package/layers/auth/server/api/auth/logout.post.ts +0 -9
- package/layers/auth/server/api/auth/session.get.ts +0 -1
- package/layers/auth/server/utils/admin-credential.ts +0 -42
- package/layers/auth/server/utils/client-ip.ts +0 -88
- package/layers/auth/server/utils/login-throttle.ts +0 -126
- package/layers/auth/server/utils/password.ts +0 -52
- package/layers/auth/server/utils/session-cookie.ts +0 -44
- package/layers/auth/server/utils/session-epoch.ts +0 -96
- package/layers/auth/server/utils/session.ts +0 -88
- package/layers/collections/server/collections/pages.ts +0 -19
- package/layers/collections/server/utils/populate-relations.ts +0 -79
- package/layers/core/app/utils/bulk-actions.ts +0 -9
- package/layers/core/app/utils/filter-ops.ts +0 -98
- package/layers/core/app/utils/list-limits.ts +0 -27
- package/layers/core/app/utils/locale-path.ts +0 -12
- package/layers/core/app/utils/slugify.ts +0 -16
- package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
- package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
- package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
- package/layers/core/server/api/[collection]/[id].get.ts +0 -11
- package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
- package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
- package/layers/core/server/api/[collection]/index.get.ts +0 -24
- package/layers/core/server/api/[collection]/index.post.ts +0 -8
- package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
- package/layers/core/server/api/[collection]/index.put.ts +0 -24
- package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
- package/layers/core/server/api/[collection]/options.get.ts +0 -18
- package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
- package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
- package/layers/core/server/api/[collection]/translations.get.ts +0 -25
- package/layers/core/server/api/collections/[name].get.ts +0 -4
- package/layers/core/server/api/collections/index.get.ts +0 -3
- package/layers/core/server/api/references/broken.get.ts +0 -9
- package/layers/core/server/api/references/referrers.get.ts +0 -24
- package/layers/core/server/database/record-refs.ts +0 -20
- package/layers/core/server/plugins/03.record-refs.ts +0 -12
- package/layers/core/server/schema/bootstrap.ts +0 -41
- package/layers/core/server/schema/desired.ts +0 -99
- package/layers/core/server/schema/dev-sync.ts +0 -23
- package/layers/core/server/schema/dialect.ts +0 -61
- package/layers/core/server/schema/diff.ts +0 -110
- package/layers/core/server/schema/introspect.ts +0 -68
- package/layers/core/server/schema/model.ts +0 -61
- package/layers/core/server/schema/render-sqlite.ts +0 -89
- package/layers/core/server/schema/sync.ts +0 -201
- package/layers/core/server/utils/blocks.ts +0 -6
- package/layers/core/server/utils/collection-types.ts +0 -28
- package/layers/core/server/utils/crud.ts +0 -578
- package/layers/core/server/utils/db.ts +0 -26
- package/layers/core/server/utils/defineCollection.ts +0 -213
- package/layers/core/server/utils/duplicate.ts +0 -162
- package/layers/core/server/utils/field-layout.ts +0 -125
- package/layers/core/server/utils/filter-predicate.ts +0 -101
- package/layers/core/server/utils/http.ts +0 -50
- package/layers/core/server/utils/if-unmodified.ts +0 -13
- package/layers/core/server/utils/kestrel-config.ts +0 -451
- package/layers/core/server/utils/key-lock.ts +0 -33
- package/layers/core/server/utils/locale.ts +0 -53
- package/layers/core/server/utils/page-route.ts +0 -58
- package/layers/core/server/utils/page-slug.ts +0 -127
- package/layers/core/server/utils/picker.ts +0 -82
- package/layers/core/server/utils/populate.ts +0 -50
- package/layers/core/server/utils/prerender-memo.ts +0 -23
- package/layers/core/server/utils/read-capture.ts +0 -37
- package/layers/core/server/utils/record-ref-index.ts +0 -200
- package/layers/core/server/utils/registry.ts +0 -24
- package/layers/core/server/utils/resolve-scope.ts +0 -79
- package/layers/core/server/utils/seo.ts +0 -42
- package/layers/core/server/utils/serialize-collection.ts +0 -147
- package/layers/core/server/utils/sql.ts +0 -2
- package/layers/core/server/utils/storage.local.ts +0 -117
- package/layers/core/server/utils/storage.s3.ts +0 -197
- package/layers/core/server/utils/storage.ts +0 -90
- package/layers/core/server/utils/write-effects.ts +0 -40
- package/layers/core/server/utils/write-events.ts +0 -31
- package/layers/fields/app/utils/condition.ts +0 -71
- package/layers/fields/app/utils/define-block.ts +0 -13
- package/layers/fields/app/utils/field-constraints.ts +0 -17
- package/layers/fields/app/utils/field-meta.ts +0 -33
- package/layers/fields/app/utils/field-validate.ts +0 -75
- package/layers/fields/app/utils/field-value.ts +0 -7
- package/layers/fields/app/utils/richtext-links.ts +0 -48
- package/layers/fields/server/api/blocks/index.get.ts +0 -9
- package/layers/fields/server/field-registry/index.ts +0 -249
- package/layers/fields/server/field-registry/naming.ts +0 -10
- package/layers/fields/server/field-registry/sanitize.ts +0 -43
- package/layers/fields/server/field-registry/types.ts +0 -19
- package/layers/fields/server/utils/block-ids.ts +0 -43
- package/layers/fields/server/utils/block-populate.ts +0 -41
- package/layers/fields/server/utils/buildCollection.ts +0 -90
- package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
- package/layers/fields/server/utils/buildTable.ts +0 -113
- package/layers/fields/server/utils/conditional-required.ts +0 -25
- package/layers/fields/server/utils/defineBlock.ts +0 -86
- package/layers/fields/server/utils/defineFieldType.ts +0 -33
- package/layers/fields/server/utils/extract-refs.ts +0 -152
- package/layers/fields/server/utils/field-populate.ts +0 -68
- package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
- package/layers/media/server/api/media/[id].delete.ts +0 -18
- package/layers/media/server/api/media/[id].get.ts +0 -10
- package/layers/media/server/api/media/[id].patch.test.ts +0 -66
- package/layers/media/server/api/media/[id].patch.ts +0 -65
- package/layers/media/server/api/media/backfill.post.ts +0 -18
- package/layers/media/server/api/media/copy.post.ts +0 -7
- package/layers/media/server/api/media/delete.post.ts +0 -13
- package/layers/media/server/api/media/folders.post.ts +0 -18
- package/layers/media/server/api/media/index.get.ts +0 -15
- package/layers/media/server/api/media/index.post.ts +0 -173
- package/layers/media/server/api/media/library.get.ts +0 -17
- package/layers/media/server/api/media/move.post.ts +0 -7
- package/layers/media/server/api/media/rename.post.ts +0 -7
- package/layers/media/server/api/media/resolve.get.ts +0 -13
- package/layers/media/server/collections/media-settings.ts +0 -25
- package/layers/media/server/collections/media.ts +0 -43
- package/layers/media/server/database/folders.ts +0 -10
- package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
- package/layers/media/server/schema-tables/folders.ts +0 -4
- package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
- package/layers/media/server/utils/ai-signal-detect.ts +0 -155
- package/layers/media/server/utils/backfill.ts +0 -144
- package/layers/media/server/utils/derive.ts +0 -59
- package/layers/media/server/utils/folder-paths.ts +0 -42
- package/layers/media/server/utils/folders.ts +0 -12
- package/layers/media/server/utils/library.ts +0 -91
- package/layers/media/server/utils/media-cleanup.ts +0 -27
- package/layers/media/server/utils/media-enabled.ts +0 -23
- package/layers/media/server/utils/media-ops.ts +0 -118
- package/layers/media/server/utils/media-write.ts +0 -13
- package/layers/media/server/utils/naming.ts +0 -52
- package/layers/media/server/utils/ondemand.ts +0 -69
- package/layers/media/server/utils/persist-upload.ts +0 -62
- package/layers/media/server/utils/populate.ts +0 -47
- package/layers/media/server/utils/record.ts +0 -71
- package/layers/media/server/utils/relocate-ops.ts +0 -341
- package/layers/media/server/utils/relocate-plan.ts +0 -63
- package/layers/media/server/utils/resolve.ts +0 -98
- package/layers/media/server/utils/sanitize-svg.ts +0 -12
- package/layers/media/server/utils/sniff.ts +0 -68
- package/layers/media/server/utils/storage-relocate.ts +0 -139
- package/layers/media/server/utils/translations.ts +0 -19
- package/layers/media/server/utils/usages.ts +0 -66
- package/layers/media/server/utils/variant-capture.ts +0 -70
- package/layers/media/server/utils/variants.ts +0 -123
- package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
- package/layers/public/server/api/links/resolve.get.ts +0 -19
- package/layers/public/server/api/preview.get.ts +0 -28
- package/layers/public/server/api/preview.post.ts +0 -93
- package/layers/public/server/api/publish-status.get.ts +0 -58
- package/layers/public/server/api/publish.post.ts +0 -84
- package/layers/public/server/api/route.get.ts +0 -54
- package/layers/public/server/collections/redirects.ts +0 -75
- package/layers/public/server/collections/site.ts +0 -44
- package/layers/public/server/database/publish-deps.ts +0 -12
- package/layers/public/server/database/publish-status.ts +0 -19
- package/layers/public/server/schema-tables/publish-deps.ts +0 -4
- package/layers/public/server/schema-tables/publish-status.ts +0 -4
- package/layers/public/server/utils/link-resolve.ts +0 -38
- package/layers/public/server/utils/llms-full.ts +0 -125
- package/layers/public/server/utils/llms.ts +0 -52
- package/layers/public/server/utils/page-resolve.ts +0 -208
- package/layers/public/server/utils/populate-links.ts +0 -66
- package/layers/public/server/utils/preview-token.ts +0 -109
- package/layers/public/server/utils/publish/coalesce.ts +0 -22
- package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
- package/layers/public/server/utils/publish/deps.ts +0 -87
- package/layers/public/server/utils/publish/invalidation.ts +0 -225
- package/layers/public/server/utils/publish/pending.ts +0 -74
- package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
- package/layers/public/server/utils/publish/publish-status.ts +0 -72
- package/layers/public/server/utils/publish/publisher.ts +0 -362
- package/layers/public/server/utils/publish/queue.ts +0 -67
- package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
- package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
- package/layers/public/server/utils/publish/route-for-record.ts +0 -16
- package/layers/public/server/utils/publish/route-keys.ts +0 -13
- package/layers/public/server/utils/richtext-markdown.ts +0 -260
- package/layers/public/server/utils/site-url.ts +0 -40
- package/layers/public/server/utils/sitemap.ts +0 -99
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import { eq, inArray, or, getTableColumns, sql } from 'drizzle-orm'
|
|
2
|
-
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
3
|
-
import { findMediaUsagesForMany, type MediaUsage } from './usages'
|
|
4
|
-
import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
|
|
5
|
-
import { media } from '../collections/media'
|
|
6
|
-
import { folders } from '../database/folders'
|
|
7
|
-
import { isUnder } from './folder-paths'
|
|
8
|
-
import type { DerivativeManifest } from './record'
|
|
9
|
-
import { emitMediaWrite } from './media-write'
|
|
10
|
-
import type { StorageDriver } from '../../../core/server/utils/storage'
|
|
11
|
-
import { escapeLike } from '../../../core/server/utils/sql'
|
|
12
|
-
|
|
13
|
-
export type OpItem = { type: 'file'; id: number } | { type: 'folder'; path: string }
|
|
14
|
-
|
|
15
|
-
export interface AffectedMedia {
|
|
16
|
-
id: number; storageKey: string; folder: string | null; filename: string
|
|
17
|
-
size: number; derivatives: DerivativeManifest | null
|
|
18
|
-
}
|
|
19
|
-
export interface AffectedSet { media: AffectedMedia[]; folders: string[] }
|
|
20
|
-
|
|
21
|
-
/** Resolve op items into the concrete affected media rows + folder paths. A `folder` item
|
|
22
|
-
* expands to itself + every descendant (media + folder rows). Folders are returned deepest-first
|
|
23
|
-
* (so a delete removes children before parents); media is deduped by id. */
|
|
24
|
-
export function collectAffected(db: BetterSQLite3Database, items: OpItem[]): AffectedSet {
|
|
25
|
-
const cols = getTableColumns(media) as Record<string, never>
|
|
26
|
-
const t = media as AnySQLiteTable
|
|
27
|
-
const mediaById = new Map<number, AffectedMedia>()
|
|
28
|
-
const folderSet = new Set<string>()
|
|
29
|
-
// Only a folder item ever consults the full folder list (to find its descendants) — an all-file list
|
|
30
|
-
// never touches it, so skip the read rather than materialize every row for nothing.
|
|
31
|
-
const allFolders = items.some((i) => i.type === 'folder')
|
|
32
|
-
? db.select({ path: folders.path }).from(folders).all().map((f) => f.path)
|
|
33
|
-
: []
|
|
34
|
-
|
|
35
|
-
const add = (rows: Record<string, unknown>[]) => {
|
|
36
|
-
for (const r of rows) mediaById.set(r.id as number, {
|
|
37
|
-
id: r.id as number, storageKey: r.storageKey as string, folder: (r.folder as string | null) ?? null,
|
|
38
|
-
filename: r.filename as string, size: r.size as number, derivatives: (r.derivatives as DerivativeManifest | null) ?? null,
|
|
39
|
-
})
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
for (const item of items) {
|
|
43
|
-
if (item.type === 'file') {
|
|
44
|
-
const row = db.select().from(t).where(eq(cols.id, item.id)).get() as Record<string, unknown> | undefined
|
|
45
|
-
if (row) add([row])
|
|
46
|
-
} else {
|
|
47
|
-
for (const fp of allFolders) if (isUnder(item.path, fp)) folderSet.add(fp)
|
|
48
|
-
folderSet.add(item.path)
|
|
49
|
-
const rows = db.select().from(t).where(
|
|
50
|
-
or(eq(cols.folder, item.path), sql`${cols.folder} like ${`${escapeLike(item.path)}/%`} escape '\\'`),
|
|
51
|
-
).all() as Record<string, unknown>[]
|
|
52
|
-
add(rows)
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const folderList = [...folderSet].sort((a, b) => b.split('/').length - a.split('/').length)
|
|
57
|
-
return { media: [...mediaById.values()], folders: folderList }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Reverse-usage lookup for many media ids at once: id -> the records referencing it. */
|
|
61
|
-
export function bulkUsages(db: BetterSQLite3Database, ids: number[]): Record<number, MediaUsage[]> {
|
|
62
|
-
return findMediaUsagesForMany(db, ids)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export interface DeleteReport {
|
|
66
|
-
summary: { files: number; folders: number; totalBytes: number }
|
|
67
|
-
usages?: Record<number, MediaUsage[]>
|
|
68
|
-
/** storageKeys whose blob delete failed — the row is already gone (rows commit before storage), so a
|
|
69
|
-
* failure here is an orphaned object, not a retryable half-delete; surfaced for the caller to log/alert. */
|
|
70
|
-
failedKeys?: string[]
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Two-wave delete. dryRun → report impact + per-media usages, no mutation. Otherwise → delete
|
|
74
|
-
* all affected media rows + folder rows in one transaction, then delete the storage objects
|
|
75
|
-
* (original + derivatives). Folder cascade included. */
|
|
76
|
-
export async function deleteAffected(
|
|
77
|
-
db: BetterSQLite3Database, driver: StorageDriver, items: OpItem[], dryRun: boolean,
|
|
78
|
-
): Promise<DeleteReport> {
|
|
79
|
-
const affected = collectAffected(db, items)
|
|
80
|
-
const summary = {
|
|
81
|
-
files: affected.media.length,
|
|
82
|
-
folders: affected.folders.length,
|
|
83
|
-
totalBytes: affected.media.reduce((n, m) => n + (m.size || 0), 0),
|
|
84
|
-
}
|
|
85
|
-
if (dryRun) return { summary, usages: bulkUsages(db, affected.media.map((m) => m.id)) }
|
|
86
|
-
|
|
87
|
-
const cols = getTableColumns(media) as Record<string, never>
|
|
88
|
-
const mediaIds = affected.media.map((m) => m.id)
|
|
89
|
-
db.transaction((tx) => {
|
|
90
|
-
if (mediaIds.length) tx.delete(media).where(inArray(cols.id, mediaIds)).run()
|
|
91
|
-
if (affected.folders.length) tx.delete(folders).where(inArray(folders.path, affected.folders)).run()
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
// Best-effort per item: the rows are already committed gone, so a storage error here means an orphan
|
|
95
|
-
// object, not a rollback candidate — one bad key (a throttled/5xx S3 call) must not abort the loop and
|
|
96
|
-
// leave every LATER item's blobs undeleted too with no row left to find them again.
|
|
97
|
-
const failedKeys: string[] = []
|
|
98
|
-
for (const m of affected.media) {
|
|
99
|
-
try {
|
|
100
|
-
await driver.delete(m.storageKey)
|
|
101
|
-
for (const d of Object.values(m.derivatives ?? {})) await driver.delete(d.key)
|
|
102
|
-
} catch (error) {
|
|
103
|
-
console.error(`[kestrel] media storage delete failed for ${m.storageKey}:`, (error as Error)?.message ?? error)
|
|
104
|
-
failedKeys.push(m.storageKey)
|
|
105
|
-
}
|
|
106
|
-
emitMediaWrite({ id: m.id }, null) // deleted media → re-render listings that query it
|
|
107
|
-
}
|
|
108
|
-
// Clean up the folder dir ONLY when nothing unmanaged remains under it. removeDir is a recursive subtree
|
|
109
|
-
// wipe, so a blind call would destroy any non-media object living under the same path — e.g. an extension's
|
|
110
|
-
// encrypted blob namespace, or any consumer blob written via useStorageDriver — which no media row (and no
|
|
111
|
-
// dryRun count) accounts for. If anything is still there, leave the whole path alone.
|
|
112
|
-
for (const it of items) {
|
|
113
|
-
if (it.type !== 'folder') continue
|
|
114
|
-
const remaining = driver.listPrefix ? await driver.listPrefix(it.path) : ['guard']
|
|
115
|
-
if (remaining.length === 0) await driver.removeDir?.(it.path)
|
|
116
|
-
}
|
|
117
|
-
return failedKeys.length ? { summary, failedKeys } : { summary }
|
|
118
|
-
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { emitWrite } from '../../../core/server/utils/write-events'
|
|
2
|
-
import mediaCollection from '../collections/media'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Notify the publish runtime that a media row changed. The media-library write paths (relocate / duplicate
|
|
6
|
-
* / delete / alt-edit) bypass core CRUD, so — unlike a normal content write — they emit no write event on
|
|
7
|
-
* their own. Without this, a page that embeds the media (dep-tagged `media:<id>`) is never re-rendered, so
|
|
8
|
-
* the static output keeps the old (now 404) URL or stale alt text. `before`/`after` need only carry `id`
|
|
9
|
-
* for the media classification (non-pageLike, no status column).
|
|
10
|
-
*/
|
|
11
|
-
export function emitMediaWrite(before: Record<string, unknown> | null, after: Record<string, unknown> | null): void {
|
|
12
|
-
emitWrite(mediaCollection.def, before, after)
|
|
13
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
export function sanitizeFilename(name: string): string {
|
|
2
|
-
const base = name.split(/[/\\]/).pop() ?? ''
|
|
3
|
-
const cleaned = base.replace(/ /g, '_').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/_+/g, '_').slice(0, 120)
|
|
4
|
-
if (cleaned === '' || cleaned === '.' || cleaned === '..') return 'file'
|
|
5
|
-
return cleaned
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
/** The last extension of a filename (no leading dot), or '' when there is none. */
|
|
9
|
-
export function extensionOf(name: string): string {
|
|
10
|
-
const dot = name.lastIndexOf('.')
|
|
11
|
-
return dot > 0 ? name.slice(dot + 1) : ''
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** Sanitize `name` and force its extension to `ext` (drops any client-supplied extension).
|
|
15
|
-
* The stored extension must follow the authenticated content type, never the upload filename. */
|
|
16
|
-
export function withExtension(name: string, ext: string): string {
|
|
17
|
-
const cleaned = sanitizeFilename(name)
|
|
18
|
-
const dot = cleaned.lastIndexOf('.')
|
|
19
|
-
const stem = dot > 0 ? cleaned.slice(0, dot) : cleaned
|
|
20
|
-
return ext ? `${stem}.${ext}` : stem
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Safe relative folder: drops `.`/`..` segments (no traversal) and cleans each segment.
|
|
24
|
-
* Deliberately NOT length-capped: truncating would silently map two distinct folders onto one
|
|
25
|
-
* storage path. An unusable path is caught where it is created — folders.post.ts runs `ensureDir`
|
|
26
|
-
* before committing the row, so an over-long name fails there instead of stranding a row. */
|
|
27
|
-
export function sanitizeFolder(folder: string): string {
|
|
28
|
-
return folder
|
|
29
|
-
.split(/[/\\]+/)
|
|
30
|
-
.map((seg) => seg.trim())
|
|
31
|
-
.filter((seg) => seg && seg !== '.' && seg !== '..')
|
|
32
|
-
.map((seg) => seg.replace(/ /g, '_').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/_+/g, '_'))
|
|
33
|
-
.filter((seg) => seg.length > 0)
|
|
34
|
-
.join('/')
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function buildKey(folder: string, filename: string): string {
|
|
38
|
-
const f = sanitizeFolder(folder)
|
|
39
|
-
return f ? `${f}/${filename}` : filename
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function suggestFreeName(filename: string, isFree: (name: string) => boolean): string {
|
|
43
|
-
if (isFree(filename)) return filename
|
|
44
|
-
const dot = filename.lastIndexOf('.')
|
|
45
|
-
const stem = dot > 0 ? filename.slice(0, dot) : filename
|
|
46
|
-
const ext = dot > 0 ? filename.slice(dot) : ''
|
|
47
|
-
for (let i = 2; i < 10000; i++) {
|
|
48
|
-
const candidate = `${stem}-${i}${ext}`
|
|
49
|
-
if (isFree(candidate)) return candidate
|
|
50
|
-
}
|
|
51
|
-
throw new Error('no free filename found')
|
|
52
|
-
}
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { inArray, getTableColumns } from 'drizzle-orm'
|
|
2
|
-
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
|
3
|
-
import type { ResolvedImagePolicy, VariantFit } from '../../../core/server/utils/kestrel-config'
|
|
4
|
-
import type { StorageDriver } from '../../../core/server/utils/storage'
|
|
5
|
-
import { media } from '../collections/media'
|
|
6
|
-
import { deriveImage, RASTER } from './derive'
|
|
7
|
-
|
|
8
|
-
const FITS = new Set<VariantFit>(['cover', 'contain', 'inside', 'outside', 'fill'])
|
|
9
|
-
|
|
10
|
-
/** The storage key a request addresses, or null when the request is not for media at all: outside the
|
|
11
|
-
* configured base URL, bare, or carrying a malformed %-escape (which must degrade to a static 404, never
|
|
12
|
-
* a 500). `baseUrl` is the media base as resolved config holds it — its default is the same `/uploads`. */
|
|
13
|
-
export function variantKeyFromPath(path: string, baseUrl: string | undefined): string | null {
|
|
14
|
-
const base = (baseUrl || '/uploads').replace(/\/+$/, '')
|
|
15
|
-
const clean = path.split('?')[0]!
|
|
16
|
-
if (!clean.startsWith(`${base}/`)) return null
|
|
17
|
-
try {
|
|
18
|
-
return decodeURIComponent(clean.slice(base.length + 1)) || null
|
|
19
|
-
} catch {
|
|
20
|
-
return null
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Inverse of variantName: `w<width>` | `c<w>x<h>[-<fit>]` → the shape to derive, or null for anything else. */
|
|
25
|
-
export function parseVariantName(name: string): { width: number; height: number | null; fit: VariantFit } | null {
|
|
26
|
-
const w = /^w(\d+)$/.exec(name)
|
|
27
|
-
if (w) return { width: +w[1], height: null, fit: 'cover' }
|
|
28
|
-
const c = /^c(\d+)x(\d+)(?:-([a-z]+))?$/.exec(name)
|
|
29
|
-
if (c) {
|
|
30
|
-
const fit = (c[3] ?? 'cover') as VariantFit
|
|
31
|
-
return FITS.has(fit) ? { width: +c[1], height: +c[2], fit } : null
|
|
32
|
-
}
|
|
33
|
-
return null
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface VariantRequest { storageKey: string; mime: string; name: string; format: string }
|
|
37
|
-
|
|
38
|
-
// requestedKey is `<storageKey>-<name>.<format>`; storageKey contains dots and a name may contain '-', so
|
|
39
|
-
// resolve the original by trying every '-' split and matching a real media row (longest storageKey wins).
|
|
40
|
-
export function resolveVariantRequest(db: BetterSQLite3Database, requestedKey: string): VariantRequest | null {
|
|
41
|
-
const dot = requestedKey.lastIndexOf('.')
|
|
42
|
-
if (dot < 0) return null
|
|
43
|
-
const format = requestedKey.slice(dot + 1)
|
|
44
|
-
const stem = requestedKey.slice(0, dot)
|
|
45
|
-
const candidates: string[] = []
|
|
46
|
-
for (let i = 1; i < stem.length; i++) if (stem[i] === '-') candidates.push(stem.slice(0, i))
|
|
47
|
-
if (!candidates.length) return null
|
|
48
|
-
const cols = getTableColumns(media) as Record<string, never>
|
|
49
|
-
const rows = db.select({ storageKey: cols.storageKey, mime: cols.mime }).from(media).where(inArray(cols.storageKey, candidates)).all() as { storageKey: string; mime: string }[]
|
|
50
|
-
const match = rows.map((r) => ({ ...r, name: stem.slice(r.storageKey.length + 1), format })).sort((a, b) => b.storageKey.length - a.storageKey.length)[0]
|
|
51
|
-
return match ?? null
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const MEDIA_CACHE_CONTROL = 'public, max-age=31536000'
|
|
55
|
-
|
|
56
|
-
// DEV-only: derive one missing variant from the stored original and cache it. Registry-INDEPENDENT (parses
|
|
57
|
-
// the name), so a size/format a KestrelImg declares but the scan hasn't registered yet still shows in the
|
|
58
|
-
// editor preview without a full publish.
|
|
59
|
-
export async function deriveOnDemand(db: BetterSQLite3Database, driver: StorageDriver, policy: ResolvedImagePolicy, requestedKey: string): Promise<{ bytes: Buffer; mime: string } | null> {
|
|
60
|
-
const req = resolveVariantRequest(db, requestedKey)
|
|
61
|
-
if (!req || !RASTER.has(req.mime) || (req.format !== 'webp' && req.format !== 'jpeg')) return null
|
|
62
|
-
const shape = parseVariantName(req.name)
|
|
63
|
-
if (!shape || typeof driver.get !== 'function') return null
|
|
64
|
-
const spec = { name: req.name, width: shape.width, height: shape.height, fit: shape.fit, position: 'centre', formats: [req.format] as ('webp' | 'jpeg')[] }
|
|
65
|
-
const v = (await deriveImage(await driver.get(req.storageKey), { ...policy, variants: [spec] })).variants[0]
|
|
66
|
-
if (!v) return null
|
|
67
|
-
await driver.put(requestedKey, v.bytes, v.mime, { cacheControl: MEDIA_CACHE_CONTROL })
|
|
68
|
-
return { bytes: v.bytes, mime: v.mime }
|
|
69
|
-
}
|
|
@@ -1,62 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,47 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,71 +0,0 @@
|
|
|
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
|
-
/** EU AI Act disclosure to write. Omitted/null ⇒ the column is left out of the values entirely, which is
|
|
18
|
-
* what keeps an overwrite from wiping a disclosure the re-upload did not re-send. */
|
|
19
|
-
aiSourceType?: string | null
|
|
20
|
-
aiNote?: string | null
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Object key for a resized/reformatted variant of an original. The stem is the FULL original key
|
|
25
|
-
* (extension included), so two same-stem uploads that differ only in extension — `logo.png` and
|
|
26
|
-
* `logo.jpg`, a designer exporting both formats — never derive the same key and overwrite/cross-delete
|
|
27
|
-
* each other's variants. `storageKey` is unique, so this stays injective per original.
|
|
28
|
-
*/
|
|
29
|
-
export function derivativeKey(originalKey: string, name: string, format: string): string {
|
|
30
|
-
return `${originalKey}-${name}.${format}`
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function buildMediaValues(input: MediaInput): Record<string, unknown> {
|
|
34
|
-
const manifest: DerivativeManifest = {}
|
|
35
|
-
const src = input.derived
|
|
36
|
-
for (const v of src?.variants ?? []) {
|
|
37
|
-
// Name-keyed (`<name>.<format>`); height is the variant's REAL output dim (crops break the aspect ratio).
|
|
38
|
-
manifest[`${v.name}.${v.format}`] = { key: derivativeKey(input.storageKey, v.name, v.format), width: v.width, height: v.height, mime: v.mime }
|
|
39
|
-
}
|
|
40
|
-
return {
|
|
41
|
-
storageKey: input.storageKey,
|
|
42
|
-
folder: input.folder || null,
|
|
43
|
-
filename: input.filename,
|
|
44
|
-
mime: input.mime,
|
|
45
|
-
ext: input.ext,
|
|
46
|
-
size: input.size,
|
|
47
|
-
width: input.derived?.width ?? null,
|
|
48
|
-
height: input.derived?.height ?? null,
|
|
49
|
-
checksum: input.checksum,
|
|
50
|
-
thumbhash: input.derived?.thumbhash ?? null,
|
|
51
|
-
derivatives: manifest,
|
|
52
|
-
translations: input.translations ?? {},
|
|
53
|
-
// Written only when there is something to write: the overwrite path feeds these same values to an
|
|
54
|
-
// UPDATE, and a null here would silently clear a disclosure an editor set on the existing row.
|
|
55
|
-
...(input.aiSourceType != null ? { aiSourceType: input.aiSourceType } : {}),
|
|
56
|
-
...(input.aiNote != null ? { aiNote: input.aiNote } : {}),
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* On overwrite (re-upload of an existing storageKey) the multipart request rarely re-sends
|
|
62
|
-
* alt/title/description — the conflict-dialog overwrite path never does — so a bare re-upload must not
|
|
63
|
-
* wipe the per-locale metadata editors maintain via the viewer. Merge any incoming fields over the
|
|
64
|
-
* existing map (same semantics as the PATCH endpoint); an empty incoming map leaves the existing intact.
|
|
65
|
-
*/
|
|
66
|
-
export function withPreservedTranslations(
|
|
67
|
-
values: Record<string, unknown>,
|
|
68
|
-
existing: { translations?: Translations } | undefined,
|
|
69
|
-
): Record<string, unknown> {
|
|
70
|
-
return { ...values, translations: mergeTranslations(existing?.translations, (values.translations ?? {}) as Translations) }
|
|
71
|
-
}
|