@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
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
|
-
|
|
332
|
-
`@scalemule/news-publication/article-workflow
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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',
|
|
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
|
|
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
|
}
|