@scalemule/news-publication 0.0.84 → 0.0.85

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
@@ -365,7 +376,8 @@ var patchKeys = /* @__PURE__ */ new Set([
365
376
  "format",
366
377
  "layout",
367
378
  "cards",
368
- "citations"
379
+ "citations",
380
+ "tag_ids"
369
381
  ]);
370
382
  function validate(input) {
371
383
  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 +386,10 @@ function validate(input) {
374
386
  );
375
387
  for (const [key, value] of Object.entries(input.patch)) {
376
388
  if (!patchKeys.has(key)) throw Error("Unsupported edit field: " + key);
377
- if (["cards", "citations"].includes(key)) {
389
+ if (key === "tag_ids") {
390
+ 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)))
391
+ throw Error("tag_ids must contain at most 32 distinct, nonempty UUIDs");
392
+ } else if (["cards", "citations"].includes(key)) {
378
393
  if (!Array.isArray(value)) throw Error(key + " must be an array");
379
394
  } else if (key === "layout") {
380
395
  if (!value || typeof value !== "object" || typeof value.id !== "string" || !Number.isInteger(value.version))
@@ -458,6 +473,8 @@ async function runArticleEdit(t, input) {
458
473
  stage,
459
474
  "The server must expose versioned_editorial_fields before this edit; no article was changed"
460
475
  );
476
+ if (input.patch.tag_ids !== void 0 && options.capabilities?.versioned_editorial_tags !== true)
477
+ throw new ArticleEditOperationError("CAPABILITY_UNAVAILABLE", stage, "The server must expose versioned_editorial_tags before changing tags; no article was changed");
461
478
  if (!identityMatches(source))
462
479
  throw new ArticleEditOperationError(
463
480
  "ARTICLE_IDENTITY_CHANGED",
@@ -621,6 +638,8 @@ async function runArticleEdit(t, input) {
621
638
  409,
622
639
  state.revisionId
623
640
  );
641
+ if (state.document?.tag_ids && !equal([...state.document.tag_ids].sort(), (after.tags ?? []).map((tag) => tag.id).sort()))
642
+ throw new ArticleEditOperationError("VERIFICATION_CONFLICT", stage, "Published tags differ from the saved revision", 409, state.revisionId);
624
643
  const normalizedCards = state.document?.cards;
625
644
  if (normalizedCards && after.metadata?.cards?.length !== normalizedCards.length)
626
645
  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
@@ -363,7 +374,8 @@ var patchKeys = /* @__PURE__ */ new Set([
363
374
  "format",
364
375
  "layout",
365
376
  "cards",
366
- "citations"
377
+ "citations",
378
+ "tag_ids"
367
379
  ]);
368
380
  function validate(input) {
369
381
  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 +384,10 @@ function validate(input) {
372
384
  );
373
385
  for (const [key, value] of Object.entries(input.patch)) {
374
386
  if (!patchKeys.has(key)) throw Error("Unsupported edit field: " + key);
375
- if (["cards", "citations"].includes(key)) {
387
+ if (key === "tag_ids") {
388
+ 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)))
389
+ throw Error("tag_ids must contain at most 32 distinct, nonempty UUIDs");
390
+ } else if (["cards", "citations"].includes(key)) {
376
391
  if (!Array.isArray(value)) throw Error(key + " must be an array");
377
392
  } else if (key === "layout") {
378
393
  if (!value || typeof value !== "object" || typeof value.id !== "string" || !Number.isInteger(value.version))
@@ -456,6 +471,8 @@ async function runArticleEdit(t, input) {
456
471
  stage,
457
472
  "The server must expose versioned_editorial_fields before this edit; no article was changed"
458
473
  );
474
+ if (input.patch.tag_ids !== void 0 && options.capabilities?.versioned_editorial_tags !== true)
475
+ throw new ArticleEditOperationError("CAPABILITY_UNAVAILABLE", stage, "The server must expose versioned_editorial_tags before changing tags; no article was changed");
459
476
  if (!identityMatches(source))
460
477
  throw new ArticleEditOperationError(
461
478
  "ARTICLE_IDENTITY_CHANGED",
@@ -619,6 +636,8 @@ async function runArticleEdit(t, input) {
619
636
  409,
620
637
  state.revisionId
621
638
  );
639
+ if (state.document?.tag_ids && !equal([...state.document.tag_ids].sort(), (after.tags ?? []).map((tag) => tag.id).sort()))
640
+ throw new ArticleEditOperationError("VERIFICATION_CONFLICT", stage, "Published tags differ from the saved revision", 409, state.revisionId);
622
641
  const normalizedCards = state.document?.cards;
623
642
  if (normalizedCards && after.metadata?.cards?.length !== normalizedCards.length)
624
643
  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.85",
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": {