@growth-labs/cms 0.3.1 → 0.5.0
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 +80 -6
- package/dist/engine/content-metadata.d.ts +13 -0
- package/dist/engine/content-metadata.d.ts.map +1 -0
- package/dist/engine/content-metadata.js +80 -0
- package/dist/engine/content-metadata.js.map +1 -0
- package/dist/engine/foundry-dispatch.d.ts +5 -1
- package/dist/engine/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +19 -3
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/index.d.ts +4 -2
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +6 -2
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/publication.d.ts +29 -1
- package/dist/engine/publication.d.ts.map +1 -1
- package/dist/engine/publication.js +197 -15
- package/dist/engine/publication.js.map +1 -1
- package/dist/engine/published-content.d.ts +40 -0
- package/dist/engine/published-content.d.ts.map +1 -0
- package/dist/engine/published-content.js +388 -0
- package/dist/engine/published-content.js.map +1 -0
- package/dist/engine/publisher.d.ts +20 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +264 -65
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/revisions.d.ts.map +1 -1
- package/dist/engine/revisions.js +2 -0
- package/dist/engine/revisions.js.map +1 -1
- package/dist/providers/types.d.ts +1 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +76 -16
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/context.d.ts +1 -0
- package/dist/routes/context.d.ts.map +1 -1
- package/dist/routes/context.js.map +1 -1
- package/dist/routes/media.d.ts.map +1 -1
- package/dist/routes/media.js +53 -31
- package/dist/routes/media.js.map +1 -1
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +24 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/types.d.ts +6 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js.map +1 -1
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +65 -10
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/dist/ui/editor/content-payload.d.ts +2 -0
- package/dist/ui/editor/content-payload.d.ts.map +1 -1
- package/dist/ui/editor/content-payload.js +15 -4
- package/dist/ui/editor/content-payload.js.map +1 -1
- package/dist/ui/editor/editor-media-duration.d.ts +24 -0
- package/dist/ui/editor/editor-media-duration.d.ts.map +1 -0
- package/dist/ui/editor/editor-media-duration.js +51 -0
- package/dist/ui/editor/editor-media-duration.js.map +1 -0
- package/dist/ui/screens/MediaScreen.d.ts.map +1 -1
- package/dist/ui/screens/MediaScreen.js +8 -1
- package/dist/ui/screens/MediaScreen.js.map +1 -1
- package/dist/ui/screens/media-upload.d.ts +9 -2
- package/dist/ui/screens/media-upload.d.ts.map +1 -1
- package/dist/ui/screens/media-upload.js +24 -0
- package/dist/ui/screens/media-upload.js.map +1 -1
- package/migrations/0020_content_metadata_read_model.sql +19 -0
- package/migrations/0021_published_revision_slug_index.sql +5 -0
- package/package.json +1 -1
- package/src/engine/content-metadata.ts +122 -0
- package/src/engine/foundry-dispatch.ts +32 -4
- package/src/engine/index.ts +27 -0
- package/src/engine/publication.ts +329 -18
- package/src/engine/published-content.ts +581 -0
- package/src/engine/publisher.ts +403 -107
- package/src/engine/revisions.ts +4 -0
- package/src/providers/types.ts +1 -0
- package/src/routes/content.ts +91 -16
- package/src/routes/context.ts +1 -0
- package/src/routes/media.ts +63 -41
- package/src/schema/migrations.ts +24 -0
- package/src/schema/types.ts +6 -0
- package/src/ui/editor/ContentForm.tsx +121 -4
- package/src/ui/editor/content-payload.ts +20 -2
- package/src/ui/editor/editor-media-duration.ts +75 -0
- package/src/ui/screens/MediaScreen.tsx +13 -2
- package/src/ui/screens/media-upload.ts +41 -2
package/src/engine/publisher.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// duplicate is NOT re-queued to Foundry. Any future `content_items` column
|
|
21
21
|
// addition requires a matching edit here or the duplicate silently drops it.
|
|
22
22
|
import type { ContentType } from '../schema/types.js'
|
|
23
|
+
import { type ContentMetadata, serializeContentMetadata } from './content-metadata.js'
|
|
23
24
|
import type { D1Database } from './d1.js'
|
|
24
25
|
import { evaluateArticleBody, type PublishGuardOutcome } from './publish-guard.js'
|
|
25
26
|
import { slugify } from './slug.js'
|
|
@@ -95,6 +96,8 @@ export interface BaseContentInput {
|
|
|
95
96
|
tags?: string[]
|
|
96
97
|
/** The SEO focus keyword for this content item (migration 0004 column). */
|
|
97
98
|
seoFocusKeyword?: string | null
|
|
99
|
+
/** Declared site-specific JSON values retained in every immutable revision. */
|
|
100
|
+
metadata?: ContentMetadata
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
export interface ArticleInput extends BaseContentInput {
|
|
@@ -154,12 +157,23 @@ export interface PageInput extends BaseContentInput {
|
|
|
154
157
|
}
|
|
155
158
|
}
|
|
156
159
|
|
|
157
|
-
export type CreateContentInput =
|
|
160
|
+
export type CreateContentInput = (
|
|
158
161
|
| ArticleInput
|
|
159
162
|
| VideoInput
|
|
160
163
|
| PodcastInput
|
|
161
164
|
| NewsletterInput
|
|
162
165
|
| PageInput
|
|
166
|
+
) & {
|
|
167
|
+
/** Optional namespaced archive key. New copies deliberately do not inherit it. */
|
|
168
|
+
sourceId?: string | null
|
|
169
|
+
/** Initial historical values for archive ingestion; normal creates omit this. */
|
|
170
|
+
initialTimestamps?: {
|
|
171
|
+
publishedAt?: number | null
|
|
172
|
+
createdAt?: number | null
|
|
173
|
+
updatedAt?: number | null
|
|
174
|
+
publishTz?: string | null
|
|
175
|
+
}
|
|
176
|
+
}
|
|
163
177
|
|
|
164
178
|
type BodyBackedContentInput = ArticleInput | NewsletterInput | PageInput
|
|
165
179
|
|
|
@@ -167,6 +181,118 @@ function isBodyBackedContentInput(input: CreateContentInput): input is BodyBacke
|
|
|
167
181
|
return isBodyBackedContentType(input.type)
|
|
168
182
|
}
|
|
169
183
|
|
|
184
|
+
function assertValidMediaDuration(value: unknown): asserts value is number | null | undefined {
|
|
185
|
+
if (value === null || value === undefined) return
|
|
186
|
+
if (
|
|
187
|
+
typeof value !== 'number' ||
|
|
188
|
+
!Number.isFinite(value) ||
|
|
189
|
+
!Number.isInteger(value) ||
|
|
190
|
+
value <= 0
|
|
191
|
+
) {
|
|
192
|
+
throw new Error('Media duration must be a positive integer when supplied')
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export class VerifiedMediaDurationConflictError extends Error {
|
|
197
|
+
readonly code = 'verified_duration_conflict'
|
|
198
|
+
|
|
199
|
+
constructor() {
|
|
200
|
+
super('Media duration conflicts with verified upload duration')
|
|
201
|
+
this.name = 'VerifiedMediaDurationConflictError'
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
type VerifiedMediaKind = 'video' | 'podcast'
|
|
206
|
+
|
|
207
|
+
function parseVerifiedMediaDuration(variantsJson: string | null): number | null {
|
|
208
|
+
if (!variantsJson) return null
|
|
209
|
+
try {
|
|
210
|
+
const parsed: unknown = JSON.parse(variantsJson)
|
|
211
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
212
|
+
const durationSeconds = (parsed as Record<string, unknown>).durationSeconds
|
|
213
|
+
return typeof durationSeconds === 'number' &&
|
|
214
|
+
Number.isFinite(durationSeconds) &&
|
|
215
|
+
Number.isInteger(durationSeconds) &&
|
|
216
|
+
durationSeconds > 0
|
|
217
|
+
? durationSeconds
|
|
218
|
+
: null
|
|
219
|
+
} catch {
|
|
220
|
+
return null
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function loadVerifiedMediaDuration(
|
|
225
|
+
db: D1Database,
|
|
226
|
+
kind: VerifiedMediaKind,
|
|
227
|
+
source: string | null | undefined,
|
|
228
|
+
): Promise<number | null> {
|
|
229
|
+
if (!source) return null
|
|
230
|
+
|
|
231
|
+
const isHttpUrl = /^https?:\/\//i.test(source)
|
|
232
|
+
const statement =
|
|
233
|
+
kind === 'video'
|
|
234
|
+
? isHttpUrl
|
|
235
|
+
? db
|
|
236
|
+
.prepare(
|
|
237
|
+
`SELECT variants_json
|
|
238
|
+
FROM media_assets
|
|
239
|
+
WHERE asset_type = 'video'
|
|
240
|
+
AND source_type = 'editor-video-source'
|
|
241
|
+
AND (url = ? OR public_url = ?)`,
|
|
242
|
+
)
|
|
243
|
+
.bind(source, source)
|
|
244
|
+
: db
|
|
245
|
+
.prepare(
|
|
246
|
+
`SELECT variants_json
|
|
247
|
+
FROM media_assets
|
|
248
|
+
WHERE asset_type = 'video'
|
|
249
|
+
AND source_type = 'editor-video-source'
|
|
250
|
+
AND (id = ? OR object_key = ?)`,
|
|
251
|
+
)
|
|
252
|
+
.bind(source, source)
|
|
253
|
+
: db
|
|
254
|
+
.prepare(
|
|
255
|
+
`SELECT variants_json
|
|
256
|
+
FROM media_assets
|
|
257
|
+
WHERE asset_type = 'audio'
|
|
258
|
+
AND source_type = 'editor-upload'
|
|
259
|
+
AND (id = ? OR object_key = ?)`,
|
|
260
|
+
)
|
|
261
|
+
.bind(source, source)
|
|
262
|
+
|
|
263
|
+
const rows = await statement.all<{ variants_json: string | null }>()
|
|
264
|
+
const durations = new Set<number>()
|
|
265
|
+
for (const row of rows.results ?? []) {
|
|
266
|
+
const duration = parseVerifiedMediaDuration(row.variants_json)
|
|
267
|
+
if (duration !== null) durations.add(duration)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (durations.size > 1) {
|
|
271
|
+
// Multiple exact package-owned identities disagreeing is unsafe to guess.
|
|
272
|
+
throw new VerifiedMediaDurationConflictError()
|
|
273
|
+
}
|
|
274
|
+
return durations.values().next().value ?? null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function resolveMediaDuration(
|
|
278
|
+
db: D1Database,
|
|
279
|
+
kind: VerifiedMediaKind,
|
|
280
|
+
source: string | null | undefined,
|
|
281
|
+
suppliedDuration: unknown,
|
|
282
|
+
): Promise<number | null> {
|
|
283
|
+
assertValidMediaDuration(suppliedDuration)
|
|
284
|
+
const verifiedDuration = await loadVerifiedMediaDuration(db, kind, source)
|
|
285
|
+
if (verifiedDuration === null) return suppliedDuration ?? null
|
|
286
|
+
if (
|
|
287
|
+
suppliedDuration !== null &&
|
|
288
|
+
suppliedDuration !== undefined &&
|
|
289
|
+
suppliedDuration !== verifiedDuration
|
|
290
|
+
) {
|
|
291
|
+
throw new VerifiedMediaDurationConflictError()
|
|
292
|
+
}
|
|
293
|
+
return verifiedDuration
|
|
294
|
+
}
|
|
295
|
+
|
|
170
296
|
export interface UpdateContentInput extends Partial<BaseContentInput> {
|
|
171
297
|
content?: Partial<
|
|
172
298
|
| ArticleInput['content']
|
|
@@ -183,6 +309,23 @@ export interface UpdateContentInput extends Partial<BaseContentInput> {
|
|
|
183
309
|
aiLockedFields?: string[]
|
|
184
310
|
}
|
|
185
311
|
|
|
312
|
+
interface VideoUpdatePlan {
|
|
313
|
+
script: string
|
|
314
|
+
videoId: string
|
|
315
|
+
durationSeconds: number | null
|
|
316
|
+
thumbnailImageId: string | null
|
|
317
|
+
sourceUrl: string | null
|
|
318
|
+
sourceKind: string | null
|
|
319
|
+
sourceChanged: boolean
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
interface PodcastUpdatePlan {
|
|
323
|
+
transcript: string
|
|
324
|
+
audioR2Key: string
|
|
325
|
+
durationSeconds: number | null
|
|
326
|
+
processingTriggerToken: string | null
|
|
327
|
+
}
|
|
328
|
+
|
|
186
329
|
/**
|
|
187
330
|
* The shape `getContentItem` returns from `SELECT * FROM content_items`.
|
|
188
331
|
* Note this is the engine's read-side row (nullable `featured`/timestamps as
|
|
@@ -215,10 +358,57 @@ export interface ContentItemRecord {
|
|
|
215
358
|
ai_locked_fields: string | null
|
|
216
359
|
published_revision_id: string | null
|
|
217
360
|
seo_focus_keyword: string | null
|
|
361
|
+
metadata_json: string
|
|
362
|
+
source_id: string | null
|
|
218
363
|
created_at: number | null
|
|
219
364
|
updated_at: number | null
|
|
220
365
|
}
|
|
221
366
|
|
|
367
|
+
function normalizeSourceId(value: unknown): string | null {
|
|
368
|
+
if (value === null || value === undefined) return null
|
|
369
|
+
if (typeof value !== 'string' || !value || value !== value.trim() || value.length > 256) {
|
|
370
|
+
throw new TypeError('sourceId must be a trimmed non-empty string of at most 256 characters')
|
|
371
|
+
}
|
|
372
|
+
return value
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function normalizeInitialTimestamps(value: CreateContentInput['initialTimestamps']): {
|
|
376
|
+
publishedAt: number | null
|
|
377
|
+
createdAt: number | null
|
|
378
|
+
updatedAt: number | null
|
|
379
|
+
publishTz: string | null
|
|
380
|
+
} {
|
|
381
|
+
const timestamps = value ?? {}
|
|
382
|
+
for (const [field, candidate] of [
|
|
383
|
+
['publishedAt', timestamps.publishedAt],
|
|
384
|
+
['createdAt', timestamps.createdAt],
|
|
385
|
+
['updatedAt', timestamps.updatedAt],
|
|
386
|
+
] as const) {
|
|
387
|
+
if (candidate !== null && candidate !== undefined) {
|
|
388
|
+
if (!Number.isSafeInteger(candidate) || candidate < 0) {
|
|
389
|
+
throw new TypeError(`${field} must be a non-negative epoch second`)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const publishTz = timestamps.publishTz
|
|
394
|
+
if (
|
|
395
|
+
publishTz !== null &&
|
|
396
|
+
publishTz !== undefined &&
|
|
397
|
+
(typeof publishTz !== 'string' ||
|
|
398
|
+
!publishTz.trim() ||
|
|
399
|
+
publishTz !== publishTz.trim() ||
|
|
400
|
+
publishTz.length > 64)
|
|
401
|
+
) {
|
|
402
|
+
throw new TypeError('publishTz must be a trimmed non-empty string of at most 64 characters')
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
publishedAt: timestamps.publishedAt ?? null,
|
|
406
|
+
createdAt: timestamps.createdAt ?? null,
|
|
407
|
+
updatedAt: timestamps.updatedAt ?? null,
|
|
408
|
+
publishTz: publishTz ?? null,
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
222
412
|
export async function ensureUniqueSlug(
|
|
223
413
|
db: D1Database,
|
|
224
414
|
slug: string,
|
|
@@ -229,12 +419,28 @@ export async function ensureUniqueSlug(
|
|
|
229
419
|
let suffix = 2
|
|
230
420
|
|
|
231
421
|
while (true) {
|
|
232
|
-
const
|
|
422
|
+
const draftRow = await db
|
|
233
423
|
.prepare('SELECT id FROM content_items WHERE slug = ? LIMIT 1')
|
|
234
424
|
.bind(candidate)
|
|
235
425
|
.first<{ id: string }>()
|
|
426
|
+
const publishedRow = await db
|
|
427
|
+
.prepare(
|
|
428
|
+
`SELECT ci.id
|
|
429
|
+
FROM content_items ci
|
|
430
|
+
JOIN content_revisions cr
|
|
431
|
+
ON cr.id = ci.published_revision_id AND cr.content_id = ci.id
|
|
432
|
+
WHERE ci.status = 'published'
|
|
433
|
+
AND ci.deleted_at IS NULL
|
|
434
|
+
AND json_extract(cr.payload_json, '$.item.slug') = ?
|
|
435
|
+
LIMIT 1`,
|
|
436
|
+
)
|
|
437
|
+
.bind(candidate)
|
|
438
|
+
.first<{ id: string }>()
|
|
236
439
|
|
|
237
|
-
|
|
440
|
+
const draftAvailable = !draftRow || (excludeId !== undefined && draftRow.id === excludeId)
|
|
441
|
+
const publishedAvailable =
|
|
442
|
+
!publishedRow || (excludeId !== undefined && publishedRow.id === excludeId)
|
|
443
|
+
if (draftAvailable && publishedAvailable) {
|
|
238
444
|
return candidate
|
|
239
445
|
}
|
|
240
446
|
|
|
@@ -339,10 +545,29 @@ export async function createContent(
|
|
|
339
545
|
db: D1Database,
|
|
340
546
|
input: CreateContentInput,
|
|
341
547
|
): Promise<{ id: string; slug: string }> {
|
|
548
|
+
const resolvedMediaDuration =
|
|
549
|
+
input.type === 'video'
|
|
550
|
+
? await resolveMediaDuration(
|
|
551
|
+
db,
|
|
552
|
+
'video',
|
|
553
|
+
input.content.processingSourceUrl,
|
|
554
|
+
input.content.durationSeconds,
|
|
555
|
+
)
|
|
556
|
+
: input.type === 'podcast'
|
|
557
|
+
? await resolveMediaDuration(
|
|
558
|
+
db,
|
|
559
|
+
'podcast',
|
|
560
|
+
input.content.audioR2Key,
|
|
561
|
+
input.content.durationSeconds,
|
|
562
|
+
)
|
|
563
|
+
: null
|
|
342
564
|
const id = crypto.randomUUID()
|
|
343
565
|
const slug = await ensureUniqueSlug(db, input.slug)
|
|
344
566
|
const visibility = input.visibility || 'free'
|
|
345
567
|
const primaryCategory = input.primaryCategory || 'analysis'
|
|
568
|
+
const metadataJson = serializeContentMetadata(input.metadata)
|
|
569
|
+
const sourceId = normalizeSourceId(input.sourceId)
|
|
570
|
+
const initialTimestamps = normalizeInitialTimestamps(input.initialTimestamps)
|
|
346
571
|
|
|
347
572
|
await db
|
|
348
573
|
.prepare(
|
|
@@ -350,8 +575,10 @@ export async function createContent(
|
|
|
350
575
|
INSERT INTO content_items (
|
|
351
576
|
id, type, status, visibility, slug, title, seo_title, description, excerpt,
|
|
352
577
|
byline, channel, primary_topic, featured, author_id, hero_image_id,
|
|
353
|
-
hero_image_alt, hero_image_caption, social_image_id, canonical_url
|
|
354
|
-
|
|
578
|
+
hero_image_alt, hero_image_caption, social_image_id, canonical_url,
|
|
579
|
+
metadata_json, source_id, published_at, created_at, updated_at, publish_tz
|
|
580
|
+
) VALUES (?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
581
|
+
COALESCE(?, unixepoch()), COALESCE(?, unixepoch()), COALESCE(?, 'Europe/Paris'))
|
|
355
582
|
`,
|
|
356
583
|
)
|
|
357
584
|
.bind(
|
|
@@ -373,6 +600,12 @@ export async function createContent(
|
|
|
373
600
|
input.heroImageCaption || null,
|
|
374
601
|
input.socialImageId || null,
|
|
375
602
|
input.canonicalUrl || null,
|
|
603
|
+
metadataJson,
|
|
604
|
+
sourceId,
|
|
605
|
+
initialTimestamps.publishedAt,
|
|
606
|
+
initialTimestamps.createdAt,
|
|
607
|
+
initialTimestamps.updatedAt,
|
|
608
|
+
initialTimestamps.publishTz,
|
|
376
609
|
)
|
|
377
610
|
.run()
|
|
378
611
|
|
|
@@ -414,7 +647,7 @@ export async function createContent(
|
|
|
414
647
|
id,
|
|
415
648
|
input.content.script,
|
|
416
649
|
input.content.videoId,
|
|
417
|
-
|
|
650
|
+
resolvedMediaDuration,
|
|
418
651
|
input.content.thumbnailImageId || null,
|
|
419
652
|
initialVideoProcessingState,
|
|
420
653
|
input.content.processingSourceUrl || null,
|
|
@@ -429,12 +662,7 @@ export async function createContent(
|
|
|
429
662
|
`INSERT INTO podcast_content (content_id, transcript, audio_r2_key, duration_seconds)
|
|
430
663
|
VALUES (?, ?, ?, ?)`,
|
|
431
664
|
)
|
|
432
|
-
.bind(
|
|
433
|
-
id,
|
|
434
|
-
input.content.transcript,
|
|
435
|
-
input.content.audioR2Key,
|
|
436
|
-
input.content.durationSeconds || null,
|
|
437
|
-
)
|
|
665
|
+
.bind(id, input.content.transcript, input.content.audioR2Key, resolvedMediaDuration)
|
|
438
666
|
.run()
|
|
439
667
|
}
|
|
440
668
|
|
|
@@ -464,6 +692,123 @@ export async function updateContentItem(
|
|
|
464
692
|
if (!existing) return null
|
|
465
693
|
|
|
466
694
|
const hasOwn = (source: object, key: string) => Object.hasOwn(source, key)
|
|
695
|
+
let videoUpdatePlan: VideoUpdatePlan | null | undefined
|
|
696
|
+
let podcastUpdatePlan: PodcastUpdatePlan | null | undefined
|
|
697
|
+
|
|
698
|
+
// Resolve all media identity/duration invariants before the base content row,
|
|
699
|
+
// tags, or relations can be mutated. D1 does not provide a transaction on the
|
|
700
|
+
// structural surface used here, so validation must be a strict preflight.
|
|
701
|
+
if (input.content && existing.type === 'video') {
|
|
702
|
+
const current = await db
|
|
703
|
+
.prepare(
|
|
704
|
+
`SELECT script, video_id, duration_seconds, thumbnail_image_id,
|
|
705
|
+
processing_source_url, processing_source_kind
|
|
706
|
+
FROM video_content
|
|
707
|
+
WHERE content_id = ?
|
|
708
|
+
LIMIT 1`,
|
|
709
|
+
)
|
|
710
|
+
.bind(id)
|
|
711
|
+
.first<{
|
|
712
|
+
script: string
|
|
713
|
+
video_id: string
|
|
714
|
+
duration_seconds: number | null
|
|
715
|
+
thumbnail_image_id: string | null
|
|
716
|
+
processing_source_url: string | null
|
|
717
|
+
processing_source_kind: string | null
|
|
718
|
+
}>()
|
|
719
|
+
|
|
720
|
+
if (current) {
|
|
721
|
+
const videoContent = input.content as Partial<VideoInput['content']>
|
|
722
|
+
const sourceUrl = hasOwn(videoContent, 'processingSourceUrl')
|
|
723
|
+
? (videoContent.processingSourceUrl ?? null)
|
|
724
|
+
: current.processing_source_url
|
|
725
|
+
const sourceKind = hasOwn(videoContent, 'processingSourceKind')
|
|
726
|
+
? (videoContent.processingSourceKind ?? null)
|
|
727
|
+
: current.processing_source_kind
|
|
728
|
+
const sourceChanged =
|
|
729
|
+
sourceUrl !== current.processing_source_url || sourceKind !== current.processing_source_kind
|
|
730
|
+
const durationProvided = hasOwn(videoContent, 'durationSeconds')
|
|
731
|
+
const resolvedDuration = await resolveMediaDuration(
|
|
732
|
+
db,
|
|
733
|
+
'video',
|
|
734
|
+
sourceUrl,
|
|
735
|
+
durationProvided ? videoContent.durationSeconds : undefined,
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
videoUpdatePlan = {
|
|
739
|
+
script: hasOwn(videoContent, 'script')
|
|
740
|
+
? (videoContent.script ?? current.script)
|
|
741
|
+
: current.script,
|
|
742
|
+
videoId: hasOwn(videoContent, 'videoId')
|
|
743
|
+
? (videoContent.videoId ?? current.video_id)
|
|
744
|
+
: current.video_id,
|
|
745
|
+
durationSeconds:
|
|
746
|
+
durationProvided || sourceChanged
|
|
747
|
+
? resolvedDuration
|
|
748
|
+
: (resolvedDuration ?? current.duration_seconds),
|
|
749
|
+
thumbnailImageId: hasOwn(videoContent, 'thumbnailImageId')
|
|
750
|
+
? (videoContent.thumbnailImageId ?? null)
|
|
751
|
+
: current.thumbnail_image_id,
|
|
752
|
+
sourceUrl,
|
|
753
|
+
sourceKind,
|
|
754
|
+
sourceChanged,
|
|
755
|
+
}
|
|
756
|
+
} else {
|
|
757
|
+
videoUpdatePlan = null
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (input.content && existing.type === 'podcast') {
|
|
762
|
+
const current = await db
|
|
763
|
+
.prepare(
|
|
764
|
+
`SELECT transcript, audio_r2_key, duration_seconds, processing_trigger_token
|
|
765
|
+
FROM podcast_content
|
|
766
|
+
WHERE content_id = ?
|
|
767
|
+
LIMIT 1`,
|
|
768
|
+
)
|
|
769
|
+
.bind(id)
|
|
770
|
+
.first<{
|
|
771
|
+
transcript: string
|
|
772
|
+
audio_r2_key: string
|
|
773
|
+
duration_seconds: number | null
|
|
774
|
+
processing_trigger_token: string | null
|
|
775
|
+
}>()
|
|
776
|
+
|
|
777
|
+
if (current) {
|
|
778
|
+
const podcastContent = input.content as Partial<PodcastInput['content']>
|
|
779
|
+
const audioR2Key = hasOwn(podcastContent, 'audioR2Key')
|
|
780
|
+
? (podcastContent.audioR2Key ?? current.audio_r2_key)
|
|
781
|
+
: current.audio_r2_key
|
|
782
|
+
const sourceChanged = audioR2Key !== current.audio_r2_key
|
|
783
|
+
const durationProvided = hasOwn(podcastContent, 'durationSeconds')
|
|
784
|
+
const resolvedDuration = await resolveMediaDuration(
|
|
785
|
+
db,
|
|
786
|
+
'podcast',
|
|
787
|
+
audioR2Key,
|
|
788
|
+
durationProvided ? podcastContent.durationSeconds : undefined,
|
|
789
|
+
)
|
|
790
|
+
const replacementTranscriptProvided =
|
|
791
|
+
hasOwn(podcastContent, 'transcript') && typeof podcastContent.transcript === 'string'
|
|
792
|
+
|
|
793
|
+
podcastUpdatePlan = {
|
|
794
|
+
transcript: sourceChanged
|
|
795
|
+
? replacementTranscriptProvided
|
|
796
|
+
? (podcastContent.transcript as string)
|
|
797
|
+
: ''
|
|
798
|
+
: replacementTranscriptProvided
|
|
799
|
+
? (podcastContent.transcript as string)
|
|
800
|
+
: current.transcript,
|
|
801
|
+
audioR2Key,
|
|
802
|
+
durationSeconds:
|
|
803
|
+
durationProvided || sourceChanged
|
|
804
|
+
? resolvedDuration
|
|
805
|
+
: (resolvedDuration ?? current.duration_seconds),
|
|
806
|
+
processingTriggerToken: sourceChanged ? null : current.processing_trigger_token,
|
|
807
|
+
}
|
|
808
|
+
} else {
|
|
809
|
+
podcastUpdatePlan = null
|
|
810
|
+
}
|
|
811
|
+
}
|
|
467
812
|
|
|
468
813
|
const slug = input.slug ? await ensureUniqueSlug(db, input.slug, id) : existing.slug
|
|
469
814
|
const visibility = input.visibility || existing.visibility
|
|
@@ -505,6 +850,9 @@ export async function updateContentItem(
|
|
|
505
850
|
const seoFocusKeyword = hasOwn(input, 'seoFocusKeyword')
|
|
506
851
|
? (input.seoFocusKeyword ?? null)
|
|
507
852
|
: (existing.seo_focus_keyword ?? null)
|
|
853
|
+
const metadataJson = hasOwn(input, 'metadata')
|
|
854
|
+
? serializeContentMetadata(input.metadata)
|
|
855
|
+
: existing.metadata_json
|
|
508
856
|
|
|
509
857
|
await db
|
|
510
858
|
.prepare(
|
|
@@ -528,6 +876,7 @@ export async function updateContentItem(
|
|
|
528
876
|
canonical_url = ?,
|
|
529
877
|
ai_locked_fields = ?,
|
|
530
878
|
seo_focus_keyword = ?,
|
|
879
|
+
metadata_json = ?,
|
|
531
880
|
updated_at = unixepoch()
|
|
532
881
|
WHERE id = ?
|
|
533
882
|
`,
|
|
@@ -551,6 +900,7 @@ export async function updateContentItem(
|
|
|
551
900
|
canonicalUrl,
|
|
552
901
|
aiLockedFields,
|
|
553
902
|
seoFocusKeyword,
|
|
903
|
+
metadataJson,
|
|
554
904
|
id,
|
|
555
905
|
)
|
|
556
906
|
.run()
|
|
@@ -642,37 +992,7 @@ export async function updateContentItem(
|
|
|
642
992
|
}
|
|
643
993
|
|
|
644
994
|
if (existing.type === 'video') {
|
|
645
|
-
|
|
646
|
-
.prepare(
|
|
647
|
-
`
|
|
648
|
-
SELECT script, video_id, duration_seconds, thumbnail_image_id,
|
|
649
|
-
processing_source_url, processing_source_kind
|
|
650
|
-
FROM video_content
|
|
651
|
-
WHERE content_id = ?
|
|
652
|
-
LIMIT 1
|
|
653
|
-
`,
|
|
654
|
-
)
|
|
655
|
-
.bind(id)
|
|
656
|
-
.first<{
|
|
657
|
-
script: string
|
|
658
|
-
video_id: string
|
|
659
|
-
duration_seconds: number | null
|
|
660
|
-
thumbnail_image_id: string | null
|
|
661
|
-
processing_source_url: string | null
|
|
662
|
-
processing_source_kind: string | null
|
|
663
|
-
}>()
|
|
664
|
-
if (!current) return { slug }
|
|
665
|
-
|
|
666
|
-
const videoContent = input.content as Partial<VideoInput['content']>
|
|
667
|
-
const nextSourceUrl = hasOwn(videoContent, 'processingSourceUrl')
|
|
668
|
-
? (videoContent.processingSourceUrl ?? null)
|
|
669
|
-
: current.processing_source_url
|
|
670
|
-
const nextSourceKind = hasOwn(videoContent, 'processingSourceKind')
|
|
671
|
-
? (videoContent.processingSourceKind ?? null)
|
|
672
|
-
: current.processing_source_kind
|
|
673
|
-
const sourceChanged =
|
|
674
|
-
nextSourceUrl !== current.processing_source_url ||
|
|
675
|
-
nextSourceKind !== current.processing_source_kind
|
|
995
|
+
if (!videoUpdatePlan) return { slug }
|
|
676
996
|
await db
|
|
677
997
|
.prepare(
|
|
678
998
|
`
|
|
@@ -697,78 +1017,51 @@ export async function updateContentItem(
|
|
|
697
1017
|
hls_manifest_url = CASE WHEN ? THEN NULL ELSE hls_manifest_url END,
|
|
698
1018
|
hls_poster_url = CASE WHEN ? THEN NULL ELSE hls_poster_url END
|
|
699
1019
|
WHERE content_id = ?
|
|
700
|
-
|
|
1020
|
+
`,
|
|
701
1021
|
)
|
|
702
1022
|
.bind(
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
sourceChanged ? 1 : 0,
|
|
716
|
-
sourceChanged ? 1 : 0,
|
|
717
|
-
sourceChanged ? 1 : 0,
|
|
718
|
-
sourceChanged ? 1 : 0,
|
|
719
|
-
sourceChanged ? 1 : 0,
|
|
720
|
-
sourceChanged ? 1 : 0,
|
|
721
|
-
sourceChanged ? 1 : 0,
|
|
722
|
-
sourceChanged ? 1 : 0,
|
|
723
|
-
sourceChanged ? 1 : 0,
|
|
724
|
-
sourceChanged ? 1 : 0,
|
|
725
|
-
sourceChanged ? 1 : 0,
|
|
726
|
-
sourceChanged ? 1 : 0,
|
|
727
|
-
sourceChanged ? 1 : 0,
|
|
1023
|
+
videoUpdatePlan.script,
|
|
1024
|
+
videoUpdatePlan.videoId,
|
|
1025
|
+
videoUpdatePlan.durationSeconds,
|
|
1026
|
+
videoUpdatePlan.thumbnailImageId,
|
|
1027
|
+
videoUpdatePlan.sourceUrl,
|
|
1028
|
+
videoUpdatePlan.sourceKind,
|
|
1029
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1030
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1031
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1032
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1033
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1034
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1035
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1036
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1037
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1038
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1039
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1040
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1041
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
728
1042
|
id,
|
|
729
1043
|
)
|
|
730
1044
|
.run()
|
|
731
1045
|
}
|
|
732
1046
|
|
|
733
1047
|
if (existing.type === 'podcast') {
|
|
734
|
-
|
|
735
|
-
.prepare(
|
|
736
|
-
`
|
|
737
|
-
SELECT transcript, audio_r2_key, duration_seconds
|
|
738
|
-
FROM podcast_content
|
|
739
|
-
WHERE content_id = ?
|
|
740
|
-
LIMIT 1
|
|
741
|
-
`,
|
|
742
|
-
)
|
|
743
|
-
.bind(id)
|
|
744
|
-
.first<{
|
|
745
|
-
transcript: string
|
|
746
|
-
audio_r2_key: string
|
|
747
|
-
duration_seconds: number | null
|
|
748
|
-
}>()
|
|
749
|
-
if (!current) return { slug }
|
|
750
|
-
|
|
751
|
-
const podcastContent = input.content as Partial<PodcastInput['content']>
|
|
1048
|
+
if (!podcastUpdatePlan) return { slug }
|
|
752
1049
|
await db
|
|
753
1050
|
.prepare(
|
|
754
1051
|
`
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
1052
|
+
UPDATE podcast_content SET
|
|
1053
|
+
transcript = ?,
|
|
1054
|
+
audio_r2_key = ?,
|
|
1055
|
+
duration_seconds = ?,
|
|
1056
|
+
processing_trigger_token = ?
|
|
1057
|
+
WHERE content_id = ?
|
|
1058
|
+
`,
|
|
761
1059
|
)
|
|
762
1060
|
.bind(
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
? (podcastContent.audioR2Key ?? current.audio_r2_key)
|
|
768
|
-
: current.audio_r2_key,
|
|
769
|
-
hasOwn(podcastContent, 'durationSeconds')
|
|
770
|
-
? (podcastContent.durationSeconds ?? null)
|
|
771
|
-
: current.duration_seconds,
|
|
1061
|
+
podcastUpdatePlan.transcript,
|
|
1062
|
+
podcastUpdatePlan.audioR2Key,
|
|
1063
|
+
podcastUpdatePlan.durationSeconds,
|
|
1064
|
+
podcastUpdatePlan.processingTriggerToken,
|
|
772
1065
|
id,
|
|
773
1066
|
)
|
|
774
1067
|
.run()
|
|
@@ -915,7 +1208,8 @@ export async function duplicateContentItem(
|
|
|
915
1208
|
// DEFAULT (unixepoch()) timestamps; status forced to 'draft', featured to
|
|
916
1209
|
// 0, publish_at/published_at/published_revision_id to NULL. (inventory §6)
|
|
917
1210
|
//
|
|
918
|
-
// SEO
|
|
1211
|
+
// SEO and declared metadata carry over (seo_title, seo_focus_keyword,
|
|
1212
|
+
// seo_score, ai_og_image_id, metadata_json)
|
|
919
1213
|
// — a duplicate should keep the SEO work already done on the source. But
|
|
920
1214
|
// board_position is the source item's slot in the editorial board ordering;
|
|
921
1215
|
// a fresh draft copy must NOT inherit it, so it is reset to NULL.
|
|
@@ -927,7 +1221,8 @@ export async function duplicateContentItem(
|
|
|
927
1221
|
hero_image_id, hero_image_alt, hero_image_caption, social_image_id,
|
|
928
1222
|
canonical_url, publish_at, published_at,
|
|
929
1223
|
publish_tz, ai_locked_fields, published_revision_id,
|
|
930
|
-
seo_focus_keyword, seo_score, ai_og_image_id, board_position
|
|
1224
|
+
seo_focus_keyword, seo_score, ai_og_image_id, board_position,
|
|
1225
|
+
metadata_json
|
|
931
1226
|
)
|
|
932
1227
|
SELECT
|
|
933
1228
|
?, type, 'draft', visibility, ?, title || ' (Copy)', seo_title, description, excerpt,
|
|
@@ -935,7 +1230,8 @@ export async function duplicateContentItem(
|
|
|
935
1230
|
hero_image_id, hero_image_alt, hero_image_caption, social_image_id,
|
|
936
1231
|
canonical_url, NULL, NULL,
|
|
937
1232
|
publish_tz, ai_locked_fields, NULL,
|
|
938
|
-
seo_focus_keyword, seo_score, ai_og_image_id, NULL
|
|
1233
|
+
seo_focus_keyword, seo_score, ai_og_image_id, NULL,
|
|
1234
|
+
metadata_json
|
|
939
1235
|
FROM content_items
|
|
940
1236
|
WHERE id = ?`,
|
|
941
1237
|
)
|
|
@@ -1047,14 +1343,14 @@ export async function getContentSnapshot(
|
|
|
1047
1343
|
|
|
1048
1344
|
const tags = await db
|
|
1049
1345
|
.prepare(
|
|
1050
|
-
'SELECT t.slug FROM content_tag_links l JOIN content_tags t ON t.id = l.tag_id WHERE l.content_id = ?',
|
|
1346
|
+
'SELECT t.slug FROM content_tag_links l JOIN content_tags t ON t.id = l.tag_id WHERE l.content_id = ? ORDER BY t.slug ASC',
|
|
1051
1347
|
)
|
|
1052
1348
|
.bind(contentId)
|
|
1053
1349
|
.all<{ slug: string }>()
|
|
1054
1350
|
|
|
1055
1351
|
const related = await db
|
|
1056
1352
|
.prepare(
|
|
1057
|
-
'SELECT related_id, rank, reason FROM content_relations WHERE content_id = ? ORDER BY rank ASC',
|
|
1353
|
+
'SELECT related_id, rank, reason FROM content_relations WHERE content_id = ? ORDER BY rank ASC, related_id ASC',
|
|
1058
1354
|
)
|
|
1059
1355
|
.bind(contentId)
|
|
1060
1356
|
.all<{ related_id: string; rank: number; reason: string | null }>()
|