@scalemule/news-publication 0.0.80 → 0.0.82
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 +82 -9
- package/bin/scalemule-article.mjs +77 -9
- package/dist/article-workflow-client.cjs +396 -131
- package/dist/article-workflow-client.d.cts +132 -13
- package/dist/article-workflow-client.d.ts +132 -13
- package/dist/article-workflow-client.js +395 -132
- package/package.json +1 -1
|
@@ -10,6 +10,47 @@ function articlePhotoHtmlMatches(html, lead, social) {
|
|
|
10
10
|
return image && openGraph;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// src/article-edit-verification.ts
|
|
14
|
+
var decode = (value) => value.replace(/&(?:amp|lt|gt|quot|apos|#39|#\d+|#x[\da-f]+);/giu, (s) => {
|
|
15
|
+
const named = {
|
|
16
|
+
"&": "&",
|
|
17
|
+
"<": "<",
|
|
18
|
+
">": ">",
|
|
19
|
+
""": '"',
|
|
20
|
+
"'": "'",
|
|
21
|
+
"'": "'"
|
|
22
|
+
};
|
|
23
|
+
if (named[s.toLowerCase()]) return named[s.toLowerCase()];
|
|
24
|
+
const hex = s.toLowerCase().startsWith("&#x");
|
|
25
|
+
const n = parseInt(s.slice(hex ? 3 : 2, -1), hex ? 16 : 10);
|
|
26
|
+
return Number.isFinite(n) && n >= 0 && n <= 1114111 ? String.fromCodePoint(n) : s;
|
|
27
|
+
});
|
|
28
|
+
var text = (html) => decode(html.replace(/<[^>]*>/gu, " ")).replace(/\s+/gu, " ").trim();
|
|
29
|
+
var plain = (value) => value.replace(/\s+/gu, " ").trim();
|
|
30
|
+
function articleEditHtmlMatches(html, expected) {
|
|
31
|
+
const visible = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/giu, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style>/giu, " ");
|
|
32
|
+
const headline = [...visible.matchAll(/<h1\b[^>]*>([\s\S]*?)<\/h1>/giu)].some(
|
|
33
|
+
(m) => text(m[1]) === plain(expected.title)
|
|
34
|
+
);
|
|
35
|
+
if (!headline) return false;
|
|
36
|
+
const paragraphs = [
|
|
37
|
+
...expected.content_html.matchAll(
|
|
38
|
+
/<(p|li|h[2-6]|blockquote)\b[^>]*>([\s\S]*?)<\/\1>/giu
|
|
39
|
+
)
|
|
40
|
+
].map((m) => text(m[2])).filter(Boolean);
|
|
41
|
+
const all = text(visible);
|
|
42
|
+
if (!paragraphs.length || !paragraphs.every((p) => all.includes(p)))
|
|
43
|
+
return false;
|
|
44
|
+
const titles = [
|
|
45
|
+
...visible.matchAll(
|
|
46
|
+
/<aside\b[^>]*class=["'][^"']*\barticle-verified-card\b[^"']*["'][^>]*>([\s\S]*?)<\/aside>/giu
|
|
47
|
+
)
|
|
48
|
+
].flatMap(
|
|
49
|
+
(m) => [...m[1].matchAll(/<h2\b[^>]*>([\s\S]*?)<\/h2>/giu)].map((m2) => text(m2[1]))
|
|
50
|
+
);
|
|
51
|
+
return JSON.stringify(titles.sort()) === JSON.stringify(expected.card_titles.map(plain).sort());
|
|
52
|
+
}
|
|
53
|
+
|
|
13
54
|
// src/article-photo-operation.ts
|
|
14
55
|
var ArticlePhotoOperationError = class extends Error {
|
|
15
56
|
constructor(code, stage, message, fileId, status) {
|
|
@@ -285,6 +326,325 @@ async function runArticlePhoto(t, input) {
|
|
|
285
326
|
}
|
|
286
327
|
}
|
|
287
328
|
|
|
329
|
+
// src/article-edit-operation.ts
|
|
330
|
+
var ArticleEditOperationError = class extends Error {
|
|
331
|
+
constructor(code, stage, message, status, revisionId) {
|
|
332
|
+
super(message);
|
|
333
|
+
this.code = code;
|
|
334
|
+
this.stage = stage;
|
|
335
|
+
this.status = status;
|
|
336
|
+
this.revisionId = revisionId;
|
|
337
|
+
this.name = "ArticleEditOperationError";
|
|
338
|
+
}
|
|
339
|
+
code;
|
|
340
|
+
stage;
|
|
341
|
+
status;
|
|
342
|
+
revisionId;
|
|
343
|
+
};
|
|
344
|
+
var canonical2 = (v) => Array.isArray(v) ? v.map(canonical2) : v && typeof v === "object" ? Object.fromEntries(
|
|
345
|
+
Object.keys(v).sort().map((k) => [k, canonical2(v[k])])
|
|
346
|
+
) : v;
|
|
347
|
+
var equal = (a, b) => JSON.stringify(canonical2(a)) === JSON.stringify(canonical2(b));
|
|
348
|
+
var digest2 = async (v) => [
|
|
349
|
+
...new Uint8Array(
|
|
350
|
+
await crypto.subtle.digest(
|
|
351
|
+
"SHA-256",
|
|
352
|
+
new TextEncoder().encode(JSON.stringify(canonical2(v)))
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
].map((v2) => v2.toString(16).padStart(2, "0")).join("");
|
|
356
|
+
var patchKeys = /* @__PURE__ */ new Set([
|
|
357
|
+
"title",
|
|
358
|
+
"subtitle",
|
|
359
|
+
"content_markdown",
|
|
360
|
+
"excerpt",
|
|
361
|
+
"section",
|
|
362
|
+
"community",
|
|
363
|
+
"format",
|
|
364
|
+
"layout",
|
|
365
|
+
"cards",
|
|
366
|
+
"citations"
|
|
367
|
+
]);
|
|
368
|
+
function validate(input) {
|
|
369
|
+
if (!input.operationKey || !/^[\w.:-]{1,128}$/u.test(input.operationKey) || !input.articleId || !input.patch || typeof input.patch !== "object" || Array.isArray(input.patch) || !Object.keys(input.patch).length)
|
|
370
|
+
throw Error(
|
|
371
|
+
"Supply an article, stable operation key and nonempty editorial patch"
|
|
372
|
+
);
|
|
373
|
+
for (const [key, value] of Object.entries(input.patch)) {
|
|
374
|
+
if (!patchKeys.has(key)) throw Error("Unsupported edit field: " + key);
|
|
375
|
+
if (["cards", "citations"].includes(key)) {
|
|
376
|
+
if (!Array.isArray(value)) throw Error(key + " must be an array");
|
|
377
|
+
} else if (key === "layout") {
|
|
378
|
+
if (!value || typeof value !== "object" || typeof value.id !== "string" || !Number.isInteger(value.version))
|
|
379
|
+
throw Error("Select an existing versioned layout");
|
|
380
|
+
} else if (typeof value !== "string")
|
|
381
|
+
throw Error(key + " must be a string");
|
|
382
|
+
}
|
|
383
|
+
if (input.expectedGeneration !== void 0 && (!Number.isSafeInteger(input.expectedGeneration) || input.expectedGeneration < 1))
|
|
384
|
+
throw Error("Expected generation must be positive");
|
|
385
|
+
if (input.enableReviewPolicy && ![
|
|
386
|
+
"NONE",
|
|
387
|
+
"FACT_CHECK",
|
|
388
|
+
"CONTRIBUTOR_REVIEW",
|
|
389
|
+
"SUBJECT_APPROVAL_REQUIRED",
|
|
390
|
+
"SUBJECT_APPROVAL_PLUS_EDITOR"
|
|
391
|
+
].includes(input.enableReviewPolicy))
|
|
392
|
+
throw Error("Select an explicit supported review policy");
|
|
393
|
+
if (new TextEncoder().encode(JSON.stringify(input.patch)).length > 128 * 1024)
|
|
394
|
+
throw Error("Editorial patch exceeds 128 KiB");
|
|
395
|
+
}
|
|
396
|
+
async function runArticleEdit(t, input) {
|
|
397
|
+
validate(input);
|
|
398
|
+
const started = performance.now();
|
|
399
|
+
let stage = "preflight";
|
|
400
|
+
const timeout = input.timeoutMs ?? 1e4;
|
|
401
|
+
if (!Number.isFinite(timeout) || timeout < 100 || timeout > 12e4)
|
|
402
|
+
throw Error("Edit deadline must be between 100 and 120000 milliseconds");
|
|
403
|
+
const signal = AbortSignal.timeout(timeout);
|
|
404
|
+
const request = (path, method, body, extra) => t.request(path, method, body, extra, signal);
|
|
405
|
+
const identity = await digest2([
|
|
406
|
+
t.publicationId,
|
|
407
|
+
input.articleId,
|
|
408
|
+
input.operationKey,
|
|
409
|
+
input.patch,
|
|
410
|
+
input.expectedGeneration ?? null,
|
|
411
|
+
input.enableReviewPolicy ?? null
|
|
412
|
+
]);
|
|
413
|
+
if (input.checkpoint && (input.checkpoint.version !== 1 || input.checkpoint.identity !== identity || input.checkpoint.articleId !== input.articleId))
|
|
414
|
+
throw new ArticleEditOperationError(
|
|
415
|
+
"OPERATION_CONFLICT",
|
|
416
|
+
"validation",
|
|
417
|
+
"This checkpoint belongs to different input; reuse its original patch and operation key"
|
|
418
|
+
);
|
|
419
|
+
let state = input.checkpoint ? structuredClone(input.checkpoint) : { version: 1, identity, articleId: input.articleId, stage };
|
|
420
|
+
const save = async (next) => {
|
|
421
|
+
stage = next;
|
|
422
|
+
state.stage = next;
|
|
423
|
+
await input.saveCheckpoint(structuredClone(state));
|
|
424
|
+
};
|
|
425
|
+
const article = "/articles/" + encodeURIComponent(input.articleId);
|
|
426
|
+
const currentRevision = (record) => record.revisions?.find((v) => v.id === record.current_revision_id);
|
|
427
|
+
const identityOf = (post) => ({
|
|
428
|
+
slug: post.slug,
|
|
429
|
+
published_at: post.published_at ?? null,
|
|
430
|
+
canonical_url: post.canonical_url ?? null
|
|
431
|
+
});
|
|
432
|
+
const identityMatches = (post) => !state.sourceIdentity || state.sourceIdentity.slug === post.slug && (state.sourceIdentity.published_at === null || state.sourceIdentity.published_at === (post.published_at ?? null)) && (state.sourceIdentity.canonical_url === null || state.sourceIdentity.canonical_url === (post.canonical_url ?? null));
|
|
433
|
+
const result = (record, status) => ({
|
|
434
|
+
article_id: input.articleId,
|
|
435
|
+
revision_id: state.revisionId ?? record.current_revision_id,
|
|
436
|
+
generation: record.generation,
|
|
437
|
+
state: status,
|
|
438
|
+
editorial_status: record.status,
|
|
439
|
+
elapsed_ms: Math.round(performance.now() - started)
|
|
440
|
+
});
|
|
441
|
+
try {
|
|
442
|
+
const [options, source, initial] = await Promise.all([
|
|
443
|
+
request(
|
|
444
|
+
"/article-workflow/options?article_id=" + encodeURIComponent(input.articleId)
|
|
445
|
+
),
|
|
446
|
+
request(article + "/document"),
|
|
447
|
+
request(article + "/collaboration").catch((error) => {
|
|
448
|
+
if (error.status === 404 && error.message === "Story collaboration is not enabled")
|
|
449
|
+
return null;
|
|
450
|
+
throw error;
|
|
451
|
+
})
|
|
452
|
+
]);
|
|
453
|
+
if (options.workflow_version !== 1 || options.capabilities?.versioned_editorial_fields !== true)
|
|
454
|
+
throw new ArticleEditOperationError(
|
|
455
|
+
"CAPABILITY_UNAVAILABLE",
|
|
456
|
+
stage,
|
|
457
|
+
"The server must expose versioned_editorial_fields before this edit; no article was changed"
|
|
458
|
+
);
|
|
459
|
+
if (!identityMatches(source))
|
|
460
|
+
throw new ArticleEditOperationError(
|
|
461
|
+
"ARTICLE_IDENTITY_CHANGED",
|
|
462
|
+
stage,
|
|
463
|
+
"Article URL, publication date or canonical identity changed; inspect before continuing"
|
|
464
|
+
);
|
|
465
|
+
state.sourceIdentity ??= identityOf(source);
|
|
466
|
+
let record = initial;
|
|
467
|
+
if (!record) {
|
|
468
|
+
if (!input.enableReviewPolicy)
|
|
469
|
+
throw new ArticleEditOperationError(
|
|
470
|
+
"ENROLLMENT_REQUIRED",
|
|
471
|
+
stage,
|
|
472
|
+
"Legacy article: choose an explicit review policy for versioned editorial onboarding; no content was changed"
|
|
473
|
+
);
|
|
474
|
+
if (source.status === "scheduled")
|
|
475
|
+
throw new ArticleEditOperationError(
|
|
476
|
+
"SCHEDULED_ARTICLE",
|
|
477
|
+
stage,
|
|
478
|
+
"Onboarding cancels an old schedule; handle this scheduled article explicitly"
|
|
479
|
+
);
|
|
480
|
+
await save("enroll");
|
|
481
|
+
await request(article + "/collaboration/enable", "POST", {
|
|
482
|
+
review_policy: input.enableReviewPolicy
|
|
483
|
+
});
|
|
484
|
+
record = await request(article + "/collaboration");
|
|
485
|
+
}
|
|
486
|
+
if (state.revisionId) {
|
|
487
|
+
if (record.current_revision_id !== state.revisionId)
|
|
488
|
+
throw new ArticleEditOperationError(
|
|
489
|
+
"ARTICLE_CHANGED",
|
|
490
|
+
stage,
|
|
491
|
+
"Another revision is current; this operation cannot release or overwrite it",
|
|
492
|
+
409,
|
|
493
|
+
state.revisionId
|
|
494
|
+
);
|
|
495
|
+
const selected = currentRevision(record);
|
|
496
|
+
if (!selected || !equal(selected.document, state.document))
|
|
497
|
+
throw new ArticleEditOperationError(
|
|
498
|
+
"VERIFICATION_CONFLICT",
|
|
499
|
+
stage,
|
|
500
|
+
"The saved revision differs from this operation",
|
|
501
|
+
409,
|
|
502
|
+
state.revisionId
|
|
503
|
+
);
|
|
504
|
+
} else {
|
|
505
|
+
if (!state.document) {
|
|
506
|
+
if (input.expectedGeneration !== void 0 && record.generation !== input.expectedGeneration)
|
|
507
|
+
throw new ArticleEditOperationError(
|
|
508
|
+
"ARTICLE_CHANGED",
|
|
509
|
+
stage,
|
|
510
|
+
"The article generation changed; reload before applying this patch",
|
|
511
|
+
409
|
|
512
|
+
);
|
|
513
|
+
const selected = currentRevision(record);
|
|
514
|
+
if (!selected?.complete_snapshot && selected?.complete_snapshot !== void 0)
|
|
515
|
+
throw new ArticleEditOperationError(
|
|
516
|
+
"INCOMPLETE_REVISION",
|
|
517
|
+
stage,
|
|
518
|
+
"Inspect this historical revision before editing"
|
|
519
|
+
);
|
|
520
|
+
if (!selected?.document)
|
|
521
|
+
throw Error("Current immutable article document is missing");
|
|
522
|
+
const effective = { ...selected.document };
|
|
523
|
+
for (const key of ["cards", "citations"])
|
|
524
|
+
if (effective[key] === void 0 && selected.editorial_metadata?.[key] !== void 0)
|
|
525
|
+
effective[key] = selected.editorial_metadata[key];
|
|
526
|
+
if (effective.excerpt === void 0 && source.excerpt !== null && source.excerpt !== void 0)
|
|
527
|
+
effective.excerpt = source.excerpt;
|
|
528
|
+
const changed = Object.entries(input.patch).some(
|
|
529
|
+
([key, value]) => !equal(effective[key], value)
|
|
530
|
+
);
|
|
531
|
+
if (!changed) {
|
|
532
|
+
state.document = selected.document;
|
|
533
|
+
state.revisionId = record.current_revision_id;
|
|
534
|
+
state.savedGeneration = record.generation;
|
|
535
|
+
state.unchanged = true;
|
|
536
|
+
await save("verify");
|
|
537
|
+
if (!input.release) return result(record, "unchanged");
|
|
538
|
+
} else {
|
|
539
|
+
const document = { ...selected.document, ...input.patch };
|
|
540
|
+
if (document.excerpt === void 0 && source.excerpt !== null && source.excerpt !== void 0)
|
|
541
|
+
document.excerpt = source.excerpt;
|
|
542
|
+
state.document = document;
|
|
543
|
+
state.expectedGeneration = record.generation;
|
|
544
|
+
await save("save");
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (!state.revisionId) {
|
|
548
|
+
const saved = await request(
|
|
549
|
+
"/article-workflow",
|
|
550
|
+
"POST",
|
|
551
|
+
{
|
|
552
|
+
operation: "save",
|
|
553
|
+
article_id: input.articleId,
|
|
554
|
+
expected_generation: state.expectedGeneration,
|
|
555
|
+
document: state.document,
|
|
556
|
+
reason: state.sourceIdentity?.published_at ? "POST_PUBLICATION_CORRECTION" : "EDITOR_EDIT"
|
|
557
|
+
},
|
|
558
|
+
{ "Idempotency-Key": input.operationKey }
|
|
559
|
+
);
|
|
560
|
+
state.revisionId = saved.revision_id;
|
|
561
|
+
state.savedGeneration = saved.generation;
|
|
562
|
+
await save("verify");
|
|
563
|
+
record = await request(article + "/collaboration");
|
|
564
|
+
if (record.current_revision_id !== state.revisionId || !equal(currentRevision(record)?.document, state.document))
|
|
565
|
+
throw new ArticleEditOperationError(
|
|
566
|
+
"ARTICLE_CHANGED",
|
|
567
|
+
stage,
|
|
568
|
+
"A different revision is current; no release was attempted",
|
|
569
|
+
409,
|
|
570
|
+
state.revisionId
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (record.published_revision_id === state.revisionId) {
|
|
575
|
+
await save("published");
|
|
576
|
+
return result(record, "already_published");
|
|
577
|
+
}
|
|
578
|
+
if (!input.release) {
|
|
579
|
+
await save("saved");
|
|
580
|
+
return result(record, state.unchanged ? "unchanged" : "saved_for_review");
|
|
581
|
+
}
|
|
582
|
+
await save("release");
|
|
583
|
+
try {
|
|
584
|
+
await request(article + "/collaboration", "POST", {
|
|
585
|
+
action: "RELEASE",
|
|
586
|
+
expected_generation: record.generation,
|
|
587
|
+
scheduled_at: null
|
|
588
|
+
});
|
|
589
|
+
} catch (error) {
|
|
590
|
+
const checked = await request(article + "/collaboration");
|
|
591
|
+
if (checked.published_revision_id === state.revisionId && checked.current_revision_id === state.revisionId) {
|
|
592
|
+
record = checked;
|
|
593
|
+
} else throw error;
|
|
594
|
+
}
|
|
595
|
+
record = await request(article + "/collaboration");
|
|
596
|
+
if (record.current_revision_id !== state.revisionId || record.published_revision_id !== state.revisionId)
|
|
597
|
+
throw new ArticleEditOperationError(
|
|
598
|
+
"VERIFICATION_CONFLICT",
|
|
599
|
+
stage,
|
|
600
|
+
"This exact revision has not been confirmed published",
|
|
601
|
+
409,
|
|
602
|
+
state.revisionId
|
|
603
|
+
);
|
|
604
|
+
const after = await request(article + "/document");
|
|
605
|
+
if (!identityMatches(after))
|
|
606
|
+
throw new ArticleEditOperationError(
|
|
607
|
+
"ARTICLE_IDENTITY_CHANGED",
|
|
608
|
+
stage,
|
|
609
|
+
"The release changed a permanent article identity",
|
|
610
|
+
409,
|
|
611
|
+
state.revisionId
|
|
612
|
+
);
|
|
613
|
+
for (const key of ["title", "subtitle", "content_markdown", "excerpt"])
|
|
614
|
+
if (state.document?.[key] !== void 0 && !equal(after[key] ?? "", state.document[key]))
|
|
615
|
+
throw new ArticleEditOperationError(
|
|
616
|
+
"VERIFICATION_CONFLICT",
|
|
617
|
+
stage,
|
|
618
|
+
"Public article field differs after release: " + key,
|
|
619
|
+
409,
|
|
620
|
+
state.revisionId
|
|
621
|
+
);
|
|
622
|
+
const normalizedCards = state.document?.cards;
|
|
623
|
+
if (normalizedCards && after.metadata?.cards?.length !== normalizedCards.length)
|
|
624
|
+
throw new ArticleEditOperationError(
|
|
625
|
+
"VERIFICATION_CONFLICT",
|
|
626
|
+
stage,
|
|
627
|
+
"Public card count differs from the saved revision",
|
|
628
|
+
409,
|
|
629
|
+
state.revisionId
|
|
630
|
+
);
|
|
631
|
+
await save("published");
|
|
632
|
+
return result(record, "published");
|
|
633
|
+
} catch (error) {
|
|
634
|
+
if (error instanceof ArticleEditOperationError) throw error;
|
|
635
|
+
const status = error?.status;
|
|
636
|
+
const message = error instanceof Error ? error.message : "Article edit failed";
|
|
637
|
+
const code = signal.aborted ? "DEADLINE_EXCEEDED" : status === 401 || status === 403 ? "PERMISSION_DENIED" : status === 409 && stage === "release" ? "RELEASE_HELD" : status === 409 ? "ARTICLE_CHANGED" : "STEP_FAILED";
|
|
638
|
+
throw new ArticleEditOperationError(
|
|
639
|
+
code,
|
|
640
|
+
stage,
|
|
641
|
+
signal.aborted ? "Edit reached its deadline; resume the same patch, operation key and checkpoint. Success was not asserted." : message,
|
|
642
|
+
status,
|
|
643
|
+
state.revisionId
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
288
648
|
// src/article-workflow-client.ts
|
|
289
649
|
var ArticleWorkflowError = class extends Error {
|
|
290
650
|
constructor(message, status, articleId) {
|
|
@@ -341,6 +701,9 @@ var PublicationArticleClient = class {
|
|
|
341
701
|
`/article-workflow/options${articleId ? `?article_id=${encodeURIComponent(articleId)}` : ""}`
|
|
342
702
|
);
|
|
343
703
|
}
|
|
704
|
+
publicArticle(slug, signal) {
|
|
705
|
+
return this.request("/article-content/" + encodeURIComponent(slug), void 0, void 0, void 0, signal);
|
|
706
|
+
}
|
|
344
707
|
/** Explicit approver-authorized onboarding for a legacy article; never called by get().
|
|
345
708
|
* Existing schedules are cancelled by the server; public content remains unchanged.
|
|
346
709
|
* Missing immutable media identities must be resolved through newsroom media controls.
|
|
@@ -369,6 +732,10 @@ var PublicationArticleClient = class {
|
|
|
369
732
|
uploadAndReplacePublishedCover(input) {
|
|
370
733
|
return runArticlePhoto({ publicationId: this.config.publicationId, apiUrl: this.config.apiUrl, fetch: this.config.fetch ?? fetch, request: this.request.bind(this) }, input);
|
|
371
734
|
}
|
|
735
|
+
/** Save one exact revision; release is explicit and never manufactures approval. */
|
|
736
|
+
editArticle(input) {
|
|
737
|
+
return runArticleEdit({ publicationId: this.config.publicationId, request: this.request.bind(this) }, input);
|
|
738
|
+
}
|
|
372
739
|
/** Replace a legacy publication cover using ready, measured public Photo renditions.
|
|
373
740
|
* This never enrolls a story, publishes text, creates narration or retries a conflict.
|
|
374
741
|
* Collaboration-managed stories retain their server-enforced immutable revision workflow.
|
|
@@ -531,138 +898,34 @@ var PublicationArticleClient = class {
|
|
|
531
898
|
"If-Match": String(generation)
|
|
532
899
|
});
|
|
533
900
|
}
|
|
534
|
-
/** One
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
if (
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
await asset.file.arrayBuffer()
|
|
562
|
-
)
|
|
563
|
-
)
|
|
564
|
-
).map((v) => v.toString(16).padStart(2, "0")).join("");
|
|
565
|
-
const existing = current.contributions.find(
|
|
566
|
-
(c) => c.kind === "MEDIA" && c.state !== "REJECTED" && c.state !== "WITHDRAWN" && c.payload.sha256 === digest2 && Object.entries(asset.metadata).every(
|
|
567
|
-
([k, v]) => c.payload[k] === v
|
|
568
|
-
)
|
|
569
|
-
);
|
|
570
|
-
const staged = existing?.payload.file_id ? { contribution_id: existing.id, file_id: existing.payload.file_id } : await this.upload(
|
|
571
|
-
created.article_id,
|
|
572
|
-
current.generation,
|
|
573
|
-
asset.file,
|
|
574
|
-
asset.filename,
|
|
575
|
-
asset.metadata
|
|
576
|
-
);
|
|
577
|
-
await this.registerPhoto(staged.file_id);
|
|
578
|
-
if (input.approveMedia) {
|
|
579
|
-
if (!asset.metadata.rights_confirmed || asset.metadata.private_preview_only)
|
|
580
|
-
throw new Error("Publication requires confirmed image rights");
|
|
581
|
-
if (existing?.state !== "APPROVED") {
|
|
582
|
-
current = await this.get(created.article_id);
|
|
583
|
-
await this.command(created.article_id, current.generation, {
|
|
584
|
-
action: "RESOLVE",
|
|
585
|
-
contribution_id: staged.contribution_id,
|
|
586
|
-
state: "APPROVED"
|
|
587
|
-
});
|
|
588
|
-
}
|
|
589
|
-
selected.push({
|
|
590
|
-
kind: "image",
|
|
591
|
-
contribution_id: staged.contribution_id,
|
|
592
|
-
file_id: staged.file_id,
|
|
593
|
-
caption: asset.metadata.caption,
|
|
594
|
-
credit: asset.metadata.credit,
|
|
595
|
-
alt: asset.metadata.alt,
|
|
596
|
-
position: asset.position
|
|
597
|
-
});
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
if (selected.length) {
|
|
601
|
-
const current = await this.get(created.article_id);
|
|
602
|
-
const document = { ...input.document, media: selected };
|
|
603
|
-
const saved = current.revisions.find(
|
|
604
|
-
(r) => r.id === current.current_revision_id
|
|
605
|
-
)?.document;
|
|
606
|
-
if (!saved) throw new Error("Current immutable revision is missing");
|
|
607
|
-
if (sameDocument(saved, document))
|
|
608
|
-
return {
|
|
609
|
-
...created,
|
|
610
|
-
revision_id: current.current_revision_id,
|
|
611
|
-
generation: current.generation,
|
|
612
|
-
editorial_status: current.status,
|
|
613
|
-
replayed: true
|
|
614
|
-
};
|
|
615
|
-
if (!sameDocument(
|
|
616
|
-
{ ...saved, media: [] },
|
|
617
|
-
{ ...input.document, media: [] }
|
|
618
|
-
) || saved.media?.length)
|
|
619
|
-
throw new ArticleWorkflowError(
|
|
620
|
-
"The article was edited after this package began; load its current revision before changing it",
|
|
621
|
-
409,
|
|
622
|
-
created.article_id
|
|
623
|
-
);
|
|
624
|
-
return await this.save(
|
|
625
|
-
`${input.operationKey}:assets`,
|
|
626
|
-
created.article_id,
|
|
627
|
-
current.generation,
|
|
628
|
-
document,
|
|
629
|
-
"MEDIA_UPDATE"
|
|
630
|
-
);
|
|
631
|
-
}
|
|
632
|
-
return created;
|
|
633
|
-
} catch (error) {
|
|
634
|
-
if (error instanceof ArticleWorkflowError) {
|
|
635
|
-
error.articleId = created.article_id;
|
|
636
|
-
throw error;
|
|
637
|
-
}
|
|
638
|
-
throw new ArticleWorkflowError(
|
|
639
|
-
error instanceof Error ? error.message : "Article package interrupted",
|
|
640
|
-
0,
|
|
641
|
-
created.article_id
|
|
642
|
-
);
|
|
643
|
-
}
|
|
901
|
+
/** One server-owned submission. No client-side Photo/TTS orchestration and no
|
|
902
|
+
* fallback to a different workflow. A 202 receipt is acceptance, not readiness.
|
|
903
|
+
* Replay the identical manifest/files/key after an uncertain acknowledgement.
|
|
904
|
+
*/
|
|
905
|
+
submit(manifest, operationKey, files = [], signal) {
|
|
906
|
+
if (!operationKey) throw new Error("A stable operation key is required");
|
|
907
|
+
if (files.length > 10 || files.some((f) => f.file.size < 1 || f.file.size > 25 * 1024 * 1024) || files.reduce((n, f) => n + f.file.size, 0) > 50 * 1024 * 1024) throw new Error("Supply up to ten photographs, at most 25 MiB each and 50 MiB total");
|
|
908
|
+
const slots = new Set(files.map((f) => f.slot));
|
|
909
|
+
if (slots.size !== files.length || files.length !== (manifest.assets?.length ?? 0) || manifest.assets?.some((a) => !slots.has(a.slot))) throw new Error("Supply one file for each declared asset slot");
|
|
910
|
+
if (!files.length) return this.request("/submissions", "POST", manifest, { "Idempotency-Key": operationKey }, signal);
|
|
911
|
+
const form = new FormData();
|
|
912
|
+
form.append("manifest", JSON.stringify(manifest));
|
|
913
|
+
for (const asset of files) form.append(asset.slot, asset.file, asset.filename);
|
|
914
|
+
return this.request("/submissions", "POST", form, { "Idempotency-Key": operationKey }, signal);
|
|
915
|
+
}
|
|
916
|
+
submission(submissionId, signal) {
|
|
917
|
+
return this.request("/submissions/" + encodeURIComponent(submissionId), "GET", void 0, void 0, signal);
|
|
918
|
+
}
|
|
919
|
+
submitArticle(input) {
|
|
920
|
+
if (input.document.media?.length) throw new Error("Pass file assets separately when creating an article package");
|
|
921
|
+
const assets = (input.assets ?? []).map((a, i) => ({ ...a, slot: "asset_" + String.fromCharCode(97 + i) }));
|
|
922
|
+
return this.submit({
|
|
923
|
+
article: { operation: "create", author_profile_id: input.authorProfileId, document: input.document, review_policy: input.reviewPolicy },
|
|
924
|
+
intent: input.intent ?? "draft",
|
|
925
|
+
approve_media: input.approveMedia,
|
|
926
|
+
assets: assets.map((a) => ({ slot: a.slot, position: a.position, metadata: a.metadata }))
|
|
927
|
+
}, input.operationKey, assets);
|
|
644
928
|
}
|
|
645
929
|
};
|
|
646
|
-
function sameDocument(a, b) {
|
|
647
|
-
const canonical2 = (d) => [
|
|
648
|
-
d.title,
|
|
649
|
-
d.subtitle || "",
|
|
650
|
-
d.content_markdown,
|
|
651
|
-
d.section,
|
|
652
|
-
d.community || "",
|
|
653
|
-
d.format || null,
|
|
654
|
-
d.layout ? [d.layout.id, d.layout.version] : null,
|
|
655
|
-
(d.media || []).map((m) => [
|
|
656
|
-
m.kind || "image",
|
|
657
|
-
m.contribution_id,
|
|
658
|
-
m.file_id,
|
|
659
|
-
m.caption,
|
|
660
|
-
m.credit,
|
|
661
|
-
m.alt,
|
|
662
|
-
m.position
|
|
663
|
-
])
|
|
664
|
-
];
|
|
665
|
-
return JSON.stringify(canonical2(a)) === JSON.stringify(canonical2(b));
|
|
666
|
-
}
|
|
667
930
|
|
|
668
|
-
export { ArticlePhotoOperationError, ArticleWorkflowError, PublicationArticleClient, articlePhotoHtmlMatches };
|
|
931
|
+
export { ArticleEditOperationError, ArticlePhotoOperationError, ArticleWorkflowError, PublicationArticleClient, articleEditHtmlMatches, articlePhotoHtmlMatches };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scalemule/news-publication",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.82",
|
|
4
4
|
"description": "Shared server framework for ScaleMule local news publications. Walnut Creek Times is the broadsheet default; each paper supplies its own identity and feature flags.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|