@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 CHANGED
@@ -328,15 +328,88 @@ The publisher-supplied `PawMark` preserves the approved SVG paths. Hosts may set
328
328
 
329
329
  ### Standard article submission
330
330
 
331
- Server tools and authorized agents use `PublicationArticleClient` from
332
- `@scalemule/news-publication/article-workflow`. Discover `options()` first;
333
- require `workflow_version: 1`. Use `submitArticle()` for a private draft plus
334
- managed photo staging, or `get()` and `save()` for a generation-fenced edit.
335
- Keep the operation key when resuming. Editorial approval and release remain
336
- explicit commands; acceptance does not certify scan, image or audio completion.
337
- The platform contract and a complete example live in
338
- `docs/news-platform/ARTICLE-WORKFLOW-V1.md` in the ScaleMule meta-repo.
339
- Do not use direct SQL, S3 paths, base64 bodies or invented media readiness.
331
+ Submit through the server-owned API using `PublicationArticleClient` from
332
+ `@scalemule/news-publication/article-workflow`:
333
+
334
+ ```ts
335
+ const receipt = await client.submitArticle({
336
+ operationKey: "story-intake-v1", authorProfileId: authorId,
337
+ document: { title, content_markdown: body, section: "Local Business" },
338
+ intent: "publish",
339
+ assets: [{ file: photo, filename: "venue.jpg", position: "hero",
340
+ metadata: { alt: "Exterior of the actual venue", credit: "Photographer",
341
+ rights_confirmed: true } }],
342
+ });
343
+ ```
344
+
345
+ This sends **one POST** to `/v1/blog/publications/{id}/submissions`, with a
346
+ multipart manifest and its files. JSON without files is supported by
347
+ `client.submit(manifest, key)`. The same API accepts `article.operation: "save"`
348
+ with the existing article ID, expected generation, complete document and revision
349
+ reason. The server owns private Photo/Storage intake, scan checks, immutable
350
+ media attachment, required private narration and release. Optional narration
351
+ and missing-visual commissioning use the existing post-publication workers.
352
+ Existing subject/editorial/sensitivity approvals still apply. `draft` is the
353
+ safe default; `publish` requests release after the configured gates.
354
+
355
+ An accepted receipt is **not** a claim that media is ready or a page has been
356
+ verified. Read `client.submission(receipt.submission_id)` for holds, processing
357
+ and public readiness. After a timeout, replay the identical manifest, files and
358
+ operation key. Do not re-upload through other endpoints or create a new article.
359
+ A foreign revision, rejected media, expired replay or unconfirmed asset intake
360
+ stops with a retained receipt rather than silently overwriting or duplicating.
361
+ No client-side fallback performs a separate multi-service publishing sequence.
362
+ Discover `managed_submission_version: 1` before adopting this endpoint on an
363
+ older server. Supported files are still JPEG/PNG/WebP photographs, not video or
364
+ animation. Never use SQL, S3 paths, embedded binary or invented ready flags.
365
+
366
+ CLI equivalents:
367
+
368
+ ```sh
369
+ scalemule-article submit --publication "$PUBLICATION_ID" \
370
+ --manifest article.json --asset hero=./venue.jpg --operation-key story-intake-v1
371
+ scalemule-article status --publication "$PUBLICATION_ID" --submission "$SUBMISSION_ID"
372
+ ```
373
+
374
+ The manifest uses the API's snake_case fields: `article`, `intent`, and optional
375
+ `assets: [{slot: "hero", position: "hero", metadata: {...}}]`. Each `--asset`
376
+ matches one declared slot. Credentials are normal project/member credentials.
377
+ The operation key is retained for 30 days. A legacy article must first have an
378
+ explicitly selected collaboration policy; this endpoint never downgrades review.
379
+
380
+ ### Edit article prose, cards and citations
381
+
382
+ ```sh
383
+ npx --yes --package @scalemule/news-publication@0.0.81 scalemule-article edit \
384
+ --url "$ARTICLE_URL" --patch ./approved-edit.json --operation-key correction-v1
385
+ ```
386
+
387
+ The patch may contain title, subtitle, content_markdown, excerpt, section,
388
+ community, format, layout, cards and citations. It preserves omitted fields and
389
+ media and saves one immutable revision. Default: save for review. Add
390
+ `--release` only to request release through the existing policy; the command
391
+ never issues APPROVE or downgrades review. On a legacy article, explicitly choose
392
+ `--enable-review-policy` only as a legitimate editorial onboarding decision.
393
+ Scheduled legacy articles stop before onboarding. Normal API-key/member grants
394
+ remain authoritative; the deployed server must advertise
395
+ `versioned_editorial_fields`.
396
+
397
+ The 10-second default deadline bounds the operation; `--timeout-ms` may raise it
398
+ to at most 120 seconds. Keep the same patch/key and private checkpoint
399
+ (`--state` or `~/.scalemule/newsroom/operations`) on retries. The checkpoint
400
+ contains the submitted document and private provenance: never publish it.
401
+ Unknown save/release responses reconcile against the original revision; another
402
+ current revision stops rather than being overwritten. Completed replay and no-op
403
+ edits add no revision. `RELEASE_HELD`/exit 2 preserves the saved revision while
404
+ review is pending. With `--url --release`, success requires the rendered
405
+ headline, prose and actual card components; public visibility pending is also
406
+ exit 2. A saved revision and a rendered published correction are separate states.
407
+
408
+ Use the same client method, `client.editArticle`, for a caller-supplied durable
409
+ checkpoint store. This is local resumability over the existing server save
410
+ receipt, not a distributed transaction covering entities, syndication, media
411
+ commissioning or human review. The notices-only `/editorial` endpoint is not
412
+ an article-body/card editor. Routine content corrections require no backend PR.
340
413
 
341
414
  ### Replace a published article photo from a local file
342
415
 
@@ -1,20 +1,32 @@
1
1
  #!/usr/bin/env node
2
- import { readFile, mkdir, writeFile, rename, open, unlink } from 'node:fs/promises';
2
+ import { readFile, stat, mkdir, writeFile, rename, open, unlink } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join, basename } from 'node:path';
5
5
  import { createHash } from 'node:crypto';
6
6
  import { parseArgs } from 'node:util';
7
- import { PublicationArticleClient, articlePhotoHtmlMatches } from '../dist/article-workflow-client.js';
7
+ import { PublicationArticleClient, articlePhotoHtmlMatches,articleEditHtmlMatches } from '../dist/article-workflow-client.js';
8
8
 
9
9
  const help = `Usage:
10
+ scalemule-article submit --publication ID --manifest article.json --operation-key KEY [--asset hero=/path/photo.jpg]
11
+ scalemule-article status --publication ID --submission ID
10
12
  scalemule-article inspect --publication ID --article ID
11
13
  scalemule-article replace-cover --publication ID --article ID --file-id ID --expected-version N --metadata photo.json
12
14
  scalemule-article replace-photo --url ARTICLE_URL --file PHOTO --metadata photo.json --operation-key KEY
15
+ scalemule-article edit --url ARTICLE_URL --patch edit.json --operation-key KEY [--release]
13
16
 
14
17
  Credentials: SCALEMULE_API_KEY and SCALEMULE_ACCESS_TOKEN (normal authorized member).
15
18
  Platform admins may use their normal access token with --application-id ID.
16
19
  Optional --api-url URL defaults to https://api.scalemule.com.
17
20
 
21
+ edit saves prose, excerpt, cards and citations in one immutable revision.
22
+ Unspecified fields and media are retained. Default: save for review.
23
+ --release explicitly requests release under the existing policy; it never issues
24
+ APPROVE or changes a policy. Legacy onboarding requires an explicit
25
+ --enable-review-policy value. Scheduled legacy articles stop before onboarding.
26
+ Use --expected-generation when coordinating an edit with another editor.
27
+ Default edit deadline: 10000 ms. Reuse the same patch/key/checkpoint on retry.
28
+ RELEASE_HELD means the revision is saved and existing review gates remain.
29
+
18
30
  replace-photo accepts a local file and uses normal member/API-key credentials.
19
31
  It requires developer-level asset access and publication approval rights.
20
32
  It resolves the publication from its authorized API inventory, saves a private
@@ -44,17 +56,17 @@ that every website/CDN cache already displays the new image.
44
56
  try {
45
57
  const { values, positionals } = parseArgs({
46
58
  allowPositionals: true, strict: true,
47
- options: Object.fromEntries(['publication', 'article', 'file-id', 'expected-version', 'metadata', 'application-id', 'api-url', 'url', 'file', 'operation-key', 'state', 'timeout-ms'].map(k => [k, { type: 'string' }]).concat([['help', { type: 'boolean' }]])),
59
+ 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','manifest','submission'].map(k => [k, { type: 'string' }]).concat([['asset',{type:'string',multiple:true}],['help', { type: 'boolean' }],['release',{type:'boolean'}]])),
48
60
  });
49
61
  if (values.help || !positionals.length) {
50
62
  console.log(help);
51
63
  } else {
52
64
  const [command, ...extra] = positionals;
53
65
  const commandStarted = performance.now();
54
- const budget = Number(values['timeout-ms'] ?? 30000);
66
+ const budget = Number(values['timeout-ms'] ?? (command==='edit'?10000:30000));
55
67
  if (!Number.isFinite(budget) || budget < 100 || budget > 120000) throw Error('--timeout-ms must be between 100 and 120000');
56
68
  const remaining = () => { const ms = Math.floor(budget - (performance.now()-commandStarted)); if (ms < 1) throw Error('Operation deadline reached; resume the same operation'); return ms; };
57
- if (extra.length || !['inspect', 'replace-cover', 'replace-photo'].includes(command)) throw Error('Use inspect, replace-cover or replace-photo; see --help');
69
+ if (extra.length || !['submit','status','inspect', 'replace-cover', 'replace-photo','edit'].includes(command)) throw Error('Use submit, status, inspect, edit, replace-cover or replace-photo; see --help');
58
70
  if (values.url) {
59
71
  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');
60
72
  const url = new URL(values.url);
@@ -73,7 +85,7 @@ try {
73
85
  if ((values.publication && values.publication !== pub.id) || (values.article && values.article !== resolved.article.id)) throw Error('Article URL and supplied IDs disagree');
74
86
  values.publication = pub.id; values.article = resolved.article.id;
75
87
  }
76
- for (const key of ['publication', 'article']) if (!values[key]) throw Error(`Missing --${key}`);
88
+ for (const key of (['submit','status'].includes(command)?['publication']:['publication','article'])) if (!values[key]) throw Error(`Missing --${key}`);
77
89
  if (!process.env.SCALEMULE_ACCESS_TOKEN) throw Error('SCALEMULE_ACCESS_TOKEN is required; use normal member or platform admin authorization');
78
90
  if (!values['application-id'] && !process.env.SCALEMULE_API_KEY) throw Error('Member calls also require SCALEMULE_API_KEY');
79
91
  const client = new PublicationArticleClient({
@@ -84,7 +96,22 @@ try {
84
96
  accessToken: process.env.SCALEMULE_ACCESS_TOKEN,
85
97
  });
86
98
  const started = performance.now();
87
- if (command === 'inspect') {
99
+ if(command==='submit'){
100
+ for(const key of ['manifest','operation-key'])if(!values[key])throw Error('Missing --'+key);
101
+ const manifest=JSON.parse(await readFile(values.manifest,'utf8'));
102
+ const assets=[];
103
+ for(const entry of values.asset??[]){
104
+ const split=entry.indexOf('=');if(split<1)throw Error('--asset must be slot=/path/to/photo');
105
+ const slot=entry.slice(0,split),path=entry.slice(split+1);
106
+ const info=await stat(path);if(!info.isFile()||info.size<1||info.size>25*1024*1024)throw Error('Photo must be a regular file between 1 byte and 25 MiB');
107
+ assets.push({slot,filename:basename(path),file:new Blob([await readFile(path)])});
108
+ }
109
+ const receipt=await client.submit(manifest,values['operation-key'],assets,AbortSignal.timeout(remaining()));
110
+ console.log(JSON.stringify({...receipt,elapsed_ms:Math.round(performance.now()-started)},null,2));
111
+ }else if(command==='status'){
112
+ if(!values.submission)throw Error('Missing --submission');
113
+ console.log(JSON.stringify(await client.submission(values.submission,AbortSignal.timeout(remaining())),null,2));
114
+ }else if (command === 'inspect') {
88
115
  const [options, processing, collaboration] = await Promise.all([
89
116
  client.options(values.article), client.processing(values.article),
90
117
  client.get(values.article).then(current => ({ enabled: true, generation: current.generation, status: current.status })).catch(error => {
@@ -99,6 +126,47 @@ try {
99
126
  capabilities: options.capabilities, narration: processing.narration,
100
127
  assets: processing.assets, collaboration, elapsed_ms: Math.round(performance.now() - started),
101
128
  }, null, 2));
129
+ } else if (command==='edit') {
130
+ for(const key of ['patch','operation-key'])if(!values[key])throw Error('Missing --'+key);
131
+ const patch=JSON.parse(await readFile(values.patch,'utf8'));
132
+ const key=createHash('sha256').update(JSON.stringify(['edit',values['api-url']??'https://api.scalemule.com',values.publication,values.article,values['operation-key']])).digest('hex');
133
+ const statePath=values.state??join(homedir(),'.scalemule','newsroom','operations',key+'.json');
134
+ await mkdir(dirname(statePath),{recursive:true,mode:0o700});
135
+ const lockPath=statePath+'.lock';let lock;
136
+ try{lock=await open(lockPath,'wx',0o600);}catch(error){
137
+ if(error.code!=='EEXIST')throw error;
138
+ const pid=Number(await readFile(lockPath,'utf8'));if(!Number.isSafeInteger(pid)||pid<1)throw Error('Invalid operation lock; inspect '+lockPath);
139
+ let alive=true;try{process.kill(pid,0);}catch(e){if(e.code==='ESRCH')alive=false;else throw e;}
140
+ if(alive)throw Error('This edit operation is already running');
141
+ await unlink(lockPath);lock=await open(lockPath,'wx',0o600);
142
+ }
143
+ await lock.writeFile(String(process.pid));await lock.close();
144
+ try{
145
+ let checkpoint;try{checkpoint=JSON.parse(await readFile(statePath,'utf8'));}catch(error){if(error.code!=='ENOENT')throw error;}
146
+ const result=await client.editArticle({articleId:values.article,operationKey:values['operation-key'],patch,
147
+ expectedGeneration:values['expected-generation']===undefined?undefined:Number(values['expected-generation']),
148
+ enableReviewPolicy:values['enable-review-policy'],release:values.release,checkpoint,timeoutMs:remaining(),
149
+ saveCheckpoint:async state=>{const temp=statePath+'.'+process.pid+'.tmp';await writeFile(temp,JSON.stringify(state),{mode:0o600});await rename(temp,statePath);checkpoint=state;},
150
+ });
151
+ if(values.url&&values.release&&['published','already_published'].includes(result.state)){
152
+ let verified=false,reason;
153
+ try{
154
+ const snapshot=await client.publicArticle(checkpoint.sourceIdentity.slug,AbortSignal.timeout(remaining()));
155
+ const article=snapshot.article;
156
+ if(!article||!article.content_html||article.id!==values.article)throw Error('Published reader snapshot is missing');
157
+ while(!verified){
158
+ const response=await fetch(values.url,{redirect:'error',cache:'no-store',signal:AbortSignal.timeout(Math.min(5000,remaining()))});
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)});
161
+ if(!verified)await new Promise(r=>setTimeout(r,Math.min(300,remaining())));
162
+ }
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;}
165
+ else console.log(JSON.stringify({...result,public_page_verified:true,total_elapsed_ms:Math.round(performance.now()-commandStarted)},null,2));
166
+ }else{
167
+ console.log(JSON.stringify({...result,total_elapsed_ms:Math.round(performance.now()-commandStarted)},null,2));
168
+ }
169
+ }finally{await unlink(lockPath);}
102
170
  } else if (command === 'replace-photo') {
103
171
  for (const key of ['file','metadata','operation-key']) if (!values[key]) throw Error(`Missing --${key}`);
104
172
  if (values['application-id']) throw Error('Local-photo intake uses normal publication member credentials');
@@ -182,6 +250,6 @@ try {
182
250
  }
183
251
  }
184
252
  } catch (error) {
185
- console.error(JSON.stringify({ error: error.message, status: error.status, code:error.code, stage:error.stage, file_id:error.fileId }));
186
- process.exitCode = 1;
253
+ console.error(JSON.stringify({ error: error.message, status: error.status, code:error.code, stage:error.stage, file_id:error.fileId,revision_id:error.revisionId }));
254
+ process.exitCode = error.code==='RELEASE_HELD'?2:1;
187
255
  }