@scalemule/news-publication 0.0.84 → 0.0.86

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 CHANGED
@@ -377,7 +377,7 @@ matches one declared slot. Credentials are normal project/member credentials.
377
377
  The operation key is retained for 30 days. A legacy article must first have an
378
378
  explicitly selected collaboration policy; this endpoint never downgrades review.
379
379
 
380
- ### Edit article prose, cards and citations
380
+ ### Edit article prose, cards, citations and tags
381
381
 
382
382
  ```sh
383
383
  npx --yes --package @scalemule/news-publication@0.0.81 scalemule-article edit \
@@ -385,7 +385,10 @@ npx --yes --package @scalemule/news-publication@0.0.81 scalemule-article edit \
385
385
  ```
386
386
 
387
387
  The patch may contain title, subtitle, content_markdown, excerpt, section,
388
- community, format, layout, cards and citations. It preserves omitted fields and
388
+ community, format, layout, cards, citations and `tag_ids`. Tag edits require SDK
389
+ 0.0.85+ and the deployed `versioned_editorial_tags` capability. Supply up to 32
390
+ existing tenant-owned tag UUIDs; an empty array clears assignments. Create or
391
+ select tags using the ordinary Blog tags API first. It preserves omitted fields and
389
392
  media and saves one immutable revision. Default: save for review. Add
390
393
  `--release` only to request release through the existing policy; the command
391
394
  never issues APPROVE or downgrades review. On a legacy article, explicitly choose
@@ -402,7 +405,7 @@ Unknown save/release responses reconcile against the original revision; another
402
405
  current revision stops rather than being overwritten. Completed replay and no-op
403
406
  edits add no revision. `RELEASE_HELD`/exit 2 preserves the saved revision while
404
407
  review is pending. With `--url --release`, success requires the rendered
405
- headline, prose and actual card components; public visibility pending is also
408
+ headline, prose, actual card components and requested canonical tag links; public visibility pending is also
406
409
  exit 2. A saved revision and a rendered published correction are separate states.
407
410
 
408
411
  Use the same client method, `client.editArticle`, for a caller-supplied durable
@@ -18,7 +18,7 @@ Credentials: SCALEMULE_API_KEY and SCALEMULE_ACCESS_TOKEN (normal authorized mem
18
18
  Platform admins may use their normal access token with --application-id ID.
19
19
  Optional --api-url URL defaults to https://api.scalemule.com.
20
20
 
21
- edit saves prose, excerpt, cards and citations in one immutable revision.
21
+ edit saves prose, excerpt, cards, citations and tenant-owned tag_ids in one immutable revision.
22
22
  Unspecified fields and media are retained. Default: save for review.
23
23
  --release explicitly requests release under the existing policy; it never issues
24
24
  APPROVE or changes a policy. Legacy onboarding requires an explicit
@@ -157,11 +157,11 @@ try {
157
157
  while(!verified){
158
158
  const response=await fetch(values.url,{redirect:'error',cache:'no-store',signal:AbortSignal.timeout(Math.min(5000,remaining()))});
159
159
  const html=await response.text();
160
- verified=response.ok&&articleEditHtmlMatches(html,{title:article.title,content_html:article.content_html,card_titles:(article.metadata?.cards??[]).map(c=>c.title)});
160
+ verified=response.ok&&articleEditHtmlMatches(html,{title:article.title,content_html:article.content_html,card_titles:(article.metadata?.cards??[]).map(c=>c.title),tags:checkpoint.document.tag_ids?article.tags:undefined});
161
161
  if(!verified)await new Promise(r=>setTimeout(r,Math.min(300,remaining())));
162
162
  }
163
163
  }catch(error){reason=error.message;}
164
- if(!verified){console.error(JSON.stringify({...result,state:'published_public_verification_pending',stage:'public_page',message:'The exact revision is published; rendered prose/cards remain unverified. Resume the same edit.',reason}));process.exitCode=2;}
164
+ if(!verified){console.error(JSON.stringify({...result,state:'published_public_verification_pending',stage:'public_page',message:'The exact revision is published; rendered prose/cards/tags remain unverified. Resume the same edit.',reason}));process.exitCode=2;}
165
165
  else console.log(JSON.stringify({...result,public_page_verified:true,total_elapsed_ms:Math.round(performance.now()-commandStarted)},null,2));
166
166
  }else{
167
167
  console.log(JSON.stringify({...result,total_elapsed_ms:Math.round(performance.now()-commandStarted)},null,2));
@@ -43,6 +43,17 @@ function articleEditHtmlMatches(html, expected) {
43
43
  const all = text(visible);
44
44
  if (!paragraphs.length || !paragraphs.every((p) => all.includes(p)))
45
45
  return false;
46
+ if (expected.tags?.length) {
47
+ const links = [...visible.matchAll(/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/giu)];
48
+ if (!expected.tags.every((tag) => links.some((link) => {
49
+ try {
50
+ const url = new URL(decode(link[1]), "https://publication.example");
51
+ return url.pathname === "/news" && url.searchParams.get("tag") === tag.slug && text(link[2]).replace(/^#\s*/u, "") === tag.name;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }))) return false;
56
+ }
46
57
  const titles = [
47
58
  ...visible.matchAll(
48
59
  /<aside\b[^>]*class=["'][^"']*\barticle-verified-card\b[^"']*["'][^>]*>([\s\S]*?)<\/aside>/giu
@@ -347,6 +358,16 @@ var canonical2 = (v) => Array.isArray(v) ? v.map(canonical2) : v && typeof v ===
347
358
  Object.keys(v).sort().map((k) => [k, canonical2(v[k])])
348
359
  ) : v;
349
360
  var equal = (a, b) => JSON.stringify(canonical2(a)) === JSON.stringify(canonical2(b));
361
+ var uuidIdentity = (value) => {
362
+ if (typeof value !== "string" || !/^(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i.test(value)) return value;
363
+ return value.replaceAll("-", "").toLowerCase();
364
+ };
365
+ var comparableDocument = (document) => document && typeof document === "object" ? { ...document, ...Array.isArray(document.media) ? { media: document.media.map((media) => ({
366
+ ...media,
367
+ contribution_id: uuidIdentity(media.contribution_id),
368
+ file_id: uuidIdentity(media.file_id)
369
+ })) } : {} } : document;
370
+ var documentsEqual = (a, b) => equal(comparableDocument(a), comparableDocument(b));
350
371
  var digest2 = async (v) => [
351
372
  ...new Uint8Array(
352
373
  await crypto.subtle.digest(
@@ -365,7 +386,8 @@ var patchKeys = /* @__PURE__ */ new Set([
365
386
  "format",
366
387
  "layout",
367
388
  "cards",
368
- "citations"
389
+ "citations",
390
+ "tag_ids"
369
391
  ]);
370
392
  function validate(input) {
371
393
  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)
@@ -374,7 +396,10 @@ function validate(input) {
374
396
  );
375
397
  for (const [key, value] of Object.entries(input.patch)) {
376
398
  if (!patchKeys.has(key)) throw Error("Unsupported edit field: " + key);
377
- if (["cards", "citations"].includes(key)) {
399
+ if (key === "tag_ids") {
400
+ if (!Array.isArray(value) || value.length > 32 || new Set(value).size !== value.length || value.some((id) => typeof id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(id) || /^0{8}-0{4}-0{4}-0{4}-0{12}$/u.test(id)))
401
+ throw Error("tag_ids must contain at most 32 distinct, nonempty UUIDs");
402
+ } else if (["cards", "citations"].includes(key)) {
378
403
  if (!Array.isArray(value)) throw Error(key + " must be an array");
379
404
  } else if (key === "layout") {
380
405
  if (!value || typeof value !== "object" || typeof value.id !== "string" || !Number.isInteger(value.version))
@@ -458,6 +483,8 @@ async function runArticleEdit(t, input) {
458
483
  stage,
459
484
  "The server must expose versioned_editorial_fields before this edit; no article was changed"
460
485
  );
486
+ if (input.patch.tag_ids !== void 0 && options.capabilities?.versioned_editorial_tags !== true)
487
+ throw new ArticleEditOperationError("CAPABILITY_UNAVAILABLE", stage, "The server must expose versioned_editorial_tags before changing tags; no article was changed");
461
488
  if (!identityMatches(source))
462
489
  throw new ArticleEditOperationError(
463
490
  "ARTICLE_IDENTITY_CHANGED",
@@ -495,7 +522,7 @@ async function runArticleEdit(t, input) {
495
522
  state.revisionId
496
523
  );
497
524
  const selected = currentRevision(record);
498
- if (!selected || !equal(selected.document, state.document))
525
+ if (!selected || !documentsEqual(selected.document, state.document))
499
526
  throw new ArticleEditOperationError(
500
527
  "VERIFICATION_CONFLICT",
501
528
  stage,
@@ -563,7 +590,7 @@ async function runArticleEdit(t, input) {
563
590
  state.savedGeneration = saved.generation;
564
591
  await save("verify");
565
592
  record = await request(article + "/collaboration");
566
- if (record.current_revision_id !== state.revisionId || !equal(currentRevision(record)?.document, state.document))
593
+ if (record.current_revision_id !== state.revisionId || !documentsEqual(currentRevision(record)?.document, state.document))
567
594
  throw new ArticleEditOperationError(
568
595
  "ARTICLE_CHANGED",
569
596
  stage,
@@ -621,6 +648,8 @@ async function runArticleEdit(t, input) {
621
648
  409,
622
649
  state.revisionId
623
650
  );
651
+ if (state.document?.tag_ids && !equal([...state.document.tag_ids].sort(), (after.tags ?? []).map((tag) => tag.id).sort()))
652
+ throw new ArticleEditOperationError("VERIFICATION_CONFLICT", stage, "Published tags differ from the saved revision", 409, state.revisionId);
624
653
  const normalizedCards = state.document?.cards;
625
654
  if (normalizedCards && after.metadata?.cards?.length !== normalizedCards.length)
626
655
  throw new ArticleEditOperationError(
@@ -8,6 +8,10 @@ declare function articleEditHtmlMatches(html: string, expected: {
8
8
  title: string;
9
9
  content_html: string;
10
10
  card_titles: string[];
11
+ tags?: {
12
+ name: string;
13
+ slug: string;
14
+ }[];
11
15
  }): boolean;
12
16
 
13
17
  type PhotoOperationCheckpoint = {
@@ -48,7 +52,7 @@ declare class ArticlePhotoOperationError extends Error {
48
52
  constructor(code: string, stage: string, message: string, fileId?: string | undefined, status?: number | undefined);
49
53
  }
50
54
 
51
- type ArticleEditPatch = Partial<Pick<ArticleDocument, "title" | "subtitle" | "content_markdown" | "excerpt" | "section" | "community" | "format" | "layout" | "cards" | "citations">>;
55
+ type ArticleEditPatch = Partial<Pick<ArticleDocument, "title" | "subtitle" | "content_markdown" | "excerpt" | "section" | "community" | "format" | "layout" | "cards" | "citations" | "tag_ids">>;
52
56
  type ArticleEditCheckpoint = {
53
57
  version: 1;
54
58
  identity: string;
@@ -93,6 +97,7 @@ type ArticleDocument = {
93
97
  excerpt?: string;
94
98
  cards?: unknown[];
95
99
  citations?: unknown[];
100
+ tag_ids?: string[];
96
101
  section: string;
97
102
  community?: string;
98
103
  format?: ArticleFormat;
@@ -195,6 +200,7 @@ type ArticleWorkflowOptions = {
195
200
  prioritized_narration_processing?: boolean;
196
201
  content_first_editor_document?: boolean;
197
202
  versioned_editorial_fields?: boolean;
203
+ versioned_editorial_tags?: boolean;
198
204
  managed_submission_version?: number | null;
199
205
  multipart_submission?: boolean;
200
206
  };
@@ -8,6 +8,10 @@ declare function articleEditHtmlMatches(html: string, expected: {
8
8
  title: string;
9
9
  content_html: string;
10
10
  card_titles: string[];
11
+ tags?: {
12
+ name: string;
13
+ slug: string;
14
+ }[];
11
15
  }): boolean;
12
16
 
13
17
  type PhotoOperationCheckpoint = {
@@ -48,7 +52,7 @@ declare class ArticlePhotoOperationError extends Error {
48
52
  constructor(code: string, stage: string, message: string, fileId?: string | undefined, status?: number | undefined);
49
53
  }
50
54
 
51
- type ArticleEditPatch = Partial<Pick<ArticleDocument, "title" | "subtitle" | "content_markdown" | "excerpt" | "section" | "community" | "format" | "layout" | "cards" | "citations">>;
55
+ type ArticleEditPatch = Partial<Pick<ArticleDocument, "title" | "subtitle" | "content_markdown" | "excerpt" | "section" | "community" | "format" | "layout" | "cards" | "citations" | "tag_ids">>;
52
56
  type ArticleEditCheckpoint = {
53
57
  version: 1;
54
58
  identity: string;
@@ -93,6 +97,7 @@ type ArticleDocument = {
93
97
  excerpt?: string;
94
98
  cards?: unknown[];
95
99
  citations?: unknown[];
100
+ tag_ids?: string[];
96
101
  section: string;
97
102
  community?: string;
98
103
  format?: ArticleFormat;
@@ -195,6 +200,7 @@ type ArticleWorkflowOptions = {
195
200
  prioritized_narration_processing?: boolean;
196
201
  content_first_editor_document?: boolean;
197
202
  versioned_editorial_fields?: boolean;
203
+ versioned_editorial_tags?: boolean;
198
204
  managed_submission_version?: number | null;
199
205
  multipart_submission?: boolean;
200
206
  };
@@ -41,6 +41,17 @@ function articleEditHtmlMatches(html, expected) {
41
41
  const all = text(visible);
42
42
  if (!paragraphs.length || !paragraphs.every((p) => all.includes(p)))
43
43
  return false;
44
+ if (expected.tags?.length) {
45
+ const links = [...visible.matchAll(/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/giu)];
46
+ if (!expected.tags.every((tag) => links.some((link) => {
47
+ try {
48
+ const url = new URL(decode(link[1]), "https://publication.example");
49
+ return url.pathname === "/news" && url.searchParams.get("tag") === tag.slug && text(link[2]).replace(/^#\s*/u, "") === tag.name;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }))) return false;
54
+ }
44
55
  const titles = [
45
56
  ...visible.matchAll(
46
57
  /<aside\b[^>]*class=["'][^"']*\barticle-verified-card\b[^"']*["'][^>]*>([\s\S]*?)<\/aside>/giu
@@ -345,6 +356,16 @@ var canonical2 = (v) => Array.isArray(v) ? v.map(canonical2) : v && typeof v ===
345
356
  Object.keys(v).sort().map((k) => [k, canonical2(v[k])])
346
357
  ) : v;
347
358
  var equal = (a, b) => JSON.stringify(canonical2(a)) === JSON.stringify(canonical2(b));
359
+ var uuidIdentity = (value) => {
360
+ if (typeof value !== "string" || !/^(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i.test(value)) return value;
361
+ return value.replaceAll("-", "").toLowerCase();
362
+ };
363
+ var comparableDocument = (document) => document && typeof document === "object" ? { ...document, ...Array.isArray(document.media) ? { media: document.media.map((media) => ({
364
+ ...media,
365
+ contribution_id: uuidIdentity(media.contribution_id),
366
+ file_id: uuidIdentity(media.file_id)
367
+ })) } : {} } : document;
368
+ var documentsEqual = (a, b) => equal(comparableDocument(a), comparableDocument(b));
348
369
  var digest2 = async (v) => [
349
370
  ...new Uint8Array(
350
371
  await crypto.subtle.digest(
@@ -363,7 +384,8 @@ var patchKeys = /* @__PURE__ */ new Set([
363
384
  "format",
364
385
  "layout",
365
386
  "cards",
366
- "citations"
387
+ "citations",
388
+ "tag_ids"
367
389
  ]);
368
390
  function validate(input) {
369
391
  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)
@@ -372,7 +394,10 @@ function validate(input) {
372
394
  );
373
395
  for (const [key, value] of Object.entries(input.patch)) {
374
396
  if (!patchKeys.has(key)) throw Error("Unsupported edit field: " + key);
375
- if (["cards", "citations"].includes(key)) {
397
+ if (key === "tag_ids") {
398
+ if (!Array.isArray(value) || value.length > 32 || new Set(value).size !== value.length || value.some((id) => typeof id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(id) || /^0{8}-0{4}-0{4}-0{4}-0{12}$/u.test(id)))
399
+ throw Error("tag_ids must contain at most 32 distinct, nonempty UUIDs");
400
+ } else if (["cards", "citations"].includes(key)) {
376
401
  if (!Array.isArray(value)) throw Error(key + " must be an array");
377
402
  } else if (key === "layout") {
378
403
  if (!value || typeof value !== "object" || typeof value.id !== "string" || !Number.isInteger(value.version))
@@ -456,6 +481,8 @@ async function runArticleEdit(t, input) {
456
481
  stage,
457
482
  "The server must expose versioned_editorial_fields before this edit; no article was changed"
458
483
  );
484
+ if (input.patch.tag_ids !== void 0 && options.capabilities?.versioned_editorial_tags !== true)
485
+ throw new ArticleEditOperationError("CAPABILITY_UNAVAILABLE", stage, "The server must expose versioned_editorial_tags before changing tags; no article was changed");
459
486
  if (!identityMatches(source))
460
487
  throw new ArticleEditOperationError(
461
488
  "ARTICLE_IDENTITY_CHANGED",
@@ -493,7 +520,7 @@ async function runArticleEdit(t, input) {
493
520
  state.revisionId
494
521
  );
495
522
  const selected = currentRevision(record);
496
- if (!selected || !equal(selected.document, state.document))
523
+ if (!selected || !documentsEqual(selected.document, state.document))
497
524
  throw new ArticleEditOperationError(
498
525
  "VERIFICATION_CONFLICT",
499
526
  stage,
@@ -561,7 +588,7 @@ async function runArticleEdit(t, input) {
561
588
  state.savedGeneration = saved.generation;
562
589
  await save("verify");
563
590
  record = await request(article + "/collaboration");
564
- if (record.current_revision_id !== state.revisionId || !equal(currentRevision(record)?.document, state.document))
591
+ if (record.current_revision_id !== state.revisionId || !documentsEqual(currentRevision(record)?.document, state.document))
565
592
  throw new ArticleEditOperationError(
566
593
  "ARTICLE_CHANGED",
567
594
  stage,
@@ -619,6 +646,8 @@ async function runArticleEdit(t, input) {
619
646
  409,
620
647
  state.revisionId
621
648
  );
649
+ if (state.document?.tag_ids && !equal([...state.document.tag_ids].sort(), (after.tags ?? []).map((tag) => tag.id).sort()))
650
+ throw new ArticleEditOperationError("VERIFICATION_CONFLICT", stage, "Published tags differ from the saved revision", 409, state.revisionId);
622
651
  const normalizedCards = state.document?.cards;
623
652
  if (normalizedCards && after.metadata?.cards?.length !== normalizedCards.length)
624
653
  throw new ArticleEditOperationError(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalemule/news-publication",
3
- "version": "0.0.84",
3
+ "version": "0.0.86",
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": {