@scalemule/news-publication 0.0.80 → 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,40 @@ 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
|
+
|
|
341
375
|
### Replace a published article photo from a local file
|
|
342
376
|
|
|
343
377
|
```sh
|
|
@@ -4,17 +4,27 @@ 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
10
|
scalemule-article inspect --publication ID --article ID
|
|
11
11
|
scalemule-article replace-cover --publication ID --article ID --file-id ID --expected-version N --metadata photo.json
|
|
12
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]
|
|
13
14
|
|
|
14
15
|
Credentials: SCALEMULE_API_KEY and SCALEMULE_ACCESS_TOKEN (normal authorized member).
|
|
15
16
|
Platform admins may use their normal access token with --application-id ID.
|
|
16
17
|
Optional --api-url URL defaults to https://api.scalemule.com.
|
|
17
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
|
+
|
|
18
28
|
replace-photo accepts a local file and uses normal member/API-key credentials.
|
|
19
29
|
It requires developer-level asset access and publication approval rights.
|
|
20
30
|
It resolves the publication from its authorized API inventory, saves a private
|
|
@@ -44,17 +54,17 @@ that every website/CDN cache already displays the new image.
|
|
|
44
54
|
try {
|
|
45
55
|
const { values, positionals } = parseArgs({
|
|
46
56
|
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' }]])),
|
|
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'}]])),
|
|
48
58
|
});
|
|
49
59
|
if (values.help || !positionals.length) {
|
|
50
60
|
console.log(help);
|
|
51
61
|
} else {
|
|
52
62
|
const [command, ...extra] = positionals;
|
|
53
63
|
const commandStarted = performance.now();
|
|
54
|
-
const budget = Number(values['timeout-ms'] ?? 30000);
|
|
64
|
+
const budget = Number(values['timeout-ms'] ?? (command==='edit'?10000:30000));
|
|
55
65
|
if (!Number.isFinite(budget) || budget < 100 || budget > 120000) throw Error('--timeout-ms must be between 100 and 120000');
|
|
56
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; };
|
|
57
|
-
if (extra.length || !['inspect', 'replace-cover', 'replace-photo'].includes(command)) throw Error('Use inspect, replace-cover or replace-photo; see --help');
|
|
67
|
+
if (extra.length || !['inspect', 'replace-cover', 'replace-photo','edit'].includes(command)) throw Error('Use inspect, edit, replace-cover or replace-photo; see --help');
|
|
58
68
|
if (values.url) {
|
|
59
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');
|
|
60
70
|
const url = new URL(values.url);
|
|
@@ -99,6 +109,47 @@ try {
|
|
|
99
109
|
capabilities: options.capabilities, narration: processing.narration,
|
|
100
110
|
assets: processing.assets, collaboration, elapsed_ms: Math.round(performance.now() - started),
|
|
101
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);}
|
|
102
153
|
} else if (command === 'replace-photo') {
|
|
103
154
|
for (const key of ['file','metadata','operation-key']) if (!values[key]) throw Error(`Missing --${key}`);
|
|
104
155
|
if (values['application-id']) throw Error('Local-photo intake uses normal publication member credentials');
|
|
@@ -182,6 +233,6 @@ try {
|
|
|
182
233
|
}
|
|
183
234
|
}
|
|
184
235
|
} 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;
|
|
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;
|
|
187
238
|
}
|
|
@@ -12,6 +12,47 @@ function articlePhotoHtmlMatches(html, lead, social) {
|
|
|
12
12
|
return image && openGraph;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// src/article-edit-verification.ts
|
|
16
|
+
var decode = (value) => value.replace(/&(?:amp|lt|gt|quot|apos|#39|#\d+|#x[\da-f]+);/giu, (s) => {
|
|
17
|
+
const named = {
|
|
18
|
+
"&": "&",
|
|
19
|
+
"<": "<",
|
|
20
|
+
">": ">",
|
|
21
|
+
""": '"',
|
|
22
|
+
"'": "'",
|
|
23
|
+
"'": "'"
|
|
24
|
+
};
|
|
25
|
+
if (named[s.toLowerCase()]) return named[s.toLowerCase()];
|
|
26
|
+
const hex = s.toLowerCase().startsWith("&#x");
|
|
27
|
+
const n = parseInt(s.slice(hex ? 3 : 2, -1), hex ? 16 : 10);
|
|
28
|
+
return Number.isFinite(n) && n >= 0 && n <= 1114111 ? String.fromCodePoint(n) : s;
|
|
29
|
+
});
|
|
30
|
+
var text = (html) => decode(html.replace(/<[^>]*>/gu, " ")).replace(/\s+/gu, " ").trim();
|
|
31
|
+
var plain = (value) => value.replace(/\s+/gu, " ").trim();
|
|
32
|
+
function articleEditHtmlMatches(html, expected) {
|
|
33
|
+
const visible = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/giu, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style>/giu, " ");
|
|
34
|
+
const headline = [...visible.matchAll(/<h1\b[^>]*>([\s\S]*?)<\/h1>/giu)].some(
|
|
35
|
+
(m) => text(m[1]) === plain(expected.title)
|
|
36
|
+
);
|
|
37
|
+
if (!headline) return false;
|
|
38
|
+
const paragraphs = [
|
|
39
|
+
...expected.content_html.matchAll(
|
|
40
|
+
/<(p|li|h[2-6]|blockquote)\b[^>]*>([\s\S]*?)<\/\1>/giu
|
|
41
|
+
)
|
|
42
|
+
].map((m) => text(m[2])).filter(Boolean);
|
|
43
|
+
const all = text(visible);
|
|
44
|
+
if (!paragraphs.length || !paragraphs.every((p) => all.includes(p)))
|
|
45
|
+
return false;
|
|
46
|
+
const titles = [
|
|
47
|
+
...visible.matchAll(
|
|
48
|
+
/<aside\b[^>]*class=["'][^"']*\barticle-verified-card\b[^"']*["'][^>]*>([\s\S]*?)<\/aside>/giu
|
|
49
|
+
)
|
|
50
|
+
].flatMap(
|
|
51
|
+
(m) => [...m[1].matchAll(/<h2\b[^>]*>([\s\S]*?)<\/h2>/giu)].map((m2) => text(m2[1]))
|
|
52
|
+
);
|
|
53
|
+
return JSON.stringify(titles.sort()) === JSON.stringify(expected.card_titles.map(plain).sort());
|
|
54
|
+
}
|
|
55
|
+
|
|
15
56
|
// src/article-photo-operation.ts
|
|
16
57
|
var ArticlePhotoOperationError = class extends Error {
|
|
17
58
|
constructor(code, stage, message, fileId, status) {
|
|
@@ -287,6 +328,325 @@ async function runArticlePhoto(t, input) {
|
|
|
287
328
|
}
|
|
288
329
|
}
|
|
289
330
|
|
|
331
|
+
// src/article-edit-operation.ts
|
|
332
|
+
var ArticleEditOperationError = class extends Error {
|
|
333
|
+
constructor(code, stage, message, status, revisionId) {
|
|
334
|
+
super(message);
|
|
335
|
+
this.code = code;
|
|
336
|
+
this.stage = stage;
|
|
337
|
+
this.status = status;
|
|
338
|
+
this.revisionId = revisionId;
|
|
339
|
+
this.name = "ArticleEditOperationError";
|
|
340
|
+
}
|
|
341
|
+
code;
|
|
342
|
+
stage;
|
|
343
|
+
status;
|
|
344
|
+
revisionId;
|
|
345
|
+
};
|
|
346
|
+
var canonical2 = (v) => Array.isArray(v) ? v.map(canonical2) : v && typeof v === "object" ? Object.fromEntries(
|
|
347
|
+
Object.keys(v).sort().map((k) => [k, canonical2(v[k])])
|
|
348
|
+
) : v;
|
|
349
|
+
var equal = (a, b) => JSON.stringify(canonical2(a)) === JSON.stringify(canonical2(b));
|
|
350
|
+
var digest2 = async (v) => [
|
|
351
|
+
...new Uint8Array(
|
|
352
|
+
await crypto.subtle.digest(
|
|
353
|
+
"SHA-256",
|
|
354
|
+
new TextEncoder().encode(JSON.stringify(canonical2(v)))
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
].map((v2) => v2.toString(16).padStart(2, "0")).join("");
|
|
358
|
+
var patchKeys = /* @__PURE__ */ new Set([
|
|
359
|
+
"title",
|
|
360
|
+
"subtitle",
|
|
361
|
+
"content_markdown",
|
|
362
|
+
"excerpt",
|
|
363
|
+
"section",
|
|
364
|
+
"community",
|
|
365
|
+
"format",
|
|
366
|
+
"layout",
|
|
367
|
+
"cards",
|
|
368
|
+
"citations"
|
|
369
|
+
]);
|
|
370
|
+
function validate(input) {
|
|
371
|
+
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
|
+
throw Error(
|
|
373
|
+
"Supply an article, stable operation key and nonempty editorial patch"
|
|
374
|
+
);
|
|
375
|
+
for (const [key, value] of Object.entries(input.patch)) {
|
|
376
|
+
if (!patchKeys.has(key)) throw Error("Unsupported edit field: " + key);
|
|
377
|
+
if (["cards", "citations"].includes(key)) {
|
|
378
|
+
if (!Array.isArray(value)) throw Error(key + " must be an array");
|
|
379
|
+
} else if (key === "layout") {
|
|
380
|
+
if (!value || typeof value !== "object" || typeof value.id !== "string" || !Number.isInteger(value.version))
|
|
381
|
+
throw Error("Select an existing versioned layout");
|
|
382
|
+
} else if (typeof value !== "string")
|
|
383
|
+
throw Error(key + " must be a string");
|
|
384
|
+
}
|
|
385
|
+
if (input.expectedGeneration !== void 0 && (!Number.isSafeInteger(input.expectedGeneration) || input.expectedGeneration < 1))
|
|
386
|
+
throw Error("Expected generation must be positive");
|
|
387
|
+
if (input.enableReviewPolicy && ![
|
|
388
|
+
"NONE",
|
|
389
|
+
"FACT_CHECK",
|
|
390
|
+
"CONTRIBUTOR_REVIEW",
|
|
391
|
+
"SUBJECT_APPROVAL_REQUIRED",
|
|
392
|
+
"SUBJECT_APPROVAL_PLUS_EDITOR"
|
|
393
|
+
].includes(input.enableReviewPolicy))
|
|
394
|
+
throw Error("Select an explicit supported review policy");
|
|
395
|
+
if (new TextEncoder().encode(JSON.stringify(input.patch)).length > 128 * 1024)
|
|
396
|
+
throw Error("Editorial patch exceeds 128 KiB");
|
|
397
|
+
}
|
|
398
|
+
async function runArticleEdit(t, input) {
|
|
399
|
+
validate(input);
|
|
400
|
+
const started = performance.now();
|
|
401
|
+
let stage = "preflight";
|
|
402
|
+
const timeout = input.timeoutMs ?? 1e4;
|
|
403
|
+
if (!Number.isFinite(timeout) || timeout < 100 || timeout > 12e4)
|
|
404
|
+
throw Error("Edit deadline must be between 100 and 120000 milliseconds");
|
|
405
|
+
const signal = AbortSignal.timeout(timeout);
|
|
406
|
+
const request = (path, method, body, extra) => t.request(path, method, body, extra, signal);
|
|
407
|
+
const identity = await digest2([
|
|
408
|
+
t.publicationId,
|
|
409
|
+
input.articleId,
|
|
410
|
+
input.operationKey,
|
|
411
|
+
input.patch,
|
|
412
|
+
input.expectedGeneration ?? null,
|
|
413
|
+
input.enableReviewPolicy ?? null
|
|
414
|
+
]);
|
|
415
|
+
if (input.checkpoint && (input.checkpoint.version !== 1 || input.checkpoint.identity !== identity || input.checkpoint.articleId !== input.articleId))
|
|
416
|
+
throw new ArticleEditOperationError(
|
|
417
|
+
"OPERATION_CONFLICT",
|
|
418
|
+
"validation",
|
|
419
|
+
"This checkpoint belongs to different input; reuse its original patch and operation key"
|
|
420
|
+
);
|
|
421
|
+
let state = input.checkpoint ? structuredClone(input.checkpoint) : { version: 1, identity, articleId: input.articleId, stage };
|
|
422
|
+
const save = async (next) => {
|
|
423
|
+
stage = next;
|
|
424
|
+
state.stage = next;
|
|
425
|
+
await input.saveCheckpoint(structuredClone(state));
|
|
426
|
+
};
|
|
427
|
+
const article = "/articles/" + encodeURIComponent(input.articleId);
|
|
428
|
+
const currentRevision = (record) => record.revisions?.find((v) => v.id === record.current_revision_id);
|
|
429
|
+
const identityOf = (post) => ({
|
|
430
|
+
slug: post.slug,
|
|
431
|
+
published_at: post.published_at ?? null,
|
|
432
|
+
canonical_url: post.canonical_url ?? null
|
|
433
|
+
});
|
|
434
|
+
const identityMatches = (post) => !state.sourceIdentity || state.sourceIdentity.slug === post.slug && (state.sourceIdentity.published_at === null || state.sourceIdentity.published_at === (post.published_at ?? null)) && (state.sourceIdentity.canonical_url === null || state.sourceIdentity.canonical_url === (post.canonical_url ?? null));
|
|
435
|
+
const result = (record, status) => ({
|
|
436
|
+
article_id: input.articleId,
|
|
437
|
+
revision_id: state.revisionId ?? record.current_revision_id,
|
|
438
|
+
generation: record.generation,
|
|
439
|
+
state: status,
|
|
440
|
+
editorial_status: record.status,
|
|
441
|
+
elapsed_ms: Math.round(performance.now() - started)
|
|
442
|
+
});
|
|
443
|
+
try {
|
|
444
|
+
const [options, source, initial] = await Promise.all([
|
|
445
|
+
request(
|
|
446
|
+
"/article-workflow/options?article_id=" + encodeURIComponent(input.articleId)
|
|
447
|
+
),
|
|
448
|
+
request(article + "/document"),
|
|
449
|
+
request(article + "/collaboration").catch((error) => {
|
|
450
|
+
if (error.status === 404 && error.message === "Story collaboration is not enabled")
|
|
451
|
+
return null;
|
|
452
|
+
throw error;
|
|
453
|
+
})
|
|
454
|
+
]);
|
|
455
|
+
if (options.workflow_version !== 1 || options.capabilities?.versioned_editorial_fields !== true)
|
|
456
|
+
throw new ArticleEditOperationError(
|
|
457
|
+
"CAPABILITY_UNAVAILABLE",
|
|
458
|
+
stage,
|
|
459
|
+
"The server must expose versioned_editorial_fields before this edit; no article was changed"
|
|
460
|
+
);
|
|
461
|
+
if (!identityMatches(source))
|
|
462
|
+
throw new ArticleEditOperationError(
|
|
463
|
+
"ARTICLE_IDENTITY_CHANGED",
|
|
464
|
+
stage,
|
|
465
|
+
"Article URL, publication date or canonical identity changed; inspect before continuing"
|
|
466
|
+
);
|
|
467
|
+
state.sourceIdentity ??= identityOf(source);
|
|
468
|
+
let record = initial;
|
|
469
|
+
if (!record) {
|
|
470
|
+
if (!input.enableReviewPolicy)
|
|
471
|
+
throw new ArticleEditOperationError(
|
|
472
|
+
"ENROLLMENT_REQUIRED",
|
|
473
|
+
stage,
|
|
474
|
+
"Legacy article: choose an explicit review policy for versioned editorial onboarding; no content was changed"
|
|
475
|
+
);
|
|
476
|
+
if (source.status === "scheduled")
|
|
477
|
+
throw new ArticleEditOperationError(
|
|
478
|
+
"SCHEDULED_ARTICLE",
|
|
479
|
+
stage,
|
|
480
|
+
"Onboarding cancels an old schedule; handle this scheduled article explicitly"
|
|
481
|
+
);
|
|
482
|
+
await save("enroll");
|
|
483
|
+
await request(article + "/collaboration/enable", "POST", {
|
|
484
|
+
review_policy: input.enableReviewPolicy
|
|
485
|
+
});
|
|
486
|
+
record = await request(article + "/collaboration");
|
|
487
|
+
}
|
|
488
|
+
if (state.revisionId) {
|
|
489
|
+
if (record.current_revision_id !== state.revisionId)
|
|
490
|
+
throw new ArticleEditOperationError(
|
|
491
|
+
"ARTICLE_CHANGED",
|
|
492
|
+
stage,
|
|
493
|
+
"Another revision is current; this operation cannot release or overwrite it",
|
|
494
|
+
409,
|
|
495
|
+
state.revisionId
|
|
496
|
+
);
|
|
497
|
+
const selected = currentRevision(record);
|
|
498
|
+
if (!selected || !equal(selected.document, state.document))
|
|
499
|
+
throw new ArticleEditOperationError(
|
|
500
|
+
"VERIFICATION_CONFLICT",
|
|
501
|
+
stage,
|
|
502
|
+
"The saved revision differs from this operation",
|
|
503
|
+
409,
|
|
504
|
+
state.revisionId
|
|
505
|
+
);
|
|
506
|
+
} else {
|
|
507
|
+
if (!state.document) {
|
|
508
|
+
if (input.expectedGeneration !== void 0 && record.generation !== input.expectedGeneration)
|
|
509
|
+
throw new ArticleEditOperationError(
|
|
510
|
+
"ARTICLE_CHANGED",
|
|
511
|
+
stage,
|
|
512
|
+
"The article generation changed; reload before applying this patch",
|
|
513
|
+
409
|
|
514
|
+
);
|
|
515
|
+
const selected = currentRevision(record);
|
|
516
|
+
if (!selected?.complete_snapshot && selected?.complete_snapshot !== void 0)
|
|
517
|
+
throw new ArticleEditOperationError(
|
|
518
|
+
"INCOMPLETE_REVISION",
|
|
519
|
+
stage,
|
|
520
|
+
"Inspect this historical revision before editing"
|
|
521
|
+
);
|
|
522
|
+
if (!selected?.document)
|
|
523
|
+
throw Error("Current immutable article document is missing");
|
|
524
|
+
const effective = { ...selected.document };
|
|
525
|
+
for (const key of ["cards", "citations"])
|
|
526
|
+
if (effective[key] === void 0 && selected.editorial_metadata?.[key] !== void 0)
|
|
527
|
+
effective[key] = selected.editorial_metadata[key];
|
|
528
|
+
if (effective.excerpt === void 0 && source.excerpt !== null && source.excerpt !== void 0)
|
|
529
|
+
effective.excerpt = source.excerpt;
|
|
530
|
+
const changed = Object.entries(input.patch).some(
|
|
531
|
+
([key, value]) => !equal(effective[key], value)
|
|
532
|
+
);
|
|
533
|
+
if (!changed) {
|
|
534
|
+
state.document = selected.document;
|
|
535
|
+
state.revisionId = record.current_revision_id;
|
|
536
|
+
state.savedGeneration = record.generation;
|
|
537
|
+
state.unchanged = true;
|
|
538
|
+
await save("verify");
|
|
539
|
+
if (!input.release) return result(record, "unchanged");
|
|
540
|
+
} else {
|
|
541
|
+
const document = { ...selected.document, ...input.patch };
|
|
542
|
+
if (document.excerpt === void 0 && source.excerpt !== null && source.excerpt !== void 0)
|
|
543
|
+
document.excerpt = source.excerpt;
|
|
544
|
+
state.document = document;
|
|
545
|
+
state.expectedGeneration = record.generation;
|
|
546
|
+
await save("save");
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (!state.revisionId) {
|
|
550
|
+
const saved = await request(
|
|
551
|
+
"/article-workflow",
|
|
552
|
+
"POST",
|
|
553
|
+
{
|
|
554
|
+
operation: "save",
|
|
555
|
+
article_id: input.articleId,
|
|
556
|
+
expected_generation: state.expectedGeneration,
|
|
557
|
+
document: state.document,
|
|
558
|
+
reason: state.sourceIdentity?.published_at ? "POST_PUBLICATION_CORRECTION" : "EDITOR_EDIT"
|
|
559
|
+
},
|
|
560
|
+
{ "Idempotency-Key": input.operationKey }
|
|
561
|
+
);
|
|
562
|
+
state.revisionId = saved.revision_id;
|
|
563
|
+
state.savedGeneration = saved.generation;
|
|
564
|
+
await save("verify");
|
|
565
|
+
record = await request(article + "/collaboration");
|
|
566
|
+
if (record.current_revision_id !== state.revisionId || !equal(currentRevision(record)?.document, state.document))
|
|
567
|
+
throw new ArticleEditOperationError(
|
|
568
|
+
"ARTICLE_CHANGED",
|
|
569
|
+
stage,
|
|
570
|
+
"A different revision is current; no release was attempted",
|
|
571
|
+
409,
|
|
572
|
+
state.revisionId
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
if (record.published_revision_id === state.revisionId) {
|
|
577
|
+
await save("published");
|
|
578
|
+
return result(record, "already_published");
|
|
579
|
+
}
|
|
580
|
+
if (!input.release) {
|
|
581
|
+
await save("saved");
|
|
582
|
+
return result(record, state.unchanged ? "unchanged" : "saved_for_review");
|
|
583
|
+
}
|
|
584
|
+
await save("release");
|
|
585
|
+
try {
|
|
586
|
+
await request(article + "/collaboration", "POST", {
|
|
587
|
+
action: "RELEASE",
|
|
588
|
+
expected_generation: record.generation,
|
|
589
|
+
scheduled_at: null
|
|
590
|
+
});
|
|
591
|
+
} catch (error) {
|
|
592
|
+
const checked = await request(article + "/collaboration");
|
|
593
|
+
if (checked.published_revision_id === state.revisionId && checked.current_revision_id === state.revisionId) {
|
|
594
|
+
record = checked;
|
|
595
|
+
} else throw error;
|
|
596
|
+
}
|
|
597
|
+
record = await request(article + "/collaboration");
|
|
598
|
+
if (record.current_revision_id !== state.revisionId || record.published_revision_id !== state.revisionId)
|
|
599
|
+
throw new ArticleEditOperationError(
|
|
600
|
+
"VERIFICATION_CONFLICT",
|
|
601
|
+
stage,
|
|
602
|
+
"This exact revision has not been confirmed published",
|
|
603
|
+
409,
|
|
604
|
+
state.revisionId
|
|
605
|
+
);
|
|
606
|
+
const after = await request(article + "/document");
|
|
607
|
+
if (!identityMatches(after))
|
|
608
|
+
throw new ArticleEditOperationError(
|
|
609
|
+
"ARTICLE_IDENTITY_CHANGED",
|
|
610
|
+
stage,
|
|
611
|
+
"The release changed a permanent article identity",
|
|
612
|
+
409,
|
|
613
|
+
state.revisionId
|
|
614
|
+
);
|
|
615
|
+
for (const key of ["title", "subtitle", "content_markdown", "excerpt"])
|
|
616
|
+
if (state.document?.[key] !== void 0 && !equal(after[key] ?? "", state.document[key]))
|
|
617
|
+
throw new ArticleEditOperationError(
|
|
618
|
+
"VERIFICATION_CONFLICT",
|
|
619
|
+
stage,
|
|
620
|
+
"Public article field differs after release: " + key,
|
|
621
|
+
409,
|
|
622
|
+
state.revisionId
|
|
623
|
+
);
|
|
624
|
+
const normalizedCards = state.document?.cards;
|
|
625
|
+
if (normalizedCards && after.metadata?.cards?.length !== normalizedCards.length)
|
|
626
|
+
throw new ArticleEditOperationError(
|
|
627
|
+
"VERIFICATION_CONFLICT",
|
|
628
|
+
stage,
|
|
629
|
+
"Public card count differs from the saved revision",
|
|
630
|
+
409,
|
|
631
|
+
state.revisionId
|
|
632
|
+
);
|
|
633
|
+
await save("published");
|
|
634
|
+
return result(record, "published");
|
|
635
|
+
} catch (error) {
|
|
636
|
+
if (error instanceof ArticleEditOperationError) throw error;
|
|
637
|
+
const status = error?.status;
|
|
638
|
+
const message = error instanceof Error ? error.message : "Article edit failed";
|
|
639
|
+
const code = signal.aborted ? "DEADLINE_EXCEEDED" : status === 401 || status === 403 ? "PERMISSION_DENIED" : status === 409 && stage === "release" ? "RELEASE_HELD" : status === 409 ? "ARTICLE_CHANGED" : "STEP_FAILED";
|
|
640
|
+
throw new ArticleEditOperationError(
|
|
641
|
+
code,
|
|
642
|
+
stage,
|
|
643
|
+
signal.aborted ? "Edit reached its deadline; resume the same patch, operation key and checkpoint. Success was not asserted." : message,
|
|
644
|
+
status,
|
|
645
|
+
state.revisionId
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
290
650
|
// src/article-workflow-client.ts
|
|
291
651
|
var ArticleWorkflowError = class extends Error {
|
|
292
652
|
constructor(message, status, articleId) {
|
|
@@ -343,6 +703,9 @@ var PublicationArticleClient = class {
|
|
|
343
703
|
`/article-workflow/options${articleId ? `?article_id=${encodeURIComponent(articleId)}` : ""}`
|
|
344
704
|
);
|
|
345
705
|
}
|
|
706
|
+
publicArticle(slug, signal) {
|
|
707
|
+
return this.request("/article-content/" + encodeURIComponent(slug), void 0, void 0, void 0, signal);
|
|
708
|
+
}
|
|
346
709
|
/** Explicit approver-authorized onboarding for a legacy article; never called by get().
|
|
347
710
|
* Existing schedules are cancelled by the server; public content remains unchanged.
|
|
348
711
|
* Missing immutable media identities must be resolved through newsroom media controls.
|
|
@@ -371,6 +734,10 @@ var PublicationArticleClient = class {
|
|
|
371
734
|
uploadAndReplacePublishedCover(input) {
|
|
372
735
|
return runArticlePhoto({ publicationId: this.config.publicationId, apiUrl: this.config.apiUrl, fetch: this.config.fetch ?? fetch, request: this.request.bind(this) }, input);
|
|
373
736
|
}
|
|
737
|
+
/** Save one exact revision; release is explicit and never manufactures approval. */
|
|
738
|
+
editArticle(input) {
|
|
739
|
+
return runArticleEdit({ publicationId: this.config.publicationId, request: this.request.bind(this) }, input);
|
|
740
|
+
}
|
|
374
741
|
/** Replace a legacy publication cover using ready, measured public Photo renditions.
|
|
375
742
|
* This never enrolls a story, publishes text, creates narration or retries a conflict.
|
|
376
743
|
* Collaboration-managed stories retain their server-enforced immutable revision workflow.
|
|
@@ -556,7 +923,7 @@ var PublicationArticleClient = class {
|
|
|
556
923
|
const selected = [];
|
|
557
924
|
for (const asset of input.assets ?? []) {
|
|
558
925
|
let current = await this.get(created.article_id);
|
|
559
|
-
const
|
|
926
|
+
const digest3 = Array.from(
|
|
560
927
|
new Uint8Array(
|
|
561
928
|
await crypto.subtle.digest(
|
|
562
929
|
"SHA-256",
|
|
@@ -565,7 +932,7 @@ var PublicationArticleClient = class {
|
|
|
565
932
|
)
|
|
566
933
|
).map((v) => v.toString(16).padStart(2, "0")).join("");
|
|
567
934
|
const existing = current.contributions.find(
|
|
568
|
-
(c) => c.kind === "MEDIA" && c.state !== "REJECTED" && c.state !== "WITHDRAWN" && c.payload.sha256 ===
|
|
935
|
+
(c) => c.kind === "MEDIA" && c.state !== "REJECTED" && c.state !== "WITHDRAWN" && c.payload.sha256 === digest3 && Object.entries(asset.metadata).every(
|
|
569
936
|
([k, v]) => c.payload[k] === v
|
|
570
937
|
)
|
|
571
938
|
);
|
|
@@ -646,10 +1013,14 @@ var PublicationArticleClient = class {
|
|
|
646
1013
|
}
|
|
647
1014
|
};
|
|
648
1015
|
function sameDocument(a, b) {
|
|
649
|
-
const
|
|
1016
|
+
const sorted = (v) => Array.isArray(v) ? v.map(sorted) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sorted(v[k])])) : v;
|
|
1017
|
+
const canonical3 = (d) => [
|
|
650
1018
|
d.title,
|
|
651
1019
|
d.subtitle || "",
|
|
652
1020
|
d.content_markdown,
|
|
1021
|
+
d.excerpt ?? null,
|
|
1022
|
+
sorted(d.cards ?? null),
|
|
1023
|
+
sorted(d.citations ?? null),
|
|
653
1024
|
d.section,
|
|
654
1025
|
d.community || "",
|
|
655
1026
|
d.format || null,
|
|
@@ -664,10 +1035,12 @@ function sameDocument(a, b) {
|
|
|
664
1035
|
m.position
|
|
665
1036
|
])
|
|
666
1037
|
];
|
|
667
|
-
return JSON.stringify(
|
|
1038
|
+
return JSON.stringify(canonical3(a)) === JSON.stringify(canonical3(b));
|
|
668
1039
|
}
|
|
669
1040
|
|
|
1041
|
+
exports.ArticleEditOperationError = ArticleEditOperationError;
|
|
670
1042
|
exports.ArticlePhotoOperationError = ArticlePhotoOperationError;
|
|
671
1043
|
exports.ArticleWorkflowError = ArticleWorkflowError;
|
|
672
1044
|
exports.PublicationArticleClient = PublicationArticleClient;
|
|
1045
|
+
exports.articleEditHtmlMatches = articleEditHtmlMatches;
|
|
673
1046
|
exports.articlePhotoHtmlMatches = articlePhotoHtmlMatches;
|
|
@@ -3,6 +3,13 @@ import { A as ArticleFormat, b as ArticleLayout } from './article-presentation-0
|
|
|
3
3
|
/** Check rendered image tags, not a URL mentioned only in serialized page data. */
|
|
4
4
|
declare function articlePhotoHtmlMatches(html: string, lead: string | string[], social: string): boolean;
|
|
5
5
|
|
|
6
|
+
/** Requires visible body and actual card asides; serialized page data is not delivery. */
|
|
7
|
+
declare function articleEditHtmlMatches(html: string, expected: {
|
|
8
|
+
title: string;
|
|
9
|
+
content_html: string;
|
|
10
|
+
card_titles: string[];
|
|
11
|
+
}): boolean;
|
|
12
|
+
|
|
6
13
|
type PhotoOperationCheckpoint = {
|
|
7
14
|
version: 1;
|
|
8
15
|
identity: string;
|
|
@@ -41,10 +48,51 @@ declare class ArticlePhotoOperationError extends Error {
|
|
|
41
48
|
constructor(code: string, stage: string, message: string, fileId?: string | undefined, status?: number | undefined);
|
|
42
49
|
}
|
|
43
50
|
|
|
51
|
+
type ArticleEditPatch = Partial<Pick<ArticleDocument, "title" | "subtitle" | "content_markdown" | "excerpt" | "section" | "community" | "format" | "layout" | "cards" | "citations">>;
|
|
52
|
+
type ArticleEditCheckpoint = {
|
|
53
|
+
version: 1;
|
|
54
|
+
identity: string;
|
|
55
|
+
articleId: string;
|
|
56
|
+
stage: string;
|
|
57
|
+
expectedGeneration?: number;
|
|
58
|
+
document?: ArticleDocument;
|
|
59
|
+
revisionId?: string;
|
|
60
|
+
savedGeneration?: number;
|
|
61
|
+
unchanged?: boolean;
|
|
62
|
+
sourceIdentity?: {
|
|
63
|
+
slug: string;
|
|
64
|
+
published_at: string | null;
|
|
65
|
+
canonical_url: string | null;
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
type ArticleEditInput = {
|
|
69
|
+
articleId: string;
|
|
70
|
+
operationKey: string;
|
|
71
|
+
patch: ArticleEditPatch;
|
|
72
|
+
expectedGeneration?: number;
|
|
73
|
+
/** Explicit legacy onboarding only. Existing policies are never changed. */
|
|
74
|
+
enableReviewPolicy?: "NONE" | "FACT_CHECK" | "CONTRIBUTOR_REVIEW" | "SUBJECT_APPROVAL_REQUIRED" | "SUBJECT_APPROVAL_PLUS_EDITOR";
|
|
75
|
+
/** Execute RELEASE under the caller's grants. Does not issue APPROVE. */
|
|
76
|
+
release?: boolean;
|
|
77
|
+
checkpoint?: ArticleEditCheckpoint;
|
|
78
|
+
saveCheckpoint: (checkpoint: ArticleEditCheckpoint) => Promise<void>;
|
|
79
|
+
timeoutMs?: number;
|
|
80
|
+
};
|
|
81
|
+
declare class ArticleEditOperationError extends Error {
|
|
82
|
+
code: string;
|
|
83
|
+
stage: string;
|
|
84
|
+
status?: number | undefined;
|
|
85
|
+
revisionId?: string | undefined;
|
|
86
|
+
constructor(code: string, stage: string, message: string, status?: number | undefined, revisionId?: string | undefined);
|
|
87
|
+
}
|
|
88
|
+
|
|
44
89
|
type ArticleDocument = {
|
|
45
90
|
title: string;
|
|
46
91
|
subtitle?: string;
|
|
47
92
|
content_markdown: string;
|
|
93
|
+
excerpt?: string;
|
|
94
|
+
cards?: unknown[];
|
|
95
|
+
citations?: unknown[];
|
|
48
96
|
section: string;
|
|
49
97
|
community?: string;
|
|
50
98
|
format?: ArticleFormat;
|
|
@@ -146,6 +194,7 @@ type ArticleWorkflowOptions = {
|
|
|
146
194
|
automatic_public_narration?: boolean;
|
|
147
195
|
prioritized_narration_processing?: boolean;
|
|
148
196
|
content_first_editor_document?: boolean;
|
|
197
|
+
versioned_editorial_fields?: boolean;
|
|
149
198
|
};
|
|
150
199
|
};
|
|
151
200
|
type ArticleOperation = {
|
|
@@ -194,6 +243,7 @@ declare class PublicationArticleClient {
|
|
|
194
243
|
});
|
|
195
244
|
private request;
|
|
196
245
|
options(articleId?: string): Promise<ArticleWorkflowOptions>;
|
|
246
|
+
publicArticle(slug: string, signal?: AbortSignal): Promise<any>;
|
|
197
247
|
/** Explicit approver-authorized onboarding for a legacy article; never called by get().
|
|
198
248
|
* Existing schedules are cancelled by the server; public content remains unchanged.
|
|
199
249
|
* Missing immutable media identities must be resolved through newsroom media controls.
|
|
@@ -212,6 +262,15 @@ declare class PublicationArticleClient {
|
|
|
212
262
|
state: string;
|
|
213
263
|
elapsed_ms: number;
|
|
214
264
|
}>;
|
|
265
|
+
/** Save one exact revision; release is explicit and never manufactures approval. */
|
|
266
|
+
editArticle(input: ArticleEditInput): Promise<{
|
|
267
|
+
article_id: string;
|
|
268
|
+
revision_id: any;
|
|
269
|
+
generation: any;
|
|
270
|
+
state: string;
|
|
271
|
+
editorial_status: any;
|
|
272
|
+
elapsed_ms: number;
|
|
273
|
+
}>;
|
|
215
274
|
/** Replace a legacy publication cover using ready, measured public Photo renditions.
|
|
216
275
|
* This never enrolls a story, publishes text, creates narration or retries a conflict.
|
|
217
276
|
* Collaboration-managed stories retain their server-enforced immutable revision workflow.
|
|
@@ -262,4 +321,4 @@ declare class PublicationArticleClient {
|
|
|
262
321
|
}): Promise<ArticleOperation>;
|
|
263
322
|
}
|
|
264
323
|
|
|
265
|
-
export { type ArticleDocument, type ArticleMedia, type ArticleOperation, type ArticlePhotoInput, ArticlePhotoOperationError, type ArticleProcessing, ArticleWorkflowError, type ArticleWorkflowOptions, type AssetMetadata, type PhotoOperationCheckpoint, PublicationArticleClient, type PublishedCoverReplacement, articlePhotoHtmlMatches };
|
|
324
|
+
export { type ArticleDocument, type ArticleEditCheckpoint, type ArticleEditInput, ArticleEditOperationError, type ArticleEditPatch, type ArticleMedia, type ArticleOperation, type ArticlePhotoInput, ArticlePhotoOperationError, type ArticleProcessing, ArticleWorkflowError, type ArticleWorkflowOptions, type AssetMetadata, type PhotoOperationCheckpoint, PublicationArticleClient, type PublishedCoverReplacement, articleEditHtmlMatches, articlePhotoHtmlMatches };
|
|
@@ -3,6 +3,13 @@ import { A as ArticleFormat, b as ArticleLayout } from './article-presentation-0
|
|
|
3
3
|
/** Check rendered image tags, not a URL mentioned only in serialized page data. */
|
|
4
4
|
declare function articlePhotoHtmlMatches(html: string, lead: string | string[], social: string): boolean;
|
|
5
5
|
|
|
6
|
+
/** Requires visible body and actual card asides; serialized page data is not delivery. */
|
|
7
|
+
declare function articleEditHtmlMatches(html: string, expected: {
|
|
8
|
+
title: string;
|
|
9
|
+
content_html: string;
|
|
10
|
+
card_titles: string[];
|
|
11
|
+
}): boolean;
|
|
12
|
+
|
|
6
13
|
type PhotoOperationCheckpoint = {
|
|
7
14
|
version: 1;
|
|
8
15
|
identity: string;
|
|
@@ -41,10 +48,51 @@ declare class ArticlePhotoOperationError extends Error {
|
|
|
41
48
|
constructor(code: string, stage: string, message: string, fileId?: string | undefined, status?: number | undefined);
|
|
42
49
|
}
|
|
43
50
|
|
|
51
|
+
type ArticleEditPatch = Partial<Pick<ArticleDocument, "title" | "subtitle" | "content_markdown" | "excerpt" | "section" | "community" | "format" | "layout" | "cards" | "citations">>;
|
|
52
|
+
type ArticleEditCheckpoint = {
|
|
53
|
+
version: 1;
|
|
54
|
+
identity: string;
|
|
55
|
+
articleId: string;
|
|
56
|
+
stage: string;
|
|
57
|
+
expectedGeneration?: number;
|
|
58
|
+
document?: ArticleDocument;
|
|
59
|
+
revisionId?: string;
|
|
60
|
+
savedGeneration?: number;
|
|
61
|
+
unchanged?: boolean;
|
|
62
|
+
sourceIdentity?: {
|
|
63
|
+
slug: string;
|
|
64
|
+
published_at: string | null;
|
|
65
|
+
canonical_url: string | null;
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
type ArticleEditInput = {
|
|
69
|
+
articleId: string;
|
|
70
|
+
operationKey: string;
|
|
71
|
+
patch: ArticleEditPatch;
|
|
72
|
+
expectedGeneration?: number;
|
|
73
|
+
/** Explicit legacy onboarding only. Existing policies are never changed. */
|
|
74
|
+
enableReviewPolicy?: "NONE" | "FACT_CHECK" | "CONTRIBUTOR_REVIEW" | "SUBJECT_APPROVAL_REQUIRED" | "SUBJECT_APPROVAL_PLUS_EDITOR";
|
|
75
|
+
/** Execute RELEASE under the caller's grants. Does not issue APPROVE. */
|
|
76
|
+
release?: boolean;
|
|
77
|
+
checkpoint?: ArticleEditCheckpoint;
|
|
78
|
+
saveCheckpoint: (checkpoint: ArticleEditCheckpoint) => Promise<void>;
|
|
79
|
+
timeoutMs?: number;
|
|
80
|
+
};
|
|
81
|
+
declare class ArticleEditOperationError extends Error {
|
|
82
|
+
code: string;
|
|
83
|
+
stage: string;
|
|
84
|
+
status?: number | undefined;
|
|
85
|
+
revisionId?: string | undefined;
|
|
86
|
+
constructor(code: string, stage: string, message: string, status?: number | undefined, revisionId?: string | undefined);
|
|
87
|
+
}
|
|
88
|
+
|
|
44
89
|
type ArticleDocument = {
|
|
45
90
|
title: string;
|
|
46
91
|
subtitle?: string;
|
|
47
92
|
content_markdown: string;
|
|
93
|
+
excerpt?: string;
|
|
94
|
+
cards?: unknown[];
|
|
95
|
+
citations?: unknown[];
|
|
48
96
|
section: string;
|
|
49
97
|
community?: string;
|
|
50
98
|
format?: ArticleFormat;
|
|
@@ -146,6 +194,7 @@ type ArticleWorkflowOptions = {
|
|
|
146
194
|
automatic_public_narration?: boolean;
|
|
147
195
|
prioritized_narration_processing?: boolean;
|
|
148
196
|
content_first_editor_document?: boolean;
|
|
197
|
+
versioned_editorial_fields?: boolean;
|
|
149
198
|
};
|
|
150
199
|
};
|
|
151
200
|
type ArticleOperation = {
|
|
@@ -194,6 +243,7 @@ declare class PublicationArticleClient {
|
|
|
194
243
|
});
|
|
195
244
|
private request;
|
|
196
245
|
options(articleId?: string): Promise<ArticleWorkflowOptions>;
|
|
246
|
+
publicArticle(slug: string, signal?: AbortSignal): Promise<any>;
|
|
197
247
|
/** Explicit approver-authorized onboarding for a legacy article; never called by get().
|
|
198
248
|
* Existing schedules are cancelled by the server; public content remains unchanged.
|
|
199
249
|
* Missing immutable media identities must be resolved through newsroom media controls.
|
|
@@ -212,6 +262,15 @@ declare class PublicationArticleClient {
|
|
|
212
262
|
state: string;
|
|
213
263
|
elapsed_ms: number;
|
|
214
264
|
}>;
|
|
265
|
+
/** Save one exact revision; release is explicit and never manufactures approval. */
|
|
266
|
+
editArticle(input: ArticleEditInput): Promise<{
|
|
267
|
+
article_id: string;
|
|
268
|
+
revision_id: any;
|
|
269
|
+
generation: any;
|
|
270
|
+
state: string;
|
|
271
|
+
editorial_status: any;
|
|
272
|
+
elapsed_ms: number;
|
|
273
|
+
}>;
|
|
215
274
|
/** Replace a legacy publication cover using ready, measured public Photo renditions.
|
|
216
275
|
* This never enrolls a story, publishes text, creates narration or retries a conflict.
|
|
217
276
|
* Collaboration-managed stories retain their server-enforced immutable revision workflow.
|
|
@@ -262,4 +321,4 @@ declare class PublicationArticleClient {
|
|
|
262
321
|
}): Promise<ArticleOperation>;
|
|
263
322
|
}
|
|
264
323
|
|
|
265
|
-
export { type ArticleDocument, type ArticleMedia, type ArticleOperation, type ArticlePhotoInput, ArticlePhotoOperationError, type ArticleProcessing, ArticleWorkflowError, type ArticleWorkflowOptions, type AssetMetadata, type PhotoOperationCheckpoint, PublicationArticleClient, type PublishedCoverReplacement, articlePhotoHtmlMatches };
|
|
324
|
+
export { type ArticleDocument, type ArticleEditCheckpoint, type ArticleEditInput, ArticleEditOperationError, type ArticleEditPatch, type ArticleMedia, type ArticleOperation, type ArticlePhotoInput, ArticlePhotoOperationError, type ArticleProcessing, ArticleWorkflowError, type ArticleWorkflowOptions, type AssetMetadata, type PhotoOperationCheckpoint, PublicationArticleClient, type PublishedCoverReplacement, articleEditHtmlMatches, articlePhotoHtmlMatches };
|
|
@@ -10,6 +10,47 @@ function articlePhotoHtmlMatches(html, lead, social) {
|
|
|
10
10
|
return image && openGraph;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// src/article-edit-verification.ts
|
|
14
|
+
var decode = (value) => value.replace(/&(?:amp|lt|gt|quot|apos|#39|#\d+|#x[\da-f]+);/giu, (s) => {
|
|
15
|
+
const named = {
|
|
16
|
+
"&": "&",
|
|
17
|
+
"<": "<",
|
|
18
|
+
">": ">",
|
|
19
|
+
""": '"',
|
|
20
|
+
"'": "'",
|
|
21
|
+
"'": "'"
|
|
22
|
+
};
|
|
23
|
+
if (named[s.toLowerCase()]) return named[s.toLowerCase()];
|
|
24
|
+
const hex = s.toLowerCase().startsWith("&#x");
|
|
25
|
+
const n = parseInt(s.slice(hex ? 3 : 2, -1), hex ? 16 : 10);
|
|
26
|
+
return Number.isFinite(n) && n >= 0 && n <= 1114111 ? String.fromCodePoint(n) : s;
|
|
27
|
+
});
|
|
28
|
+
var text = (html) => decode(html.replace(/<[^>]*>/gu, " ")).replace(/\s+/gu, " ").trim();
|
|
29
|
+
var plain = (value) => value.replace(/\s+/gu, " ").trim();
|
|
30
|
+
function articleEditHtmlMatches(html, expected) {
|
|
31
|
+
const visible = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/giu, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style>/giu, " ");
|
|
32
|
+
const headline = [...visible.matchAll(/<h1\b[^>]*>([\s\S]*?)<\/h1>/giu)].some(
|
|
33
|
+
(m) => text(m[1]) === plain(expected.title)
|
|
34
|
+
);
|
|
35
|
+
if (!headline) return false;
|
|
36
|
+
const paragraphs = [
|
|
37
|
+
...expected.content_html.matchAll(
|
|
38
|
+
/<(p|li|h[2-6]|blockquote)\b[^>]*>([\s\S]*?)<\/\1>/giu
|
|
39
|
+
)
|
|
40
|
+
].map((m) => text(m[2])).filter(Boolean);
|
|
41
|
+
const all = text(visible);
|
|
42
|
+
if (!paragraphs.length || !paragraphs.every((p) => all.includes(p)))
|
|
43
|
+
return false;
|
|
44
|
+
const titles = [
|
|
45
|
+
...visible.matchAll(
|
|
46
|
+
/<aside\b[^>]*class=["'][^"']*\barticle-verified-card\b[^"']*["'][^>]*>([\s\S]*?)<\/aside>/giu
|
|
47
|
+
)
|
|
48
|
+
].flatMap(
|
|
49
|
+
(m) => [...m[1].matchAll(/<h2\b[^>]*>([\s\S]*?)<\/h2>/giu)].map((m2) => text(m2[1]))
|
|
50
|
+
);
|
|
51
|
+
return JSON.stringify(titles.sort()) === JSON.stringify(expected.card_titles.map(plain).sort());
|
|
52
|
+
}
|
|
53
|
+
|
|
13
54
|
// src/article-photo-operation.ts
|
|
14
55
|
var ArticlePhotoOperationError = class extends Error {
|
|
15
56
|
constructor(code, stage, message, fileId, status) {
|
|
@@ -285,6 +326,325 @@ async function runArticlePhoto(t, input) {
|
|
|
285
326
|
}
|
|
286
327
|
}
|
|
287
328
|
|
|
329
|
+
// src/article-edit-operation.ts
|
|
330
|
+
var ArticleEditOperationError = class extends Error {
|
|
331
|
+
constructor(code, stage, message, status, revisionId) {
|
|
332
|
+
super(message);
|
|
333
|
+
this.code = code;
|
|
334
|
+
this.stage = stage;
|
|
335
|
+
this.status = status;
|
|
336
|
+
this.revisionId = revisionId;
|
|
337
|
+
this.name = "ArticleEditOperationError";
|
|
338
|
+
}
|
|
339
|
+
code;
|
|
340
|
+
stage;
|
|
341
|
+
status;
|
|
342
|
+
revisionId;
|
|
343
|
+
};
|
|
344
|
+
var canonical2 = (v) => Array.isArray(v) ? v.map(canonical2) : v && typeof v === "object" ? Object.fromEntries(
|
|
345
|
+
Object.keys(v).sort().map((k) => [k, canonical2(v[k])])
|
|
346
|
+
) : v;
|
|
347
|
+
var equal = (a, b) => JSON.stringify(canonical2(a)) === JSON.stringify(canonical2(b));
|
|
348
|
+
var digest2 = async (v) => [
|
|
349
|
+
...new Uint8Array(
|
|
350
|
+
await crypto.subtle.digest(
|
|
351
|
+
"SHA-256",
|
|
352
|
+
new TextEncoder().encode(JSON.stringify(canonical2(v)))
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
].map((v2) => v2.toString(16).padStart(2, "0")).join("");
|
|
356
|
+
var patchKeys = /* @__PURE__ */ new Set([
|
|
357
|
+
"title",
|
|
358
|
+
"subtitle",
|
|
359
|
+
"content_markdown",
|
|
360
|
+
"excerpt",
|
|
361
|
+
"section",
|
|
362
|
+
"community",
|
|
363
|
+
"format",
|
|
364
|
+
"layout",
|
|
365
|
+
"cards",
|
|
366
|
+
"citations"
|
|
367
|
+
]);
|
|
368
|
+
function validate(input) {
|
|
369
|
+
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)
|
|
370
|
+
throw Error(
|
|
371
|
+
"Supply an article, stable operation key and nonempty editorial patch"
|
|
372
|
+
);
|
|
373
|
+
for (const [key, value] of Object.entries(input.patch)) {
|
|
374
|
+
if (!patchKeys.has(key)) throw Error("Unsupported edit field: " + key);
|
|
375
|
+
if (["cards", "citations"].includes(key)) {
|
|
376
|
+
if (!Array.isArray(value)) throw Error(key + " must be an array");
|
|
377
|
+
} else if (key === "layout") {
|
|
378
|
+
if (!value || typeof value !== "object" || typeof value.id !== "string" || !Number.isInteger(value.version))
|
|
379
|
+
throw Error("Select an existing versioned layout");
|
|
380
|
+
} else if (typeof value !== "string")
|
|
381
|
+
throw Error(key + " must be a string");
|
|
382
|
+
}
|
|
383
|
+
if (input.expectedGeneration !== void 0 && (!Number.isSafeInteger(input.expectedGeneration) || input.expectedGeneration < 1))
|
|
384
|
+
throw Error("Expected generation must be positive");
|
|
385
|
+
if (input.enableReviewPolicy && ![
|
|
386
|
+
"NONE",
|
|
387
|
+
"FACT_CHECK",
|
|
388
|
+
"CONTRIBUTOR_REVIEW",
|
|
389
|
+
"SUBJECT_APPROVAL_REQUIRED",
|
|
390
|
+
"SUBJECT_APPROVAL_PLUS_EDITOR"
|
|
391
|
+
].includes(input.enableReviewPolicy))
|
|
392
|
+
throw Error("Select an explicit supported review policy");
|
|
393
|
+
if (new TextEncoder().encode(JSON.stringify(input.patch)).length > 128 * 1024)
|
|
394
|
+
throw Error("Editorial patch exceeds 128 KiB");
|
|
395
|
+
}
|
|
396
|
+
async function runArticleEdit(t, input) {
|
|
397
|
+
validate(input);
|
|
398
|
+
const started = performance.now();
|
|
399
|
+
let stage = "preflight";
|
|
400
|
+
const timeout = input.timeoutMs ?? 1e4;
|
|
401
|
+
if (!Number.isFinite(timeout) || timeout < 100 || timeout > 12e4)
|
|
402
|
+
throw Error("Edit deadline must be between 100 and 120000 milliseconds");
|
|
403
|
+
const signal = AbortSignal.timeout(timeout);
|
|
404
|
+
const request = (path, method, body, extra) => t.request(path, method, body, extra, signal);
|
|
405
|
+
const identity = await digest2([
|
|
406
|
+
t.publicationId,
|
|
407
|
+
input.articleId,
|
|
408
|
+
input.operationKey,
|
|
409
|
+
input.patch,
|
|
410
|
+
input.expectedGeneration ?? null,
|
|
411
|
+
input.enableReviewPolicy ?? null
|
|
412
|
+
]);
|
|
413
|
+
if (input.checkpoint && (input.checkpoint.version !== 1 || input.checkpoint.identity !== identity || input.checkpoint.articleId !== input.articleId))
|
|
414
|
+
throw new ArticleEditOperationError(
|
|
415
|
+
"OPERATION_CONFLICT",
|
|
416
|
+
"validation",
|
|
417
|
+
"This checkpoint belongs to different input; reuse its original patch and operation key"
|
|
418
|
+
);
|
|
419
|
+
let state = input.checkpoint ? structuredClone(input.checkpoint) : { version: 1, identity, articleId: input.articleId, stage };
|
|
420
|
+
const save = async (next) => {
|
|
421
|
+
stage = next;
|
|
422
|
+
state.stage = next;
|
|
423
|
+
await input.saveCheckpoint(structuredClone(state));
|
|
424
|
+
};
|
|
425
|
+
const article = "/articles/" + encodeURIComponent(input.articleId);
|
|
426
|
+
const currentRevision = (record) => record.revisions?.find((v) => v.id === record.current_revision_id);
|
|
427
|
+
const identityOf = (post) => ({
|
|
428
|
+
slug: post.slug,
|
|
429
|
+
published_at: post.published_at ?? null,
|
|
430
|
+
canonical_url: post.canonical_url ?? null
|
|
431
|
+
});
|
|
432
|
+
const identityMatches = (post) => !state.sourceIdentity || state.sourceIdentity.slug === post.slug && (state.sourceIdentity.published_at === null || state.sourceIdentity.published_at === (post.published_at ?? null)) && (state.sourceIdentity.canonical_url === null || state.sourceIdentity.canonical_url === (post.canonical_url ?? null));
|
|
433
|
+
const result = (record, status) => ({
|
|
434
|
+
article_id: input.articleId,
|
|
435
|
+
revision_id: state.revisionId ?? record.current_revision_id,
|
|
436
|
+
generation: record.generation,
|
|
437
|
+
state: status,
|
|
438
|
+
editorial_status: record.status,
|
|
439
|
+
elapsed_ms: Math.round(performance.now() - started)
|
|
440
|
+
});
|
|
441
|
+
try {
|
|
442
|
+
const [options, source, initial] = await Promise.all([
|
|
443
|
+
request(
|
|
444
|
+
"/article-workflow/options?article_id=" + encodeURIComponent(input.articleId)
|
|
445
|
+
),
|
|
446
|
+
request(article + "/document"),
|
|
447
|
+
request(article + "/collaboration").catch((error) => {
|
|
448
|
+
if (error.status === 404 && error.message === "Story collaboration is not enabled")
|
|
449
|
+
return null;
|
|
450
|
+
throw error;
|
|
451
|
+
})
|
|
452
|
+
]);
|
|
453
|
+
if (options.workflow_version !== 1 || options.capabilities?.versioned_editorial_fields !== true)
|
|
454
|
+
throw new ArticleEditOperationError(
|
|
455
|
+
"CAPABILITY_UNAVAILABLE",
|
|
456
|
+
stage,
|
|
457
|
+
"The server must expose versioned_editorial_fields before this edit; no article was changed"
|
|
458
|
+
);
|
|
459
|
+
if (!identityMatches(source))
|
|
460
|
+
throw new ArticleEditOperationError(
|
|
461
|
+
"ARTICLE_IDENTITY_CHANGED",
|
|
462
|
+
stage,
|
|
463
|
+
"Article URL, publication date or canonical identity changed; inspect before continuing"
|
|
464
|
+
);
|
|
465
|
+
state.sourceIdentity ??= identityOf(source);
|
|
466
|
+
let record = initial;
|
|
467
|
+
if (!record) {
|
|
468
|
+
if (!input.enableReviewPolicy)
|
|
469
|
+
throw new ArticleEditOperationError(
|
|
470
|
+
"ENROLLMENT_REQUIRED",
|
|
471
|
+
stage,
|
|
472
|
+
"Legacy article: choose an explicit review policy for versioned editorial onboarding; no content was changed"
|
|
473
|
+
);
|
|
474
|
+
if (source.status === "scheduled")
|
|
475
|
+
throw new ArticleEditOperationError(
|
|
476
|
+
"SCHEDULED_ARTICLE",
|
|
477
|
+
stage,
|
|
478
|
+
"Onboarding cancels an old schedule; handle this scheduled article explicitly"
|
|
479
|
+
);
|
|
480
|
+
await save("enroll");
|
|
481
|
+
await request(article + "/collaboration/enable", "POST", {
|
|
482
|
+
review_policy: input.enableReviewPolicy
|
|
483
|
+
});
|
|
484
|
+
record = await request(article + "/collaboration");
|
|
485
|
+
}
|
|
486
|
+
if (state.revisionId) {
|
|
487
|
+
if (record.current_revision_id !== state.revisionId)
|
|
488
|
+
throw new ArticleEditOperationError(
|
|
489
|
+
"ARTICLE_CHANGED",
|
|
490
|
+
stage,
|
|
491
|
+
"Another revision is current; this operation cannot release or overwrite it",
|
|
492
|
+
409,
|
|
493
|
+
state.revisionId
|
|
494
|
+
);
|
|
495
|
+
const selected = currentRevision(record);
|
|
496
|
+
if (!selected || !equal(selected.document, state.document))
|
|
497
|
+
throw new ArticleEditOperationError(
|
|
498
|
+
"VERIFICATION_CONFLICT",
|
|
499
|
+
stage,
|
|
500
|
+
"The saved revision differs from this operation",
|
|
501
|
+
409,
|
|
502
|
+
state.revisionId
|
|
503
|
+
);
|
|
504
|
+
} else {
|
|
505
|
+
if (!state.document) {
|
|
506
|
+
if (input.expectedGeneration !== void 0 && record.generation !== input.expectedGeneration)
|
|
507
|
+
throw new ArticleEditOperationError(
|
|
508
|
+
"ARTICLE_CHANGED",
|
|
509
|
+
stage,
|
|
510
|
+
"The article generation changed; reload before applying this patch",
|
|
511
|
+
409
|
|
512
|
+
);
|
|
513
|
+
const selected = currentRevision(record);
|
|
514
|
+
if (!selected?.complete_snapshot && selected?.complete_snapshot !== void 0)
|
|
515
|
+
throw new ArticleEditOperationError(
|
|
516
|
+
"INCOMPLETE_REVISION",
|
|
517
|
+
stage,
|
|
518
|
+
"Inspect this historical revision before editing"
|
|
519
|
+
);
|
|
520
|
+
if (!selected?.document)
|
|
521
|
+
throw Error("Current immutable article document is missing");
|
|
522
|
+
const effective = { ...selected.document };
|
|
523
|
+
for (const key of ["cards", "citations"])
|
|
524
|
+
if (effective[key] === void 0 && selected.editorial_metadata?.[key] !== void 0)
|
|
525
|
+
effective[key] = selected.editorial_metadata[key];
|
|
526
|
+
if (effective.excerpt === void 0 && source.excerpt !== null && source.excerpt !== void 0)
|
|
527
|
+
effective.excerpt = source.excerpt;
|
|
528
|
+
const changed = Object.entries(input.patch).some(
|
|
529
|
+
([key, value]) => !equal(effective[key], value)
|
|
530
|
+
);
|
|
531
|
+
if (!changed) {
|
|
532
|
+
state.document = selected.document;
|
|
533
|
+
state.revisionId = record.current_revision_id;
|
|
534
|
+
state.savedGeneration = record.generation;
|
|
535
|
+
state.unchanged = true;
|
|
536
|
+
await save("verify");
|
|
537
|
+
if (!input.release) return result(record, "unchanged");
|
|
538
|
+
} else {
|
|
539
|
+
const document = { ...selected.document, ...input.patch };
|
|
540
|
+
if (document.excerpt === void 0 && source.excerpt !== null && source.excerpt !== void 0)
|
|
541
|
+
document.excerpt = source.excerpt;
|
|
542
|
+
state.document = document;
|
|
543
|
+
state.expectedGeneration = record.generation;
|
|
544
|
+
await save("save");
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (!state.revisionId) {
|
|
548
|
+
const saved = await request(
|
|
549
|
+
"/article-workflow",
|
|
550
|
+
"POST",
|
|
551
|
+
{
|
|
552
|
+
operation: "save",
|
|
553
|
+
article_id: input.articleId,
|
|
554
|
+
expected_generation: state.expectedGeneration,
|
|
555
|
+
document: state.document,
|
|
556
|
+
reason: state.sourceIdentity?.published_at ? "POST_PUBLICATION_CORRECTION" : "EDITOR_EDIT"
|
|
557
|
+
},
|
|
558
|
+
{ "Idempotency-Key": input.operationKey }
|
|
559
|
+
);
|
|
560
|
+
state.revisionId = saved.revision_id;
|
|
561
|
+
state.savedGeneration = saved.generation;
|
|
562
|
+
await save("verify");
|
|
563
|
+
record = await request(article + "/collaboration");
|
|
564
|
+
if (record.current_revision_id !== state.revisionId || !equal(currentRevision(record)?.document, state.document))
|
|
565
|
+
throw new ArticleEditOperationError(
|
|
566
|
+
"ARTICLE_CHANGED",
|
|
567
|
+
stage,
|
|
568
|
+
"A different revision is current; no release was attempted",
|
|
569
|
+
409,
|
|
570
|
+
state.revisionId
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (record.published_revision_id === state.revisionId) {
|
|
575
|
+
await save("published");
|
|
576
|
+
return result(record, "already_published");
|
|
577
|
+
}
|
|
578
|
+
if (!input.release) {
|
|
579
|
+
await save("saved");
|
|
580
|
+
return result(record, state.unchanged ? "unchanged" : "saved_for_review");
|
|
581
|
+
}
|
|
582
|
+
await save("release");
|
|
583
|
+
try {
|
|
584
|
+
await request(article + "/collaboration", "POST", {
|
|
585
|
+
action: "RELEASE",
|
|
586
|
+
expected_generation: record.generation,
|
|
587
|
+
scheduled_at: null
|
|
588
|
+
});
|
|
589
|
+
} catch (error) {
|
|
590
|
+
const checked = await request(article + "/collaboration");
|
|
591
|
+
if (checked.published_revision_id === state.revisionId && checked.current_revision_id === state.revisionId) {
|
|
592
|
+
record = checked;
|
|
593
|
+
} else throw error;
|
|
594
|
+
}
|
|
595
|
+
record = await request(article + "/collaboration");
|
|
596
|
+
if (record.current_revision_id !== state.revisionId || record.published_revision_id !== state.revisionId)
|
|
597
|
+
throw new ArticleEditOperationError(
|
|
598
|
+
"VERIFICATION_CONFLICT",
|
|
599
|
+
stage,
|
|
600
|
+
"This exact revision has not been confirmed published",
|
|
601
|
+
409,
|
|
602
|
+
state.revisionId
|
|
603
|
+
);
|
|
604
|
+
const after = await request(article + "/document");
|
|
605
|
+
if (!identityMatches(after))
|
|
606
|
+
throw new ArticleEditOperationError(
|
|
607
|
+
"ARTICLE_IDENTITY_CHANGED",
|
|
608
|
+
stage,
|
|
609
|
+
"The release changed a permanent article identity",
|
|
610
|
+
409,
|
|
611
|
+
state.revisionId
|
|
612
|
+
);
|
|
613
|
+
for (const key of ["title", "subtitle", "content_markdown", "excerpt"])
|
|
614
|
+
if (state.document?.[key] !== void 0 && !equal(after[key] ?? "", state.document[key]))
|
|
615
|
+
throw new ArticleEditOperationError(
|
|
616
|
+
"VERIFICATION_CONFLICT",
|
|
617
|
+
stage,
|
|
618
|
+
"Public article field differs after release: " + key,
|
|
619
|
+
409,
|
|
620
|
+
state.revisionId
|
|
621
|
+
);
|
|
622
|
+
const normalizedCards = state.document?.cards;
|
|
623
|
+
if (normalizedCards && after.metadata?.cards?.length !== normalizedCards.length)
|
|
624
|
+
throw new ArticleEditOperationError(
|
|
625
|
+
"VERIFICATION_CONFLICT",
|
|
626
|
+
stage,
|
|
627
|
+
"Public card count differs from the saved revision",
|
|
628
|
+
409,
|
|
629
|
+
state.revisionId
|
|
630
|
+
);
|
|
631
|
+
await save("published");
|
|
632
|
+
return result(record, "published");
|
|
633
|
+
} catch (error) {
|
|
634
|
+
if (error instanceof ArticleEditOperationError) throw error;
|
|
635
|
+
const status = error?.status;
|
|
636
|
+
const message = error instanceof Error ? error.message : "Article edit failed";
|
|
637
|
+
const code = signal.aborted ? "DEADLINE_EXCEEDED" : status === 401 || status === 403 ? "PERMISSION_DENIED" : status === 409 && stage === "release" ? "RELEASE_HELD" : status === 409 ? "ARTICLE_CHANGED" : "STEP_FAILED";
|
|
638
|
+
throw new ArticleEditOperationError(
|
|
639
|
+
code,
|
|
640
|
+
stage,
|
|
641
|
+
signal.aborted ? "Edit reached its deadline; resume the same patch, operation key and checkpoint. Success was not asserted." : message,
|
|
642
|
+
status,
|
|
643
|
+
state.revisionId
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
288
648
|
// src/article-workflow-client.ts
|
|
289
649
|
var ArticleWorkflowError = class extends Error {
|
|
290
650
|
constructor(message, status, articleId) {
|
|
@@ -341,6 +701,9 @@ var PublicationArticleClient = class {
|
|
|
341
701
|
`/article-workflow/options${articleId ? `?article_id=${encodeURIComponent(articleId)}` : ""}`
|
|
342
702
|
);
|
|
343
703
|
}
|
|
704
|
+
publicArticle(slug, signal) {
|
|
705
|
+
return this.request("/article-content/" + encodeURIComponent(slug), void 0, void 0, void 0, signal);
|
|
706
|
+
}
|
|
344
707
|
/** Explicit approver-authorized onboarding for a legacy article; never called by get().
|
|
345
708
|
* Existing schedules are cancelled by the server; public content remains unchanged.
|
|
346
709
|
* Missing immutable media identities must be resolved through newsroom media controls.
|
|
@@ -369,6 +732,10 @@ var PublicationArticleClient = class {
|
|
|
369
732
|
uploadAndReplacePublishedCover(input) {
|
|
370
733
|
return runArticlePhoto({ publicationId: this.config.publicationId, apiUrl: this.config.apiUrl, fetch: this.config.fetch ?? fetch, request: this.request.bind(this) }, input);
|
|
371
734
|
}
|
|
735
|
+
/** Save one exact revision; release is explicit and never manufactures approval. */
|
|
736
|
+
editArticle(input) {
|
|
737
|
+
return runArticleEdit({ publicationId: this.config.publicationId, request: this.request.bind(this) }, input);
|
|
738
|
+
}
|
|
372
739
|
/** Replace a legacy publication cover using ready, measured public Photo renditions.
|
|
373
740
|
* This never enrolls a story, publishes text, creates narration or retries a conflict.
|
|
374
741
|
* Collaboration-managed stories retain their server-enforced immutable revision workflow.
|
|
@@ -554,7 +921,7 @@ var PublicationArticleClient = class {
|
|
|
554
921
|
const selected = [];
|
|
555
922
|
for (const asset of input.assets ?? []) {
|
|
556
923
|
let current = await this.get(created.article_id);
|
|
557
|
-
const
|
|
924
|
+
const digest3 = Array.from(
|
|
558
925
|
new Uint8Array(
|
|
559
926
|
await crypto.subtle.digest(
|
|
560
927
|
"SHA-256",
|
|
@@ -563,7 +930,7 @@ var PublicationArticleClient = class {
|
|
|
563
930
|
)
|
|
564
931
|
).map((v) => v.toString(16).padStart(2, "0")).join("");
|
|
565
932
|
const existing = current.contributions.find(
|
|
566
|
-
(c) => c.kind === "MEDIA" && c.state !== "REJECTED" && c.state !== "WITHDRAWN" && c.payload.sha256 ===
|
|
933
|
+
(c) => c.kind === "MEDIA" && c.state !== "REJECTED" && c.state !== "WITHDRAWN" && c.payload.sha256 === digest3 && Object.entries(asset.metadata).every(
|
|
567
934
|
([k, v]) => c.payload[k] === v
|
|
568
935
|
)
|
|
569
936
|
);
|
|
@@ -644,10 +1011,14 @@ var PublicationArticleClient = class {
|
|
|
644
1011
|
}
|
|
645
1012
|
};
|
|
646
1013
|
function sameDocument(a, b) {
|
|
647
|
-
const
|
|
1014
|
+
const sorted = (v) => Array.isArray(v) ? v.map(sorted) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sorted(v[k])])) : v;
|
|
1015
|
+
const canonical3 = (d) => [
|
|
648
1016
|
d.title,
|
|
649
1017
|
d.subtitle || "",
|
|
650
1018
|
d.content_markdown,
|
|
1019
|
+
d.excerpt ?? null,
|
|
1020
|
+
sorted(d.cards ?? null),
|
|
1021
|
+
sorted(d.citations ?? null),
|
|
651
1022
|
d.section,
|
|
652
1023
|
d.community || "",
|
|
653
1024
|
d.format || null,
|
|
@@ -662,7 +1033,7 @@ function sameDocument(a, b) {
|
|
|
662
1033
|
m.position
|
|
663
1034
|
])
|
|
664
1035
|
];
|
|
665
|
-
return JSON.stringify(
|
|
1036
|
+
return JSON.stringify(canonical3(a)) === JSON.stringify(canonical3(b));
|
|
666
1037
|
}
|
|
667
1038
|
|
|
668
|
-
export { ArticlePhotoOperationError, ArticleWorkflowError, PublicationArticleClient, articlePhotoHtmlMatches };
|
|
1039
|
+
export { ArticleEditOperationError, ArticlePhotoOperationError, ArticleWorkflowError, PublicationArticleClient, articleEditHtmlMatches, articlePhotoHtmlMatches };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scalemule/news-publication",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.81",
|
|
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": {
|