@scalemule/news-publication 0.0.79 → 0.0.81

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
@@ -338,6 +338,79 @@ The platform contract and a complete example live in
338
338
  `docs/news-platform/ARTICLE-WORKFLOW-V1.md` in the ScaleMule meta-repo.
339
339
  Do not use direct SQL, S3 paths, base64 bodies or invented media readiness.
340
340
 
341
+ ### Edit article prose, cards and citations
342
+
343
+ ```sh
344
+ npx --yes --package @scalemule/news-publication@0.0.81 scalemule-article edit \
345
+ --url "$ARTICLE_URL" --patch ./approved-edit.json --operation-key correction-v1
346
+ ```
347
+
348
+ The patch may contain title, subtitle, content_markdown, excerpt, section,
349
+ community, format, layout, cards and citations. It preserves omitted fields and
350
+ media and saves one immutable revision. Default: save for review. Add
351
+ `--release` only to request release through the existing policy; the command
352
+ never issues APPROVE or downgrades review. On a legacy article, explicitly choose
353
+ `--enable-review-policy` only as a legitimate editorial onboarding decision.
354
+ Scheduled legacy articles stop before onboarding. Normal API-key/member grants
355
+ remain authoritative; the deployed server must advertise
356
+ `versioned_editorial_fields`.
357
+
358
+ The 10-second default deadline bounds the operation; `--timeout-ms` may raise it
359
+ to at most 120 seconds. Keep the same patch/key and private checkpoint
360
+ (`--state` or `~/.scalemule/newsroom/operations`) on retries. The checkpoint
361
+ contains the submitted document and private provenance: never publish it.
362
+ Unknown save/release responses reconcile against the original revision; another
363
+ current revision stops rather than being overwritten. Completed replay and no-op
364
+ edits add no revision. `RELEASE_HELD`/exit 2 preserves the saved revision while
365
+ review is pending. With `--url --release`, success requires the rendered
366
+ headline, prose and actual card components; public visibility pending is also
367
+ exit 2. A saved revision and a rendered published correction are separate states.
368
+
369
+ Use the same client method, `client.editArticle`, for a caller-supplied durable
370
+ checkpoint store. This is local resumability over the existing server save
371
+ receipt, not a distributed transaction covering entities, syndication, media
372
+ commissioning or human review. The notices-only `/editorial` endpoint is not
373
+ an article-body/card editor. Routine content corrections require no backend PR.
374
+
375
+ ### Replace a published article photo from a local file
376
+
377
+ ```sh
378
+ npx --yes --package @scalemule/news-publication@0.0.80 scalemule-article replace-photo \
379
+ --url "$ARTICLE_URL" --file ./venue-exterior.jpg \
380
+ --metadata ./photo.json --operation-key venue-exterior-v1
381
+ ```
382
+
383
+ Use the normal authorized member's `SCALEMULE_API_KEY` and
384
+ `SCALEMULE_ACCESS_TOKEN`. The caller needs developer-level asset access and
385
+ publication approval rights. Supply caption, credit, factual alt, confirmed rights,
386
+ explicit `aiGenerated` and `label` (null for an ordinary photo) in `photo.json`.
387
+ The URL resolves the authorized publication and article; no UUID search or
388
+ publication build is needed. This does not find a photo or establish rights.
389
+
390
+ `uploadAndReplacePublishedCover` implements the same operation for applications.
391
+ It transfers bytes through managed Storage, preserves private capture evidence,
392
+ waits for a clean scan, registers Photo, prepares the publication's lead,
393
+ thumbnail and social renditions, reads measured delivery receipts and performs
394
+ one article-version-fenced media write. It verifies unchanged text/audio and,
395
+ when given `--url`, checks the selected lead and social URLs in the public HTML.
396
+ It requires the deployed content-first document and member Photo endpoints.
397
+
398
+ A private atomic checkpoint lives under `~/.scalemule/newsroom/operations`
399
+ (or `--state PATH`). Keep it: it contains a temporary upload credential until
400
+ completion. Reuse the same file, metadata and operation key after interruption.
401
+ A completed replay verifies the same attachment without uploading or writing
402
+ again. A concurrent article change stops instead of overwriting the editor.
403
+ The default per-run deadline is 30 seconds, adjustable with `--timeout-ms` up to
404
+ 120 seconds; timeouts name the blocked stage and preserve progress. Exit 2 means
405
+ API attachment succeeded but public HTML visibility is still pending. A timeout
406
+ is not a success receipt. This is local resumability, not a distributed
407
+ exactly-once service or a guaranteed image-processing SLA.
408
+
409
+ This published-cover convenience lane stops before upload for collaboration
410
+ articles. Their immutable revision, approval and release gates still apply.
411
+ Animated GIF/APNG/WebP and videos use the Video workflow; this lane accepts
412
+ still JPEG, PNG and WebP up to 25 MiB.
413
+
341
414
  ### Replace an existing, prepared cover
342
415
 
343
416
  For a legacy published article and an already public, scanned Photo asset,
@@ -1,16 +1,41 @@
1
1
  #!/usr/bin/env node
2
- import { readFile } from 'node:fs/promises';
2
+ import { readFile, mkdir, writeFile, rename, open, unlink } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join, basename } from 'node:path';
5
+ import { createHash } from 'node:crypto';
3
6
  import { parseArgs } from 'node:util';
4
- import { PublicationArticleClient } from '../dist/article-workflow-client.js';
7
+ import { PublicationArticleClient, articlePhotoHtmlMatches,articleEditHtmlMatches } from '../dist/article-workflow-client.js';
5
8
 
6
9
  const help = `Usage:
7
10
  scalemule-article inspect --publication ID --article ID
8
11
  scalemule-article replace-cover --publication ID --article ID --file-id ID --expected-version N --metadata photo.json
12
+ scalemule-article replace-photo --url ARTICLE_URL --file PHOTO --metadata photo.json --operation-key KEY
13
+ scalemule-article edit --url ARTICLE_URL --patch edit.json --operation-key KEY [--release]
9
14
 
10
15
  Credentials: SCALEMULE_API_KEY and SCALEMULE_ACCESS_TOKEN (normal authorized member).
11
16
  Platform admins may use their normal access token with --application-id ID.
12
17
  Optional --api-url URL defaults to https://api.scalemule.com.
13
18
 
19
+ edit saves prose, excerpt, cards and citations in one immutable revision.
20
+ Unspecified fields and media are retained. Default: save for review.
21
+ --release explicitly requests release under the existing policy; it never issues
22
+ APPROVE or changes a policy. Legacy onboarding requires an explicit
23
+ --enable-review-policy value. Scheduled legacy articles stop before onboarding.
24
+ Use --expected-generation when coordinating an edit with another editor.
25
+ Default edit deadline: 10000 ms. Reuse the same patch/key/checkpoint on retry.
26
+ RELEASE_HELD means the revision is saved and existing review gates remain.
27
+
28
+ replace-photo accepts a local file and uses normal member/API-key credentials.
29
+ It requires developer-level asset access and publication approval rights.
30
+ It resolves the publication from its authorized API inventory, saves a private
31
+ upload checkpoint, resumes the same multipart part, waits for scan and measured
32
+ publication renditions, then attaches with the original article version fence.
33
+ It verifies article invariants and, with --url, the public page. The default
34
+ deadline is 30 seconds per run; a failure names its stage and preserves progress.
35
+ Reuse the same --operation-key, metadata and file when resuming. Optional
36
+ --state PATH and --timeout-ms N (maximum 120000). Review-managed articles stop
37
+ before uploading and require their existing exact-revision editorial workflow.
38
+
14
39
  replace-cover selects an already public, scanned Photo asset. It reads measured
15
40
  hero and social renditions, preserves the article text, and changes media with
16
41
  the supplied version fence. It never uploads raw bytes, republishes the story,
@@ -29,13 +54,35 @@ that every website/CDN cache already displays the new image.
29
54
  try {
30
55
  const { values, positionals } = parseArgs({
31
56
  allowPositionals: true, strict: true,
32
- options: Object.fromEntries(['publication', 'article', 'file-id', 'expected-version', 'metadata', 'application-id', 'api-url'].map(k => [k, { type: 'string' }]).concat([['help', { type: 'boolean' }]])),
57
+ options: Object.fromEntries(['publication', 'article', 'file-id', 'expected-version', 'metadata', 'application-id', 'api-url', 'url', 'file', 'operation-key', 'state', 'timeout-ms','patch','expected-generation','enable-review-policy'].map(k => [k, { type: 'string' }]).concat([['help', { type: 'boolean' }],['release',{type:'boolean'}]])),
33
58
  });
34
59
  if (values.help || !positionals.length) {
35
60
  console.log(help);
36
61
  } else {
37
62
  const [command, ...extra] = positionals;
38
- if (extra.length || !['inspect', 'replace-cover'].includes(command)) throw Error('Use inspect or replace-cover; see --help');
63
+ const commandStarted = performance.now();
64
+ const budget = Number(values['timeout-ms'] ?? (command==='edit'?10000:30000));
65
+ if (!Number.isFinite(budget) || budget < 100 || budget > 120000) throw Error('--timeout-ms must be between 100 and 120000');
66
+ const remaining = () => { const ms = Math.floor(budget - (performance.now()-commandStarted)); if (ms < 1) throw Error('Operation deadline reached; resume the same operation'); return ms; };
67
+ if (extra.length || !['inspect', 'replace-cover', 'replace-photo','edit'].includes(command)) throw Error('Use inspect, edit, replace-cover or replace-photo; see --help');
68
+ if (values.url) {
69
+ if (!process.env.SCALEMULE_API_KEY || !process.env.SCALEMULE_ACCESS_TOKEN || values['application-id']) throw Error('URL resolution requires normal publication member/API-key credentials');
70
+ const url = new URL(values.url);
71
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) throw Error('Use the public HTTPS article URL, without a private review key');
72
+ const api = values['api-url'] ?? 'https://api.scalemule.com';
73
+ const apiOrigin = new URL(api); if (apiOrigin.protocol !== 'https:' || apiOrigin.username || apiOrigin.password) throw Error('Use an HTTPS platform API endpoint');
74
+ const headers = { 'x-api-key': process.env.SCALEMULE_API_KEY, authorization: 'Bearer '+process.env.SCALEMULE_ACCESS_TOKEN };
75
+ const get = async route => { const r = await fetch(api.replace(/\/$/,'')+route,{headers,redirect:'error',cache:'no-store',signal:AbortSignal.timeout(Math.min(10000,remaining()))}); const v = await r.json(); if (!r.ok || !v.success) throw Error(`Article resolution HTTP ${r.status}`); return v.data; };
76
+ const pubs = await get('/v1/blog/publications');
77
+ const pub = pubs.items?.find(p=>new URL(p.canonical_url).origin===url.origin);
78
+ if (!pub) throw Error('The caller has no publication grant for that article origin');
79
+ const match = /^\/news\/([^/]+)\/?$/.exec(url.pathname);
80
+ if (!match) throw Error('Use the publication article slug URL');
81
+ const resolved = await get(`/v1/blog/publications/${encodeURIComponent(pub.id)}/article-content/${encodeURIComponent(decodeURIComponent(match[1]))}`);
82
+ if (!resolved.article?.id) throw Error('No published article at this URL');
83
+ if ((values.publication && values.publication !== pub.id) || (values.article && values.article !== resolved.article.id)) throw Error('Article URL and supplied IDs disagree');
84
+ values.publication = pub.id; values.article = resolved.article.id;
85
+ }
39
86
  for (const key of ['publication', 'article']) if (!values[key]) throw Error(`Missing --${key}`);
40
87
  if (!process.env.SCALEMULE_ACCESS_TOKEN) throw Error('SCALEMULE_ACCESS_TOKEN is required; use normal member or platform admin authorization');
41
88
  if (!values['application-id'] && !process.env.SCALEMULE_API_KEY) throw Error('Member calls also require SCALEMULE_API_KEY');
@@ -62,6 +109,114 @@ try {
62
109
  capabilities: options.capabilities, narration: processing.narration,
63
110
  assets: processing.assets, collaboration, elapsed_ms: Math.round(performance.now() - started),
64
111
  }, null, 2));
112
+ } else if (command==='edit') {
113
+ for(const key of ['patch','operation-key'])if(!values[key])throw Error('Missing --'+key);
114
+ const patch=JSON.parse(await readFile(values.patch,'utf8'));
115
+ const key=createHash('sha256').update(JSON.stringify(['edit',values['api-url']??'https://api.scalemule.com',values.publication,values.article,values['operation-key']])).digest('hex');
116
+ const statePath=values.state??join(homedir(),'.scalemule','newsroom','operations',key+'.json');
117
+ await mkdir(dirname(statePath),{recursive:true,mode:0o700});
118
+ const lockPath=statePath+'.lock';let lock;
119
+ try{lock=await open(lockPath,'wx',0o600);}catch(error){
120
+ if(error.code!=='EEXIST')throw error;
121
+ const pid=Number(await readFile(lockPath,'utf8'));if(!Number.isSafeInteger(pid)||pid<1)throw Error('Invalid operation lock; inspect '+lockPath);
122
+ let alive=true;try{process.kill(pid,0);}catch(e){if(e.code==='ESRCH')alive=false;else throw e;}
123
+ if(alive)throw Error('This edit operation is already running');
124
+ await unlink(lockPath);lock=await open(lockPath,'wx',0o600);
125
+ }
126
+ await lock.writeFile(String(process.pid));await lock.close();
127
+ try{
128
+ let checkpoint;try{checkpoint=JSON.parse(await readFile(statePath,'utf8'));}catch(error){if(error.code!=='ENOENT')throw error;}
129
+ const result=await client.editArticle({articleId:values.article,operationKey:values['operation-key'],patch,
130
+ expectedGeneration:values['expected-generation']===undefined?undefined:Number(values['expected-generation']),
131
+ enableReviewPolicy:values['enable-review-policy'],release:values.release,checkpoint,timeoutMs:remaining(),
132
+ saveCheckpoint:async state=>{const temp=statePath+'.'+process.pid+'.tmp';await writeFile(temp,JSON.stringify(state),{mode:0o600});await rename(temp,statePath);checkpoint=state;},
133
+ });
134
+ if(values.url&&values.release&&['published','already_published'].includes(result.state)){
135
+ let verified=false,reason;
136
+ try{
137
+ const snapshot=await client.publicArticle(checkpoint.sourceIdentity.slug,AbortSignal.timeout(remaining()));
138
+ const article=snapshot.article;
139
+ if(!article||!article.content_html||article.id!==values.article)throw Error('Published reader snapshot is missing');
140
+ while(!verified){
141
+ const response=await fetch(values.url,{redirect:'error',cache:'no-store',signal:AbortSignal.timeout(Math.min(5000,remaining()))});
142
+ const html=await response.text();
143
+ verified=response.ok&&articleEditHtmlMatches(html,{title:article.title,content_html:article.content_html,card_titles:(article.metadata?.cards??[]).map(c=>c.title)});
144
+ if(!verified)await new Promise(r=>setTimeout(r,Math.min(300,remaining())));
145
+ }
146
+ }catch(error){reason=error.message;}
147
+ 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;}
148
+ else console.log(JSON.stringify({...result,public_page_verified:true,total_elapsed_ms:Math.round(performance.now()-commandStarted)},null,2));
149
+ }else{
150
+ console.log(JSON.stringify({...result,total_elapsed_ms:Math.round(performance.now()-commandStarted)},null,2));
151
+ }
152
+ }finally{await unlink(lockPath);}
153
+ } else if (command === 'replace-photo') {
154
+ for (const key of ['file','metadata','operation-key']) if (!values[key]) throw Error(`Missing --${key}`);
155
+ if (values['application-id']) throw Error('Local-photo intake uses normal publication member credentials');
156
+ const metadata = JSON.parse(await readFile(values.metadata,'utf8'));
157
+ for (const key of ['caption','credit','alt']) if (typeof metadata[key] !== 'string') throw Error(`photo.json requires a string ${key}`);
158
+ const key = createHash('sha256').update(JSON.stringify([values['api-url']??'https://api.scalemule.com',values.publication,values.article,values['operation-key']])).digest('hex');
159
+ const statePath = values.state ?? join(homedir(),'.scalemule','newsroom','operations',key+'.json');
160
+ await mkdir(dirname(statePath),{recursive:true,mode:0o700});
161
+ const lockPath = statePath+'.lock';
162
+ let lock;
163
+ try { lock = await open(lockPath,'wx',0o600); }
164
+ catch (error) {
165
+ if (error.code !== 'EEXIST') throw error;
166
+ const pid = Number(await readFile(lockPath,'utf8'));
167
+ if (!Number.isSafeInteger(pid) || pid < 1) throw Error('Operation lock is invalid; inspect '+lockPath);
168
+ let alive = true; try { process.kill(pid,0); } catch (e) { if (e.code === 'ESRCH') alive = false; else throw e; }
169
+ if (alive) throw Error('This operation is already running; use its existing result');
170
+ await unlink(lockPath); lock = await open(lockPath,'wx',0o600);
171
+ }
172
+ await lock.writeFile(String(process.pid)); await lock.close();
173
+ try {
174
+ let checkpoint;
175
+ try { checkpoint = JSON.parse(await readFile(statePath,'utf8')); } catch(error) { if (error.code !== 'ENOENT') throw error; }
176
+ const expectedVersion = Number(values['expected-version'] ?? checkpoint?.expectedVersion ?? (await client.processing(values.article,AbortSignal.timeout(remaining()))).version);
177
+ const bytes = await readFile(values.file);
178
+ const saveCheckpoint = async state => { const temp = statePath+'.'+process.pid+'.tmp'; await writeFile(temp,JSON.stringify(state),{mode:0o600}); await rename(temp,statePath); checkpoint = state; };
179
+ const result = await client.uploadAndReplacePublishedCover({
180
+ articleId:values.article, expectedVersion, operationKey:values['operation-key'],file:new Blob([bytes]),filename:basename(values.file),
181
+ caption:metadata.caption,credit:metadata.credit,alt:metadata.alt,rightsConfirmed:metadata.rightsConfirmed,aiGenerated:metadata.aiGenerated,label:metadata.label,focalPoint:metadata.focalPoint,
182
+ checkpoint,saveCheckpoint,timeoutMs:remaining(),
183
+ onProgress:event=>console.error(JSON.stringify(event)),
184
+ });
185
+ if (values.url) {
186
+ let visible = false;
187
+ let failure;
188
+ try {
189
+ const images = [checkpoint?.payload?.cover_image_url, checkpoint?.payload?.og_image_url];
190
+ const leads = new Set([images[0],...(checkpoint?.payload?.cover_image?.variants??[]).map(v=>v.url)].filter(Boolean));
191
+ while (!visible) {
192
+ const response = await fetch(values.url,{redirect:'error',cache:'no-store',signal:AbortSignal.timeout(Math.min(10000,remaining()))});
193
+ const html = await response.text();
194
+ visible = response.ok && images.every(Boolean) && articlePhotoHtmlMatches(html,[...leads],images[1]);
195
+ if (!visible) {
196
+ // Sites may select another measured responsive rendition of the
197
+ // same Photo. Read that evidence; never infer a CDN path or ID.
198
+ await Promise.all(['image/webp','image/jpeg','image/avif'].map(async accept=>{
199
+ const api=(values['api-url']??'https://api.scalemule.com').replace(/\/$/,'');
200
+ const r=await fetch(api+'/v1/photos/public/'+encodeURIComponent(result.file_id)+'/manifest?preset=hero',{headers:{accept},redirect:'error',signal:AbortSignal.timeout(Math.min(5000,remaining()))});
201
+ const value=await r.json();
202
+ if(!r.ok || !value.success || value.data?.file_id!==result.file_id) return;
203
+ for(const v of Object.values(value.data.variant_metadata??{})) {
204
+ if(!Number.isInteger(v.width)||v.width<1||!Number.isInteger(v.height)||v.height<1) continue;
205
+ const u=new URL(v.url);
206
+ if(u.protocol==='https:'&&!u.username&&!u.password&&!u.search&&!u.hash) leads.add(v.url);
207
+ }
208
+ }));
209
+ visible = response.ok && images.every(Boolean) && articlePhotoHtmlMatches(html,[...leads],images[1]);
210
+ }
211
+ if (!visible) await new Promise(resolve=>setTimeout(resolve,Math.min(500,remaining())));
212
+ }
213
+ } catch(error) { failure = error.message; }
214
+ if (!visible) {
215
+ console.error(JSON.stringify({...result,state:'attached_public_verification_pending',stage:'public_page',message:'The API attachment is verified; public HTML visibility remains pending. Resume this same operation.',reason:failure}));
216
+ process.exitCode = 2;
217
+ } else console.log(JSON.stringify({...result,public_page_verified:true,total_elapsed_ms:Math.round(performance.now()-commandStarted)},null,2));
218
+ } else console.log(JSON.stringify(result,null,2));
219
+ } finally { await unlink(lockPath); }
65
220
  } else {
66
221
  for (const key of ['file-id', 'expected-version', 'metadata']) if (!values[key]) throw Error(`Missing --${key}`);
67
222
  const metadata = JSON.parse(await readFile(values.metadata, 'utf8'));
@@ -78,6 +233,6 @@ try {
78
233
  }
79
234
  }
80
235
  } catch (error) {
81
- console.error(JSON.stringify({ error: error.message, status: error.status }));
82
- process.exitCode = 1;
236
+ console.error(JSON.stringify({ error: error.message, status: error.status, code:error.code, stage:error.stage, file_id:error.fileId,revision_id:error.revisionId }));
237
+ process.exitCode = error.code==='RELEASE_HELD'?2:1;
83
238
  }
@@ -18,4 +18,4 @@ declare function articlePresentation(value?: ArticlePresentation | null): Articl
18
18
  declare function articleHeroRatio(layout: ArticleLayout): number;
19
19
  declare function articleFormatLabel(format?: ArticleFormat | null): string | undefined;
20
20
 
21
- export { type ArticleFormat as A, type ArticleLayout as a, type ArticlePresentation as b, articleFormatLabel as c, articleHeroRatio as d, articlePresentation as e };
21
+ export { type ArticleFormat as A, type ArticlePresentation as a, type ArticleLayout as b, articleFormatLabel as c, articleHeroRatio as d, articlePresentation as e };
@@ -18,4 +18,4 @@ declare function articlePresentation(value?: ArticlePresentation | null): Articl
18
18
  declare function articleHeroRatio(layout: ArticleLayout): number;
19
19
  declare function articleFormatLabel(format?: ArticleFormat | null): string | undefined;
20
20
 
21
- export { type ArticleFormat as A, type ArticleLayout as a, type ArticlePresentation as b, articleFormatLabel as c, articleHeroRatio as d, articlePresentation as e };
21
+ export { type ArticleFormat as A, type ArticlePresentation as a, type ArticleLayout as b, articleFormatLabel as c, articleHeroRatio as d, articlePresentation as e };