@scalemule/news-publication 0.0.78 → 0.0.80
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 +39 -0
- package/bin/scalemule-article.mjs +109 -5
- package/dist/{article-presentation-BrNCcF-5.d.cts → article-presentation-0d1FiDgA.d.cts} +1 -1
- package/dist/{article-presentation-BrNCcF-5.d.ts → article-presentation-0d1FiDgA.d.ts} +1 -1
- package/dist/article-workflow-client.cjs +304 -8
- package/dist/article-workflow-client.d.cts +52 -3
- package/dist/article-workflow-client.d.ts +52 -3
- package/dist/article-workflow-client.js +303 -9
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/reader-sso-client.cjs +7 -3
- package/dist/reader-sso-client.js +7 -3
- package/dist/story-review.d.cts +1 -1
- package/dist/story-review.d.ts +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -338,6 +338,45 @@ 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
|
+
### Replace a published article photo from a local file
|
|
342
|
+
|
|
343
|
+
```sh
|
|
344
|
+
npx --yes --package @scalemule/news-publication@0.0.80 scalemule-article replace-photo \
|
|
345
|
+
--url "$ARTICLE_URL" --file ./venue-exterior.jpg \
|
|
346
|
+
--metadata ./photo.json --operation-key venue-exterior-v1
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Use the normal authorized member's `SCALEMULE_API_KEY` and
|
|
350
|
+
`SCALEMULE_ACCESS_TOKEN`. The caller needs developer-level asset access and
|
|
351
|
+
publication approval rights. Supply caption, credit, factual alt, confirmed rights,
|
|
352
|
+
explicit `aiGenerated` and `label` (null for an ordinary photo) in `photo.json`.
|
|
353
|
+
The URL resolves the authorized publication and article; no UUID search or
|
|
354
|
+
publication build is needed. This does not find a photo or establish rights.
|
|
355
|
+
|
|
356
|
+
`uploadAndReplacePublishedCover` implements the same operation for applications.
|
|
357
|
+
It transfers bytes through managed Storage, preserves private capture evidence,
|
|
358
|
+
waits for a clean scan, registers Photo, prepares the publication's lead,
|
|
359
|
+
thumbnail and social renditions, reads measured delivery receipts and performs
|
|
360
|
+
one article-version-fenced media write. It verifies unchanged text/audio and,
|
|
361
|
+
when given `--url`, checks the selected lead and social URLs in the public HTML.
|
|
362
|
+
It requires the deployed content-first document and member Photo endpoints.
|
|
363
|
+
|
|
364
|
+
A private atomic checkpoint lives under `~/.scalemule/newsroom/operations`
|
|
365
|
+
(or `--state PATH`). Keep it: it contains a temporary upload credential until
|
|
366
|
+
completion. Reuse the same file, metadata and operation key after interruption.
|
|
367
|
+
A completed replay verifies the same attachment without uploading or writing
|
|
368
|
+
again. A concurrent article change stops instead of overwriting the editor.
|
|
369
|
+
The default per-run deadline is 30 seconds, adjustable with `--timeout-ms` up to
|
|
370
|
+
120 seconds; timeouts name the blocked stage and preserve progress. Exit 2 means
|
|
371
|
+
API attachment succeeded but public HTML visibility is still pending. A timeout
|
|
372
|
+
is not a success receipt. This is local resumability, not a distributed
|
|
373
|
+
exactly-once service or a guaranteed image-processing SLA.
|
|
374
|
+
|
|
375
|
+
This published-cover convenience lane stops before upload for collaboration
|
|
376
|
+
articles. Their immutable revision, approval and release gates still apply.
|
|
377
|
+
Animated GIF/APNG/WebP and videos use the Video workflow; this lane accepts
|
|
378
|
+
still JPEG, PNG and WebP up to 25 MiB.
|
|
379
|
+
|
|
341
380
|
### Replace an existing, prepared cover
|
|
342
381
|
|
|
343
382
|
For a legacy published article and an already public, scanned Photo asset,
|
|
@@ -1,16 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { readFile, mkdir, writeFile, rename, open, unlink } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join, basename } from 'node:path';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
3
6
|
import { parseArgs } from 'node:util';
|
|
4
|
-
import { PublicationArticleClient } from '../dist/article-workflow-client.js';
|
|
7
|
+
import { PublicationArticleClient, articlePhotoHtmlMatches } from '../dist/article-workflow-client.js';
|
|
5
8
|
|
|
6
9
|
const help = `Usage:
|
|
7
10
|
scalemule-article inspect --publication ID --article ID
|
|
8
11
|
scalemule-article replace-cover --publication ID --article ID --file-id ID --expected-version N --metadata photo.json
|
|
12
|
+
scalemule-article replace-photo --url ARTICLE_URL --file PHOTO --metadata photo.json --operation-key KEY
|
|
9
13
|
|
|
10
14
|
Credentials: SCALEMULE_API_KEY and SCALEMULE_ACCESS_TOKEN (normal authorized member).
|
|
11
15
|
Platform admins may use their normal access token with --application-id ID.
|
|
12
16
|
Optional --api-url URL defaults to https://api.scalemule.com.
|
|
13
17
|
|
|
18
|
+
replace-photo accepts a local file and uses normal member/API-key credentials.
|
|
19
|
+
It requires developer-level asset access and publication approval rights.
|
|
20
|
+
It resolves the publication from its authorized API inventory, saves a private
|
|
21
|
+
upload checkpoint, resumes the same multipart part, waits for scan and measured
|
|
22
|
+
publication renditions, then attaches with the original article version fence.
|
|
23
|
+
It verifies article invariants and, with --url, the public page. The default
|
|
24
|
+
deadline is 30 seconds per run; a failure names its stage and preserves progress.
|
|
25
|
+
Reuse the same --operation-key, metadata and file when resuming. Optional
|
|
26
|
+
--state PATH and --timeout-ms N (maximum 120000). Review-managed articles stop
|
|
27
|
+
before uploading and require their existing exact-revision editorial workflow.
|
|
28
|
+
|
|
14
29
|
replace-cover selects an already public, scanned Photo asset. It reads measured
|
|
15
30
|
hero and social renditions, preserves the article text, and changes media with
|
|
16
31
|
the supplied version fence. It never uploads raw bytes, republishes the story,
|
|
@@ -29,13 +44,35 @@ that every website/CDN cache already displays the new image.
|
|
|
29
44
|
try {
|
|
30
45
|
const { values, positionals } = parseArgs({
|
|
31
46
|
allowPositionals: true, strict: true,
|
|
32
|
-
options: Object.fromEntries(['publication', 'article', 'file-id', 'expected-version', 'metadata', 'application-id', 'api-url'].map(k => [k, { type: 'string' }]).concat([['help', { type: 'boolean' }]])),
|
|
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' }]])),
|
|
33
48
|
});
|
|
34
49
|
if (values.help || !positionals.length) {
|
|
35
50
|
console.log(help);
|
|
36
51
|
} else {
|
|
37
52
|
const [command, ...extra] = positionals;
|
|
38
|
-
|
|
53
|
+
const commandStarted = performance.now();
|
|
54
|
+
const budget = Number(values['timeout-ms'] ?? 30000);
|
|
55
|
+
if (!Number.isFinite(budget) || budget < 100 || budget > 120000) throw Error('--timeout-ms must be between 100 and 120000');
|
|
56
|
+
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');
|
|
58
|
+
if (values.url) {
|
|
59
|
+
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
|
+
const url = new URL(values.url);
|
|
61
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) throw Error('Use the public HTTPS article URL, without a private review key');
|
|
62
|
+
const api = values['api-url'] ?? 'https://api.scalemule.com';
|
|
63
|
+
const apiOrigin = new URL(api); if (apiOrigin.protocol !== 'https:' || apiOrigin.username || apiOrigin.password) throw Error('Use an HTTPS platform API endpoint');
|
|
64
|
+
const headers = { 'x-api-key': process.env.SCALEMULE_API_KEY, authorization: 'Bearer '+process.env.SCALEMULE_ACCESS_TOKEN };
|
|
65
|
+
const get = async route => { const r = await fetch(api.replace(/\/$/,'')+route,{headers,redirect:'error',cache:'no-store',signal:AbortSignal.timeout(Math.min(10000,remaining()))}); const v = await r.json(); if (!r.ok || !v.success) throw Error(`Article resolution HTTP ${r.status}`); return v.data; };
|
|
66
|
+
const pubs = await get('/v1/blog/publications');
|
|
67
|
+
const pub = pubs.items?.find(p=>new URL(p.canonical_url).origin===url.origin);
|
|
68
|
+
if (!pub) throw Error('The caller has no publication grant for that article origin');
|
|
69
|
+
const match = /^\/news\/([^/]+)\/?$/.exec(url.pathname);
|
|
70
|
+
if (!match) throw Error('Use the publication article slug URL');
|
|
71
|
+
const resolved = await get(`/v1/blog/publications/${encodeURIComponent(pub.id)}/article-content/${encodeURIComponent(decodeURIComponent(match[1]))}`);
|
|
72
|
+
if (!resolved.article?.id) throw Error('No published article at this URL');
|
|
73
|
+
if ((values.publication && values.publication !== pub.id) || (values.article && values.article !== resolved.article.id)) throw Error('Article URL and supplied IDs disagree');
|
|
74
|
+
values.publication = pub.id; values.article = resolved.article.id;
|
|
75
|
+
}
|
|
39
76
|
for (const key of ['publication', 'article']) if (!values[key]) throw Error(`Missing --${key}`);
|
|
40
77
|
if (!process.env.SCALEMULE_ACCESS_TOKEN) throw Error('SCALEMULE_ACCESS_TOKEN is required; use normal member or platform admin authorization');
|
|
41
78
|
if (!values['application-id'] && !process.env.SCALEMULE_API_KEY) throw Error('Member calls also require SCALEMULE_API_KEY');
|
|
@@ -62,6 +99,73 @@ try {
|
|
|
62
99
|
capabilities: options.capabilities, narration: processing.narration,
|
|
63
100
|
assets: processing.assets, collaboration, elapsed_ms: Math.round(performance.now() - started),
|
|
64
101
|
}, null, 2));
|
|
102
|
+
} else if (command === 'replace-photo') {
|
|
103
|
+
for (const key of ['file','metadata','operation-key']) if (!values[key]) throw Error(`Missing --${key}`);
|
|
104
|
+
if (values['application-id']) throw Error('Local-photo intake uses normal publication member credentials');
|
|
105
|
+
const metadata = JSON.parse(await readFile(values.metadata,'utf8'));
|
|
106
|
+
for (const key of ['caption','credit','alt']) if (typeof metadata[key] !== 'string') throw Error(`photo.json requires a string ${key}`);
|
|
107
|
+
const key = createHash('sha256').update(JSON.stringify([values['api-url']??'https://api.scalemule.com',values.publication,values.article,values['operation-key']])).digest('hex');
|
|
108
|
+
const statePath = values.state ?? join(homedir(),'.scalemule','newsroom','operations',key+'.json');
|
|
109
|
+
await mkdir(dirname(statePath),{recursive:true,mode:0o700});
|
|
110
|
+
const lockPath = statePath+'.lock';
|
|
111
|
+
let lock;
|
|
112
|
+
try { lock = await open(lockPath,'wx',0o600); }
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error.code !== 'EEXIST') throw error;
|
|
115
|
+
const pid = Number(await readFile(lockPath,'utf8'));
|
|
116
|
+
if (!Number.isSafeInteger(pid) || pid < 1) throw Error('Operation lock is invalid; inspect '+lockPath);
|
|
117
|
+
let alive = true; try { process.kill(pid,0); } catch (e) { if (e.code === 'ESRCH') alive = false; else throw e; }
|
|
118
|
+
if (alive) throw Error('This operation is already running; use its existing result');
|
|
119
|
+
await unlink(lockPath); lock = await open(lockPath,'wx',0o600);
|
|
120
|
+
}
|
|
121
|
+
await lock.writeFile(String(process.pid)); await lock.close();
|
|
122
|
+
try {
|
|
123
|
+
let checkpoint;
|
|
124
|
+
try { checkpoint = JSON.parse(await readFile(statePath,'utf8')); } catch(error) { if (error.code !== 'ENOENT') throw error; }
|
|
125
|
+
const expectedVersion = Number(values['expected-version'] ?? checkpoint?.expectedVersion ?? (await client.processing(values.article,AbortSignal.timeout(remaining()))).version);
|
|
126
|
+
const bytes = await readFile(values.file);
|
|
127
|
+
const saveCheckpoint = async state => { const temp = statePath+'.'+process.pid+'.tmp'; await writeFile(temp,JSON.stringify(state),{mode:0o600}); await rename(temp,statePath); checkpoint = state; };
|
|
128
|
+
const result = await client.uploadAndReplacePublishedCover({
|
|
129
|
+
articleId:values.article, expectedVersion, operationKey:values['operation-key'],file:new Blob([bytes]),filename:basename(values.file),
|
|
130
|
+
caption:metadata.caption,credit:metadata.credit,alt:metadata.alt,rightsConfirmed:metadata.rightsConfirmed,aiGenerated:metadata.aiGenerated,label:metadata.label,focalPoint:metadata.focalPoint,
|
|
131
|
+
checkpoint,saveCheckpoint,timeoutMs:remaining(),
|
|
132
|
+
onProgress:event=>console.error(JSON.stringify(event)),
|
|
133
|
+
});
|
|
134
|
+
if (values.url) {
|
|
135
|
+
let visible = false;
|
|
136
|
+
let failure;
|
|
137
|
+
try {
|
|
138
|
+
const images = [checkpoint?.payload?.cover_image_url, checkpoint?.payload?.og_image_url];
|
|
139
|
+
const leads = new Set([images[0],...(checkpoint?.payload?.cover_image?.variants??[]).map(v=>v.url)].filter(Boolean));
|
|
140
|
+
while (!visible) {
|
|
141
|
+
const response = await fetch(values.url,{redirect:'error',cache:'no-store',signal:AbortSignal.timeout(Math.min(10000,remaining()))});
|
|
142
|
+
const html = await response.text();
|
|
143
|
+
visible = response.ok && images.every(Boolean) && articlePhotoHtmlMatches(html,[...leads],images[1]);
|
|
144
|
+
if (!visible) {
|
|
145
|
+
// Sites may select another measured responsive rendition of the
|
|
146
|
+
// same Photo. Read that evidence; never infer a CDN path or ID.
|
|
147
|
+
await Promise.all(['image/webp','image/jpeg','image/avif'].map(async accept=>{
|
|
148
|
+
const api=(values['api-url']??'https://api.scalemule.com').replace(/\/$/,'');
|
|
149
|
+
const r=await fetch(api+'/v1/photos/public/'+encodeURIComponent(result.file_id)+'/manifest?preset=hero',{headers:{accept},redirect:'error',signal:AbortSignal.timeout(Math.min(5000,remaining()))});
|
|
150
|
+
const value=await r.json();
|
|
151
|
+
if(!r.ok || !value.success || value.data?.file_id!==result.file_id) return;
|
|
152
|
+
for(const v of Object.values(value.data.variant_metadata??{})) {
|
|
153
|
+
if(!Number.isInteger(v.width)||v.width<1||!Number.isInteger(v.height)||v.height<1) continue;
|
|
154
|
+
const u=new URL(v.url);
|
|
155
|
+
if(u.protocol==='https:'&&!u.username&&!u.password&&!u.search&&!u.hash) leads.add(v.url);
|
|
156
|
+
}
|
|
157
|
+
}));
|
|
158
|
+
visible = response.ok && images.every(Boolean) && articlePhotoHtmlMatches(html,[...leads],images[1]);
|
|
159
|
+
}
|
|
160
|
+
if (!visible) await new Promise(resolve=>setTimeout(resolve,Math.min(500,remaining())));
|
|
161
|
+
}
|
|
162
|
+
} catch(error) { failure = error.message; }
|
|
163
|
+
if (!visible) {
|
|
164
|
+
console.error(JSON.stringify({...result,state:'attached_public_verification_pending',stage:'public_page',message:'The API attachment is verified; public HTML visibility remains pending. Resume this same operation.',reason:failure}));
|
|
165
|
+
process.exitCode = 2;
|
|
166
|
+
} else console.log(JSON.stringify({...result,public_page_verified:true,total_elapsed_ms:Math.round(performance.now()-commandStarted)},null,2));
|
|
167
|
+
} else console.log(JSON.stringify(result,null,2));
|
|
168
|
+
} finally { await unlink(lockPath); }
|
|
65
169
|
} else {
|
|
66
170
|
for (const key of ['file-id', 'expected-version', 'metadata']) if (!values[key]) throw Error(`Missing --${key}`);
|
|
67
171
|
const metadata = JSON.parse(await readFile(values.metadata, 'utf8'));
|
|
@@ -78,6 +182,6 @@ try {
|
|
|
78
182
|
}
|
|
79
183
|
}
|
|
80
184
|
} catch (error) {
|
|
81
|
-
console.error(JSON.stringify({ error: error.message, status: error.status }));
|
|
185
|
+
console.error(JSON.stringify({ error: error.message, status: error.status, code:error.code, stage:error.stage, file_id:error.fileId }));
|
|
82
186
|
process.exitCode = 1;
|
|
83
187
|
}
|
|
@@ -18,4 +18,4 @@ declare function articlePresentation(value?: ArticlePresentation | null): Articl
|
|
|
18
18
|
declare function articleHeroRatio(layout: ArticleLayout): number;
|
|
19
19
|
declare function articleFormatLabel(format?: ArticleFormat | null): string | undefined;
|
|
20
20
|
|
|
21
|
-
export { type ArticleFormat as A, type
|
|
21
|
+
export { type ArticleFormat as A, type ArticlePresentation as a, type ArticleLayout as b, articleFormatLabel as c, articleHeroRatio as d, articlePresentation as e };
|
|
@@ -18,4 +18,4 @@ declare function articlePresentation(value?: ArticlePresentation | null): Articl
|
|
|
18
18
|
declare function articleHeroRatio(layout: ArticleLayout): number;
|
|
19
19
|
declare function articleFormatLabel(format?: ArticleFormat | null): string | undefined;
|
|
20
20
|
|
|
21
|
-
export { type ArticleFormat as A, type
|
|
21
|
+
export { type ArticleFormat as A, type ArticlePresentation as a, type ArticleLayout as b, articleFormatLabel as c, articleHeroRatio as d, articlePresentation as e };
|
|
@@ -1,5 +1,292 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// src/article-photo-verification.ts
|
|
4
|
+
function articlePhotoHtmlMatches(html, lead, social) {
|
|
5
|
+
const contains = (tag, url) => tag.includes(url) || tag.includes(encodeURIComponent(url));
|
|
6
|
+
const leads = Array.isArray(lead) ? lead : [lead];
|
|
7
|
+
const image = [...html.matchAll(/<img\b[^>]*>/giu)].some(([tag]) => leads.some((url) => contains(tag, url)));
|
|
8
|
+
const openGraph = [...html.matchAll(/<meta\b[^>]*>/giu)].some(([tag]) => {
|
|
9
|
+
const attrs = Object.fromEntries([...tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/gu)].map((m) => [m[1].toLowerCase(), m[2] ?? m[3]]));
|
|
10
|
+
return (attrs.property ?? attrs.name)?.toLowerCase() === "og:image" && attrs.content === social;
|
|
11
|
+
});
|
|
12
|
+
return image && openGraph;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// src/article-photo-operation.ts
|
|
16
|
+
var ArticlePhotoOperationError = class extends Error {
|
|
17
|
+
constructor(code, stage, message, fileId, status) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.stage = stage;
|
|
21
|
+
this.fileId = fileId;
|
|
22
|
+
this.status = status;
|
|
23
|
+
this.name = "ArticlePhotoOperationError";
|
|
24
|
+
}
|
|
25
|
+
code;
|
|
26
|
+
stage;
|
|
27
|
+
fileId;
|
|
28
|
+
status;
|
|
29
|
+
};
|
|
30
|
+
var digest = async (value) => [...new Uint8Array(await crypto.subtle.digest("SHA-256", value))].map((v) => v.toString(16).padStart(2, "0")).join("");
|
|
31
|
+
var hash = (value) => digest(new TextEncoder().encode(JSON.stringify(value)));
|
|
32
|
+
var canonical = (v) => Array.isArray(v) ? v.map(canonical) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical(v[k])])) : v;
|
|
33
|
+
var stableArticle = (p) => [
|
|
34
|
+
p.id,
|
|
35
|
+
p.slug,
|
|
36
|
+
p.title,
|
|
37
|
+
p.subtitle,
|
|
38
|
+
p.content_markdown,
|
|
39
|
+
p.content_html,
|
|
40
|
+
p.status,
|
|
41
|
+
p.visibility,
|
|
42
|
+
p.published_at,
|
|
43
|
+
p.author_user_id,
|
|
44
|
+
p.audio_file_id,
|
|
45
|
+
Object.fromEntries(Object.entries(p.metadata ?? {}).filter(([k]) => !["cover_image", "open_graph_image", "visual_desk_receipt"].includes(k)).sort(([a], [b]) => a.localeCompare(b)))
|
|
46
|
+
];
|
|
47
|
+
function photoType(bytes) {
|
|
48
|
+
if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255)
|
|
49
|
+
return "image/jpeg";
|
|
50
|
+
const ascii = (n, length) => String.fromCharCode(...bytes.subarray(n, n + length));
|
|
51
|
+
const png = ascii(0, 8) === "\x89PNG\r\n\n";
|
|
52
|
+
const webp = ascii(0, 4) === "RIFF" && ascii(8, 4) === "WEBP";
|
|
53
|
+
if (!png && !webp)
|
|
54
|
+
throw Error("Use a still JPEG, PNG or WebP photo; animations use the Video workflow");
|
|
55
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
56
|
+
for (let n = png ? 8 : 12, chunks = 0; n < bytes.length; chunks++) {
|
|
57
|
+
if (chunks >= 1e4 || n + 8 > bytes.length)
|
|
58
|
+
throw Error("Invalid or truncated photo container");
|
|
59
|
+
const kind = ascii(png ? n + 4 : n, 4);
|
|
60
|
+
if (["acTL", "ANIM", "ANMF"].includes(kind))
|
|
61
|
+
throw Error("Animated photos require the Video workflow");
|
|
62
|
+
const size = view.getUint32(png ? n : n + 4, webp);
|
|
63
|
+
n += size + (png ? 12 : 8 + size % 2);
|
|
64
|
+
if (n > bytes.length)
|
|
65
|
+
throw Error("Truncated photo container");
|
|
66
|
+
if (png && kind === "IEND")
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
return png ? "image/png" : "image/webp";
|
|
70
|
+
}
|
|
71
|
+
function immutableUrl(raw) {
|
|
72
|
+
try {
|
|
73
|
+
const u = new URL(String(raw));
|
|
74
|
+
return u.protocol === "https:" && !u.username && !u.password && !u.search && !u.hash;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
var measured = (m, file) => m?.ready === true && m.file_id === file && immutableUrl(m.url) && Number.isInteger(m.width) && m.width > 0 && m.width <= 12e3 && Number.isInteger(m.height) && m.height > 0 && m.height <= 12e3;
|
|
80
|
+
var mediaMatches = (post, payload) => post.cover_image_url === payload.cover_image_url && post.og_image_url === payload.og_image_url && JSON.stringify(canonical(post.metadata?.cover_image)) === JSON.stringify(canonical(payload.cover_image)) && JSON.stringify(canonical(post.metadata?.open_graph_image)) === JSON.stringify(canonical(payload.open_graph_image));
|
|
81
|
+
async function runArticlePhoto(t, input) {
|
|
82
|
+
const started = performance.now();
|
|
83
|
+
if (!input.operationKey || input.operationKey.length > 128 || !Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 1 || input.rightsConfirmed !== true || !input.alt?.trim() || typeof input.aiGenerated !== "boolean" || input.aiGenerated && !input.label?.trim())
|
|
84
|
+
throw Error("Supply a stable operation key, current article version, confirmed rights, factual alt and explicit provenance");
|
|
85
|
+
if (typeof input.caption !== "string" || typeof input.credit !== "string" || !(input.label === null || typeof input.label === "string"))
|
|
86
|
+
throw Error("Supply explicit caption, credit and provenance label");
|
|
87
|
+
for (const [name, value, limit] of [["caption", input.caption, 2e3], ["credit", input.credit, 8e3], ["alt", input.alt, 1e3], ["label", input.label ?? "", 200]]) {
|
|
88
|
+
if ([...value].length > limit) throw Error(`Photo ${name} exceeds ${limit} characters`);
|
|
89
|
+
}
|
|
90
|
+
const focal = input.focalPoint ?? { x: 0.5, y: 0.5 };
|
|
91
|
+
if (![focal.x, focal.y].every((v) => Number.isFinite(v) && v >= 0 && v <= 1))
|
|
92
|
+
throw Error("Focal point must be within the image");
|
|
93
|
+
if (!input.file.size || input.file.size > 25 * 1024 * 1024)
|
|
94
|
+
throw Error("A photo must be between 1 byte and 25 MiB");
|
|
95
|
+
const bytes = new Uint8Array(await input.file.arrayBuffer());
|
|
96
|
+
const mime = photoType(bytes);
|
|
97
|
+
const identity = await hash([t.publicationId, input.articleId, input.operationKey, await digest(bytes), input.expectedVersion, input.caption, input.credit, input.alt, input.rightsConfirmed, input.aiGenerated, input.label, input.focalPoint ?? { x: 0.5, y: 0.5 }]);
|
|
98
|
+
if (input.checkpoint && (input.checkpoint.version !== 1 || input.checkpoint.identity !== identity))
|
|
99
|
+
throw new ArticlePhotoOperationError("OPERATION_CONFLICT", "validation", "The saved operation belongs to different input; do not overwrite it");
|
|
100
|
+
let state = input.checkpoint ? structuredClone(input.checkpoint) : void 0;
|
|
101
|
+
let stage = "preflight";
|
|
102
|
+
const deadline = AbortSignal.timeout(Math.min(Math.max(input.timeoutMs ?? 3e4, 100), 12e4));
|
|
103
|
+
const request = (path, method, body) => t.request(path, method, body, void 0, deadline);
|
|
104
|
+
const progress = async (next) => {
|
|
105
|
+
stage = next;
|
|
106
|
+
if (state) {
|
|
107
|
+
state.stage = next;
|
|
108
|
+
await input.saveCheckpoint(structuredClone(state));
|
|
109
|
+
}
|
|
110
|
+
input.onProgress?.({ stage, elapsed_ms: Math.round(performance.now() - started), file_id: state?.fileId });
|
|
111
|
+
};
|
|
112
|
+
const sleep = () => new Promise((resolve, reject) => {
|
|
113
|
+
deadline.throwIfAborted();
|
|
114
|
+
const id = setTimeout(() => {
|
|
115
|
+
deadline.removeEventListener("abort", abort);
|
|
116
|
+
resolve();
|
|
117
|
+
}, 300);
|
|
118
|
+
const abort = () => {
|
|
119
|
+
clearTimeout(id);
|
|
120
|
+
reject(deadline.reason);
|
|
121
|
+
};
|
|
122
|
+
deadline.addEventListener("abort", abort, { once: true });
|
|
123
|
+
});
|
|
124
|
+
const postPath = `/articles/${encodeURIComponent(input.articleId)}/document`;
|
|
125
|
+
try {
|
|
126
|
+
const [post, options, collaboration, policy] = await Promise.all([
|
|
127
|
+
request(postPath),
|
|
128
|
+
request(`/article-workflow/options?article_id=${encodeURIComponent(input.articleId)}`),
|
|
129
|
+
request(`/articles/${encodeURIComponent(input.articleId)}/collaboration`).then(() => true).catch((error) => {
|
|
130
|
+
if (error.status === 404 && error.message === "Story collaboration is not enabled")
|
|
131
|
+
return false;
|
|
132
|
+
throw error;
|
|
133
|
+
}),
|
|
134
|
+
request(`/v1/photos/policies/article/editor?publication_id=${encodeURIComponent(t.publicationId)}`)
|
|
135
|
+
]);
|
|
136
|
+
if (post.status !== "published" || post.visibility !== "public" || options.workflow_version !== 1)
|
|
137
|
+
throw new ArticlePhotoOperationError("ARTICLE_NOT_PUBLIC", stage, "Select a currently published public article");
|
|
138
|
+
if (collaboration)
|
|
139
|
+
throw new ArticlePhotoOperationError("REVISION_WORKFLOW_REQUIRED", stage, "This story requires an immutable media revision and editorial release; no upload or public mutation was made");
|
|
140
|
+
if (state?.payload && mediaMatches(post, state.payload)) {
|
|
141
|
+
if (await hash(stableArticle(post)) !== state.articleHash)
|
|
142
|
+
throw new ArticlePhotoOperationError("ARTICLE_CHANGED", stage, "The photo is attached, but other article content changed; inspect before continuing", state.fileId);
|
|
143
|
+
state.attachedVersion = post.version;
|
|
144
|
+
await progress("verified");
|
|
145
|
+
return { article_id: input.articleId, file_id: state.fileId, version: post.version, state: "already_applied", elapsed_ms: Math.round(performance.now() - started) };
|
|
146
|
+
}
|
|
147
|
+
if (post.version !== input.expectedVersion)
|
|
148
|
+
throw new ArticlePhotoOperationError("ARTICLE_CHANGED", stage, "The article version changed; existing text and media were preserved", state?.fileId);
|
|
149
|
+
const articleHash = await hash(stableArticle(post));
|
|
150
|
+
if (state && state.articleHash !== articleHash)
|
|
151
|
+
throw new ArticlePhotoOperationError("ARTICLE_CHANGED", stage, "The article changed while the operation was paused", state.fileId);
|
|
152
|
+
state ??= { version: 1, identity, stage, expectedVersion: input.expectedVersion, articleHash };
|
|
153
|
+
await progress("upload");
|
|
154
|
+
if (!state.etag) {
|
|
155
|
+
const filename = `article-${identity}.${mime === "image/jpeg" ? "jpg" : mime.split("/")[1]}`;
|
|
156
|
+
const reservation = await request("/v1/storage/signed-url/multipart/start", "POST", {
|
|
157
|
+
filename,
|
|
158
|
+
content_type: mime,
|
|
159
|
+
size_bytes: bytes.length,
|
|
160
|
+
visibility: "anonymous_visible",
|
|
161
|
+
chunk_size: 25 * 1024 * 1024,
|
|
162
|
+
client_upload_key: identity,
|
|
163
|
+
metadata: { purpose: "article-photo", publication_id: t.publicationId, article_id: input.articleId, operation_hash: identity, caption: input.caption, credit: input.credit, alt: input.alt, rights_confirmed: true, ai_generated: input.aiGenerated, label: input.label }
|
|
164
|
+
});
|
|
165
|
+
if (state.fileId && state.fileId !== reservation.file_id || reservation.total_parts !== 1)
|
|
166
|
+
throw new ArticlePhotoOperationError("UPLOAD_SESSION_CHANGED", stage, "The original upload session expired or changed; no duplicate attachment was attempted", state.fileId);
|
|
167
|
+
state.fileId = reservation.file_id;
|
|
168
|
+
state.sessionId = reservation.upload_session_id;
|
|
169
|
+
state.completionToken = reservation.completion_token;
|
|
170
|
+
await progress("upload");
|
|
171
|
+
const info = await request(`/v1/storage/files/${state.fileId}/info`);
|
|
172
|
+
if (info.filename !== filename)
|
|
173
|
+
throw new ArticlePhotoOperationError("UPLOAD_IDENTITY_MISMATCH", stage, "The upload session does not match these source bytes", state.fileId);
|
|
174
|
+
const part = reservation.part_urls?.find((p) => p.part_number === 1);
|
|
175
|
+
const u = new URL(part?.url);
|
|
176
|
+
if (u.protocol !== "https:" || u.username || u.password)
|
|
177
|
+
throw Error("Invalid managed upload destination");
|
|
178
|
+
const response = await t.fetch(u, { method: "PUT", body: input.file, redirect: "error", signal: deadline });
|
|
179
|
+
const etag = response.headers.get("etag");
|
|
180
|
+
await response.body?.cancel();
|
|
181
|
+
if (!response.ok || !etag)
|
|
182
|
+
throw Error("Managed upload did not acknowledge its part; resume this operation");
|
|
183
|
+
state.etag = etag;
|
|
184
|
+
await progress("complete");
|
|
185
|
+
}
|
|
186
|
+
if (!state.completed) {
|
|
187
|
+
await request("/v1/storage/signed-url/multipart/complete", "POST", { file_id: state.fileId, upload_session_id: state.sessionId, completion_token: state.completionToken, parts: [{ part_number: 1, etag: state.etag }] });
|
|
188
|
+
state.completed = true;
|
|
189
|
+
delete state.completionToken;
|
|
190
|
+
await progress("scan");
|
|
191
|
+
}
|
|
192
|
+
for (; ; ) {
|
|
193
|
+
const status = await request(`/v1/storage/files/${state.fileId}/status`);
|
|
194
|
+
if (["threat", "quarantined", "error"].includes(status.scan?.status))
|
|
195
|
+
throw new ArticlePhotoOperationError("SCAN_REJECTED", stage, "Storage did not clear this image; the previous cover remains", state.fileId);
|
|
196
|
+
if (status.scan?.status === "clean" && status.urls?.cdn_url)
|
|
197
|
+
break;
|
|
198
|
+
await sleep();
|
|
199
|
+
}
|
|
200
|
+
await progress("photo");
|
|
201
|
+
const batched = policy.capabilities?.batched_article_renditions === true;
|
|
202
|
+
let prepared;
|
|
203
|
+
if (!state.registered || batched) {
|
|
204
|
+
if (batched) await progress("renditions");
|
|
205
|
+
const registered = await request("/v1/photos/register", "POST", { file_id: state.fileId, content_kind: "article", role: "lead", publication_id: t.publicationId, prepare_article_renditions: batched });
|
|
206
|
+
if (batched && registered.prepared_article_renditions !== true) throw Error("Photo did not confirm the measured rendition batch; resume the same upload");
|
|
207
|
+
prepared = batched ? registered : void 0;
|
|
208
|
+
state.registered = true;
|
|
209
|
+
await progress("renditions");
|
|
210
|
+
}
|
|
211
|
+
const source = prepared?.source ?? await request(`/v1/photos/public/${state.fileId}/manifest?preset=hero`);
|
|
212
|
+
if (source.file_id !== state.fileId || !Number.isInteger(source.width) || source.width <= 0)
|
|
213
|
+
throw Error("Photo source identity or measured dimensions are missing");
|
|
214
|
+
const requirements = ["lead", "thumbnail", "social"].flatMap((role) => {
|
|
215
|
+
const specs = policy.roles?.[role]?.renditions;
|
|
216
|
+
if (!Array.isArray(specs) || !specs.length)
|
|
217
|
+
throw Error("Publication Photo policy is incomplete");
|
|
218
|
+
return specs.filter((r) => r.fit !== "scaledown" || r.width <= source.width).map((spec) => ({ role, spec }));
|
|
219
|
+
});
|
|
220
|
+
if (!requirements.length || requirements.length > 48)
|
|
221
|
+
throw Error("Publication Photo policy exceeds operation limits");
|
|
222
|
+
const outputs = [];
|
|
223
|
+
const preparations = /* @__PURE__ */ new Map();
|
|
224
|
+
let next = 0;
|
|
225
|
+
await Promise.all(Array.from({ length: Math.min(4, requirements.length) }, async () => {
|
|
226
|
+
while (next < requirements.length) {
|
|
227
|
+
const { role, spec: r } = requirements[next++];
|
|
228
|
+
if (!Number.isInteger(r.width) || r.width < 1 || r.width > 12e3 || !["cover", "contain", "scaledown"].includes(r.fit) || !["jpeg", "webp", "avif"].includes(r.format) || !Number.isInteger(r.quality) || r.quality < 1 || r.quality > 100 || r.height != null && (!Number.isInteger(r.height) || r.height < 1 || r.height > 12e3))
|
|
229
|
+
throw Error("Invalid publication Photo policy");
|
|
230
|
+
const key = `w${r.width}_h${r.height ?? 0}_${r.fit}_q${r.quality}_${r.format}`;
|
|
231
|
+
let preparation = preparations.get(key);
|
|
232
|
+
if (!preparation) {
|
|
233
|
+
preparation = (async () => {
|
|
234
|
+
const path = `/v1/photos/public/${state.fileId}/manifest?rendition=${key}`;
|
|
235
|
+
let receipt2 = batched ? prepared?.article_renditions?.find((v) => v.key === key && v.role === role && v.format === r.format) : await request(path);
|
|
236
|
+
if (batched && !measured(receipt2, state.fileId)) throw Error("Photo batch is missing a required measured receipt; resume the same uploaded file");
|
|
237
|
+
if (!measured(receipt2, state.fileId)) {
|
|
238
|
+
const q = new URLSearchParams({ width: String(r.width), fit: r.fit, quality: String(r.quality), format: r.format });
|
|
239
|
+
if (r.height)
|
|
240
|
+
q.set("height", String(r.height));
|
|
241
|
+
const response = await t.fetch(new URL(t.apiUrl.replace(/\/$/, "") + `/v1/photos/public/${state.fileId}/transform?${q}`), { method: "GET", redirect: "manual", signal: deadline });
|
|
242
|
+
const ok = response.ok && response.headers.get("content-type")?.startsWith("image/") || [302, 303, 307, 308].includes(response.status);
|
|
243
|
+
await response.body?.cancel();
|
|
244
|
+
if (!ok)
|
|
245
|
+
throw Error("Photo could not prepare a required rendition");
|
|
246
|
+
while (!measured(receipt2, state.fileId)) {
|
|
247
|
+
receipt2 = await request(path);
|
|
248
|
+
if (!measured(receipt2, state.fileId))
|
|
249
|
+
await sleep();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return receipt2;
|
|
253
|
+
})();
|
|
254
|
+
preparations.set(key, preparation);
|
|
255
|
+
}
|
|
256
|
+
const receipt = await preparation;
|
|
257
|
+
outputs.push({ role, file_id: state.fileId, url: receipt.url, width: receipt.width, height: receipt.height, format: r.format });
|
|
258
|
+
}
|
|
259
|
+
}));
|
|
260
|
+
const leads = outputs.filter((o) => o.role === "lead").sort((a, b) => a.width - b.width || a.format.localeCompare(b.format));
|
|
261
|
+
const master = [...leads].reverse().find((o) => o.format === "webp") ?? leads.at(-1);
|
|
262
|
+
const social = outputs.find((o) => o.role === "social" && o.format === "jpeg") ?? outputs.find((o) => o.role === "social");
|
|
263
|
+
if (!master || !social || leads.length > 20)
|
|
264
|
+
throw Error("Photo policy has no usable lead or social output");
|
|
265
|
+
state.payload = {
|
|
266
|
+
expected_version: state.expectedVersion,
|
|
267
|
+
cover_image_url: master.url,
|
|
268
|
+
og_image_url: social.url,
|
|
269
|
+
cover_image: { file_id: state.fileId, master_url: master.url, width: master.width, height: master.height, aspect_ratio: `${master.width}:${master.height}`, alt: input.alt, caption: input.caption, attribution: input.credit, ai_generated: input.aiGenerated, label: input.label, focal_point: input.focalPoint ?? { x: 0.5, y: 0.5 }, variants: leads.map(({ url, width, height, format }) => ({ url, width, height, format })) },
|
|
270
|
+
open_graph_image: { file_id: state.fileId, url: social.url, width: social.width, height: social.height, aspect_ratio: `${social.width}:${social.height}` }
|
|
271
|
+
};
|
|
272
|
+
await progress("attach");
|
|
273
|
+
const result = await request(`/articles/${encodeURIComponent(input.articleId)}/media`, "PUT", state.payload);
|
|
274
|
+
state.attachedVersion = result.version;
|
|
275
|
+
await progress("verify");
|
|
276
|
+
const after = await request(postPath);
|
|
277
|
+
if (!mediaMatches(after, state.payload) || await hash(stableArticle(after)) !== state.articleHash)
|
|
278
|
+
throw new ArticlePhotoOperationError("VERIFICATION_CONFLICT", stage, "The current article differs from this operation; inspect it before making another change", state.fileId);
|
|
279
|
+
await progress("verified");
|
|
280
|
+
return { article_id: input.articleId, file_id: state.fileId, version: after.version, state: "verified", elapsed_ms: Math.round(performance.now() - started) };
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (error instanceof ArticlePhotoOperationError)
|
|
283
|
+
throw error;
|
|
284
|
+
const status = typeof error?.status === "number" ? error.status : void 0;
|
|
285
|
+
const code = deadline.aborted ? "DEADLINE_EXCEEDED" : status === 409 && stage === "attach" ? "ARTICLE_CHANGED" : status === 401 || status === 403 ? "PERMISSION_DENIED" : "STEP_FAILED";
|
|
286
|
+
throw new ArticlePhotoOperationError(code, stage, deadline.aborted ? "Operation reached its time limit; resume the same operation and checkpoint. Success was not asserted." : error instanceof Error ? error.message : "Article photo operation failed", state?.fileId, status);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
3
290
|
// src/article-workflow-client.ts
|
|
4
291
|
var ArticleWorkflowError = class extends Error {
|
|
5
292
|
constructor(message, status, articleId) {
|
|
@@ -21,7 +308,7 @@ var PublicationArticleClient = class {
|
|
|
21
308
|
}
|
|
22
309
|
config;
|
|
23
310
|
base;
|
|
24
|
-
async request(path, method = "GET", body, extra) {
|
|
311
|
+
async request(path, method = "GET", body, extra, signal) {
|
|
25
312
|
const headers = new Headers(extra);
|
|
26
313
|
headers.set("accept", "application/json");
|
|
27
314
|
if (this.config.apiKey) headers.set("x-api-key", this.config.apiKey);
|
|
@@ -39,7 +326,7 @@ var PublicationArticleClient = class {
|
|
|
39
326
|
method,
|
|
40
327
|
headers,
|
|
41
328
|
body: body === void 0 ? void 0 : form ? body : JSON.stringify(body),
|
|
42
|
-
signal: AbortSignal.timeout(form ? 9e4 : 3e4),
|
|
329
|
+
signal: signal ?? AbortSignal.timeout(form ? 9e4 : 3e4),
|
|
43
330
|
cache: "no-store",
|
|
44
331
|
redirect: "error"
|
|
45
332
|
});
|
|
@@ -72,11 +359,18 @@ var PublicationArticleClient = class {
|
|
|
72
359
|
`/articles/${encodeURIComponent(articleId)}/collaboration`
|
|
73
360
|
);
|
|
74
361
|
}
|
|
75
|
-
processing(articleId) {
|
|
362
|
+
processing(articleId, signal) {
|
|
76
363
|
return this.request(
|
|
77
|
-
`/articles/${encodeURIComponent(articleId)}/processing
|
|
364
|
+
`/articles/${encodeURIComponent(articleId)}/processing`,
|
|
365
|
+
void 0,
|
|
366
|
+
void 0,
|
|
367
|
+
void 0,
|
|
368
|
+
signal
|
|
78
369
|
);
|
|
79
370
|
}
|
|
371
|
+
uploadAndReplacePublishedCover(input) {
|
|
372
|
+
return runArticlePhoto({ publicationId: this.config.publicationId, apiUrl: this.config.apiUrl, fetch: this.config.fetch ?? fetch, request: this.request.bind(this) }, input);
|
|
373
|
+
}
|
|
80
374
|
/** Replace a legacy publication cover using ready, measured public Photo renditions.
|
|
81
375
|
* This never enrolls a story, publishes text, creates narration or retries a conflict.
|
|
82
376
|
* Collaboration-managed stories retain their server-enforced immutable revision workflow.
|
|
@@ -262,7 +556,7 @@ var PublicationArticleClient = class {
|
|
|
262
556
|
const selected = [];
|
|
263
557
|
for (const asset of input.assets ?? []) {
|
|
264
558
|
let current = await this.get(created.article_id);
|
|
265
|
-
const
|
|
559
|
+
const digest2 = Array.from(
|
|
266
560
|
new Uint8Array(
|
|
267
561
|
await crypto.subtle.digest(
|
|
268
562
|
"SHA-256",
|
|
@@ -271,7 +565,7 @@ var PublicationArticleClient = class {
|
|
|
271
565
|
)
|
|
272
566
|
).map((v) => v.toString(16).padStart(2, "0")).join("");
|
|
273
567
|
const existing = current.contributions.find(
|
|
274
|
-
(c) => c.kind === "MEDIA" && c.state !== "REJECTED" && c.state !== "WITHDRAWN" && c.payload.sha256 ===
|
|
568
|
+
(c) => c.kind === "MEDIA" && c.state !== "REJECTED" && c.state !== "WITHDRAWN" && c.payload.sha256 === digest2 && Object.entries(asset.metadata).every(
|
|
275
569
|
([k, v]) => c.payload[k] === v
|
|
276
570
|
)
|
|
277
571
|
);
|
|
@@ -352,7 +646,7 @@ var PublicationArticleClient = class {
|
|
|
352
646
|
}
|
|
353
647
|
};
|
|
354
648
|
function sameDocument(a, b) {
|
|
355
|
-
const
|
|
649
|
+
const canonical2 = (d) => [
|
|
356
650
|
d.title,
|
|
357
651
|
d.subtitle || "",
|
|
358
652
|
d.content_markdown,
|
|
@@ -370,8 +664,10 @@ function sameDocument(a, b) {
|
|
|
370
664
|
m.position
|
|
371
665
|
])
|
|
372
666
|
];
|
|
373
|
-
return JSON.stringify(
|
|
667
|
+
return JSON.stringify(canonical2(a)) === JSON.stringify(canonical2(b));
|
|
374
668
|
}
|
|
375
669
|
|
|
670
|
+
exports.ArticlePhotoOperationError = ArticlePhotoOperationError;
|
|
376
671
|
exports.ArticleWorkflowError = ArticleWorkflowError;
|
|
377
672
|
exports.PublicationArticleClient = PublicationArticleClient;
|
|
673
|
+
exports.articlePhotoHtmlMatches = articlePhotoHtmlMatches;
|
|
@@ -1,4 +1,45 @@
|
|
|
1
|
-
import { A as ArticleFormat,
|
|
1
|
+
import { A as ArticleFormat, b as ArticleLayout } from './article-presentation-0d1FiDgA.cjs';
|
|
2
|
+
|
|
3
|
+
/** Check rendered image tags, not a URL mentioned only in serialized page data. */
|
|
4
|
+
declare function articlePhotoHtmlMatches(html: string, lead: string | string[], social: string): boolean;
|
|
5
|
+
|
|
6
|
+
type PhotoOperationCheckpoint = {
|
|
7
|
+
version: 1;
|
|
8
|
+
identity: string;
|
|
9
|
+
stage: string;
|
|
10
|
+
expectedVersion: number;
|
|
11
|
+
articleHash: string;
|
|
12
|
+
fileId?: string;
|
|
13
|
+
sessionId?: string;
|
|
14
|
+
completionToken?: string;
|
|
15
|
+
etag?: string;
|
|
16
|
+
completed?: boolean;
|
|
17
|
+
registered?: boolean;
|
|
18
|
+
payload?: any;
|
|
19
|
+
attachedVersion?: number;
|
|
20
|
+
};
|
|
21
|
+
type ArticlePhotoInput = Omit<PublishedCoverReplacement, "fileId" | "expectedVersion" | "socialRenditionKey"> & {
|
|
22
|
+
operationKey: string;
|
|
23
|
+
expectedVersion: number;
|
|
24
|
+
file: Blob;
|
|
25
|
+
filename: string;
|
|
26
|
+
checkpoint?: PhotoOperationCheckpoint;
|
|
27
|
+
/** Persist atomically before the next side effect. Contains private upload tokens. */
|
|
28
|
+
saveCheckpoint: (state: PhotoOperationCheckpoint) => Promise<void>;
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
onProgress?: (event: {
|
|
31
|
+
stage: string;
|
|
32
|
+
elapsed_ms: number;
|
|
33
|
+
file_id?: string;
|
|
34
|
+
}) => void;
|
|
35
|
+
};
|
|
36
|
+
declare class ArticlePhotoOperationError extends Error {
|
|
37
|
+
code: string;
|
|
38
|
+
stage: string;
|
|
39
|
+
fileId?: string | undefined;
|
|
40
|
+
status?: number | undefined;
|
|
41
|
+
constructor(code: string, stage: string, message: string, fileId?: string | undefined, status?: number | undefined);
|
|
42
|
+
}
|
|
2
43
|
|
|
3
44
|
type ArticleDocument = {
|
|
4
45
|
title: string;
|
|
@@ -104,6 +145,7 @@ type ArticleWorkflowOptions = {
|
|
|
104
145
|
inline_media_body?: boolean;
|
|
105
146
|
automatic_public_narration?: boolean;
|
|
106
147
|
prioritized_narration_processing?: boolean;
|
|
148
|
+
content_first_editor_document?: boolean;
|
|
107
149
|
};
|
|
108
150
|
};
|
|
109
151
|
type ArticleOperation = {
|
|
@@ -162,7 +204,14 @@ declare class PublicationArticleClient {
|
|
|
162
204
|
current_revision_id?: string;
|
|
163
205
|
}>;
|
|
164
206
|
get(articleId: string): Promise<Record>;
|
|
165
|
-
processing(articleId: string): Promise<ArticleProcessing>;
|
|
207
|
+
processing(articleId: string, signal?: AbortSignal): Promise<ArticleProcessing>;
|
|
208
|
+
uploadAndReplacePublishedCover(input: ArticlePhotoInput): Promise<{
|
|
209
|
+
article_id: string;
|
|
210
|
+
file_id: string | undefined;
|
|
211
|
+
version: any;
|
|
212
|
+
state: string;
|
|
213
|
+
elapsed_ms: number;
|
|
214
|
+
}>;
|
|
166
215
|
/** Replace a legacy publication cover using ready, measured public Photo renditions.
|
|
167
216
|
* This never enrolls a story, publishes text, creates narration or retries a conflict.
|
|
168
217
|
* Collaboration-managed stories retain their server-enforced immutable revision workflow.
|
|
@@ -213,4 +262,4 @@ declare class PublicationArticleClient {
|
|
|
213
262
|
}): Promise<ArticleOperation>;
|
|
214
263
|
}
|
|
215
264
|
|
|
216
|
-
export { type ArticleDocument, type ArticleMedia, type ArticleOperation, type ArticleProcessing, ArticleWorkflowError, type ArticleWorkflowOptions, type AssetMetadata, PublicationArticleClient, type PublishedCoverReplacement };
|
|
265
|
+
export { type ArticleDocument, type ArticleMedia, type ArticleOperation, type ArticlePhotoInput, ArticlePhotoOperationError, type ArticleProcessing, ArticleWorkflowError, type ArticleWorkflowOptions, type AssetMetadata, type PhotoOperationCheckpoint, PublicationArticleClient, type PublishedCoverReplacement, articlePhotoHtmlMatches };
|
|
@@ -1,4 +1,45 @@
|
|
|
1
|
-
import { A as ArticleFormat,
|
|
1
|
+
import { A as ArticleFormat, b as ArticleLayout } from './article-presentation-0d1FiDgA.js';
|
|
2
|
+
|
|
3
|
+
/** Check rendered image tags, not a URL mentioned only in serialized page data. */
|
|
4
|
+
declare function articlePhotoHtmlMatches(html: string, lead: string | string[], social: string): boolean;
|
|
5
|
+
|
|
6
|
+
type PhotoOperationCheckpoint = {
|
|
7
|
+
version: 1;
|
|
8
|
+
identity: string;
|
|
9
|
+
stage: string;
|
|
10
|
+
expectedVersion: number;
|
|
11
|
+
articleHash: string;
|
|
12
|
+
fileId?: string;
|
|
13
|
+
sessionId?: string;
|
|
14
|
+
completionToken?: string;
|
|
15
|
+
etag?: string;
|
|
16
|
+
completed?: boolean;
|
|
17
|
+
registered?: boolean;
|
|
18
|
+
payload?: any;
|
|
19
|
+
attachedVersion?: number;
|
|
20
|
+
};
|
|
21
|
+
type ArticlePhotoInput = Omit<PublishedCoverReplacement, "fileId" | "expectedVersion" | "socialRenditionKey"> & {
|
|
22
|
+
operationKey: string;
|
|
23
|
+
expectedVersion: number;
|
|
24
|
+
file: Blob;
|
|
25
|
+
filename: string;
|
|
26
|
+
checkpoint?: PhotoOperationCheckpoint;
|
|
27
|
+
/** Persist atomically before the next side effect. Contains private upload tokens. */
|
|
28
|
+
saveCheckpoint: (state: PhotoOperationCheckpoint) => Promise<void>;
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
onProgress?: (event: {
|
|
31
|
+
stage: string;
|
|
32
|
+
elapsed_ms: number;
|
|
33
|
+
file_id?: string;
|
|
34
|
+
}) => void;
|
|
35
|
+
};
|
|
36
|
+
declare class ArticlePhotoOperationError extends Error {
|
|
37
|
+
code: string;
|
|
38
|
+
stage: string;
|
|
39
|
+
fileId?: string | undefined;
|
|
40
|
+
status?: number | undefined;
|
|
41
|
+
constructor(code: string, stage: string, message: string, fileId?: string | undefined, status?: number | undefined);
|
|
42
|
+
}
|
|
2
43
|
|
|
3
44
|
type ArticleDocument = {
|
|
4
45
|
title: string;
|
|
@@ -104,6 +145,7 @@ type ArticleWorkflowOptions = {
|
|
|
104
145
|
inline_media_body?: boolean;
|
|
105
146
|
automatic_public_narration?: boolean;
|
|
106
147
|
prioritized_narration_processing?: boolean;
|
|
148
|
+
content_first_editor_document?: boolean;
|
|
107
149
|
};
|
|
108
150
|
};
|
|
109
151
|
type ArticleOperation = {
|
|
@@ -162,7 +204,14 @@ declare class PublicationArticleClient {
|
|
|
162
204
|
current_revision_id?: string;
|
|
163
205
|
}>;
|
|
164
206
|
get(articleId: string): Promise<Record>;
|
|
165
|
-
processing(articleId: string): Promise<ArticleProcessing>;
|
|
207
|
+
processing(articleId: string, signal?: AbortSignal): Promise<ArticleProcessing>;
|
|
208
|
+
uploadAndReplacePublishedCover(input: ArticlePhotoInput): Promise<{
|
|
209
|
+
article_id: string;
|
|
210
|
+
file_id: string | undefined;
|
|
211
|
+
version: any;
|
|
212
|
+
state: string;
|
|
213
|
+
elapsed_ms: number;
|
|
214
|
+
}>;
|
|
166
215
|
/** Replace a legacy publication cover using ready, measured public Photo renditions.
|
|
167
216
|
* This never enrolls a story, publishes text, creates narration or retries a conflict.
|
|
168
217
|
* Collaboration-managed stories retain their server-enforced immutable revision workflow.
|
|
@@ -213,4 +262,4 @@ declare class PublicationArticleClient {
|
|
|
213
262
|
}): Promise<ArticleOperation>;
|
|
214
263
|
}
|
|
215
264
|
|
|
216
|
-
export { type ArticleDocument, type ArticleMedia, type ArticleOperation, type ArticleProcessing, ArticleWorkflowError, type ArticleWorkflowOptions, type AssetMetadata, PublicationArticleClient, type PublishedCoverReplacement };
|
|
265
|
+
export { type ArticleDocument, type ArticleMedia, type ArticleOperation, type ArticlePhotoInput, ArticlePhotoOperationError, type ArticleProcessing, ArticleWorkflowError, type ArticleWorkflowOptions, type AssetMetadata, type PhotoOperationCheckpoint, PublicationArticleClient, type PublishedCoverReplacement, articlePhotoHtmlMatches };
|
|
@@ -1,3 +1,290 @@
|
|
|
1
|
+
// src/article-photo-verification.ts
|
|
2
|
+
function articlePhotoHtmlMatches(html, lead, social) {
|
|
3
|
+
const contains = (tag, url) => tag.includes(url) || tag.includes(encodeURIComponent(url));
|
|
4
|
+
const leads = Array.isArray(lead) ? lead : [lead];
|
|
5
|
+
const image = [...html.matchAll(/<img\b[^>]*>/giu)].some(([tag]) => leads.some((url) => contains(tag, url)));
|
|
6
|
+
const openGraph = [...html.matchAll(/<meta\b[^>]*>/giu)].some(([tag]) => {
|
|
7
|
+
const attrs = Object.fromEntries([...tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/gu)].map((m) => [m[1].toLowerCase(), m[2] ?? m[3]]));
|
|
8
|
+
return (attrs.property ?? attrs.name)?.toLowerCase() === "og:image" && attrs.content === social;
|
|
9
|
+
});
|
|
10
|
+
return image && openGraph;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// src/article-photo-operation.ts
|
|
14
|
+
var ArticlePhotoOperationError = class extends Error {
|
|
15
|
+
constructor(code, stage, message, fileId, status) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.stage = stage;
|
|
19
|
+
this.fileId = fileId;
|
|
20
|
+
this.status = status;
|
|
21
|
+
this.name = "ArticlePhotoOperationError";
|
|
22
|
+
}
|
|
23
|
+
code;
|
|
24
|
+
stage;
|
|
25
|
+
fileId;
|
|
26
|
+
status;
|
|
27
|
+
};
|
|
28
|
+
var digest = async (value) => [...new Uint8Array(await crypto.subtle.digest("SHA-256", value))].map((v) => v.toString(16).padStart(2, "0")).join("");
|
|
29
|
+
var hash = (value) => digest(new TextEncoder().encode(JSON.stringify(value)));
|
|
30
|
+
var canonical = (v) => Array.isArray(v) ? v.map(canonical) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical(v[k])])) : v;
|
|
31
|
+
var stableArticle = (p) => [
|
|
32
|
+
p.id,
|
|
33
|
+
p.slug,
|
|
34
|
+
p.title,
|
|
35
|
+
p.subtitle,
|
|
36
|
+
p.content_markdown,
|
|
37
|
+
p.content_html,
|
|
38
|
+
p.status,
|
|
39
|
+
p.visibility,
|
|
40
|
+
p.published_at,
|
|
41
|
+
p.author_user_id,
|
|
42
|
+
p.audio_file_id,
|
|
43
|
+
Object.fromEntries(Object.entries(p.metadata ?? {}).filter(([k]) => !["cover_image", "open_graph_image", "visual_desk_receipt"].includes(k)).sort(([a], [b]) => a.localeCompare(b)))
|
|
44
|
+
];
|
|
45
|
+
function photoType(bytes) {
|
|
46
|
+
if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255)
|
|
47
|
+
return "image/jpeg";
|
|
48
|
+
const ascii = (n, length) => String.fromCharCode(...bytes.subarray(n, n + length));
|
|
49
|
+
const png = ascii(0, 8) === "\x89PNG\r\n\n";
|
|
50
|
+
const webp = ascii(0, 4) === "RIFF" && ascii(8, 4) === "WEBP";
|
|
51
|
+
if (!png && !webp)
|
|
52
|
+
throw Error("Use a still JPEG, PNG or WebP photo; animations use the Video workflow");
|
|
53
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
54
|
+
for (let n = png ? 8 : 12, chunks = 0; n < bytes.length; chunks++) {
|
|
55
|
+
if (chunks >= 1e4 || n + 8 > bytes.length)
|
|
56
|
+
throw Error("Invalid or truncated photo container");
|
|
57
|
+
const kind = ascii(png ? n + 4 : n, 4);
|
|
58
|
+
if (["acTL", "ANIM", "ANMF"].includes(kind))
|
|
59
|
+
throw Error("Animated photos require the Video workflow");
|
|
60
|
+
const size = view.getUint32(png ? n : n + 4, webp);
|
|
61
|
+
n += size + (png ? 12 : 8 + size % 2);
|
|
62
|
+
if (n > bytes.length)
|
|
63
|
+
throw Error("Truncated photo container");
|
|
64
|
+
if (png && kind === "IEND")
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
return png ? "image/png" : "image/webp";
|
|
68
|
+
}
|
|
69
|
+
function immutableUrl(raw) {
|
|
70
|
+
try {
|
|
71
|
+
const u = new URL(String(raw));
|
|
72
|
+
return u.protocol === "https:" && !u.username && !u.password && !u.search && !u.hash;
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
var measured = (m, file) => m?.ready === true && m.file_id === file && immutableUrl(m.url) && Number.isInteger(m.width) && m.width > 0 && m.width <= 12e3 && Number.isInteger(m.height) && m.height > 0 && m.height <= 12e3;
|
|
78
|
+
var mediaMatches = (post, payload) => post.cover_image_url === payload.cover_image_url && post.og_image_url === payload.og_image_url && JSON.stringify(canonical(post.metadata?.cover_image)) === JSON.stringify(canonical(payload.cover_image)) && JSON.stringify(canonical(post.metadata?.open_graph_image)) === JSON.stringify(canonical(payload.open_graph_image));
|
|
79
|
+
async function runArticlePhoto(t, input) {
|
|
80
|
+
const started = performance.now();
|
|
81
|
+
if (!input.operationKey || input.operationKey.length > 128 || !Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 1 || input.rightsConfirmed !== true || !input.alt?.trim() || typeof input.aiGenerated !== "boolean" || input.aiGenerated && !input.label?.trim())
|
|
82
|
+
throw Error("Supply a stable operation key, current article version, confirmed rights, factual alt and explicit provenance");
|
|
83
|
+
if (typeof input.caption !== "string" || typeof input.credit !== "string" || !(input.label === null || typeof input.label === "string"))
|
|
84
|
+
throw Error("Supply explicit caption, credit and provenance label");
|
|
85
|
+
for (const [name, value, limit] of [["caption", input.caption, 2e3], ["credit", input.credit, 8e3], ["alt", input.alt, 1e3], ["label", input.label ?? "", 200]]) {
|
|
86
|
+
if ([...value].length > limit) throw Error(`Photo ${name} exceeds ${limit} characters`);
|
|
87
|
+
}
|
|
88
|
+
const focal = input.focalPoint ?? { x: 0.5, y: 0.5 };
|
|
89
|
+
if (![focal.x, focal.y].every((v) => Number.isFinite(v) && v >= 0 && v <= 1))
|
|
90
|
+
throw Error("Focal point must be within the image");
|
|
91
|
+
if (!input.file.size || input.file.size > 25 * 1024 * 1024)
|
|
92
|
+
throw Error("A photo must be between 1 byte and 25 MiB");
|
|
93
|
+
const bytes = new Uint8Array(await input.file.arrayBuffer());
|
|
94
|
+
const mime = photoType(bytes);
|
|
95
|
+
const identity = await hash([t.publicationId, input.articleId, input.operationKey, await digest(bytes), input.expectedVersion, input.caption, input.credit, input.alt, input.rightsConfirmed, input.aiGenerated, input.label, input.focalPoint ?? { x: 0.5, y: 0.5 }]);
|
|
96
|
+
if (input.checkpoint && (input.checkpoint.version !== 1 || input.checkpoint.identity !== identity))
|
|
97
|
+
throw new ArticlePhotoOperationError("OPERATION_CONFLICT", "validation", "The saved operation belongs to different input; do not overwrite it");
|
|
98
|
+
let state = input.checkpoint ? structuredClone(input.checkpoint) : void 0;
|
|
99
|
+
let stage = "preflight";
|
|
100
|
+
const deadline = AbortSignal.timeout(Math.min(Math.max(input.timeoutMs ?? 3e4, 100), 12e4));
|
|
101
|
+
const request = (path, method, body) => t.request(path, method, body, void 0, deadline);
|
|
102
|
+
const progress = async (next) => {
|
|
103
|
+
stage = next;
|
|
104
|
+
if (state) {
|
|
105
|
+
state.stage = next;
|
|
106
|
+
await input.saveCheckpoint(structuredClone(state));
|
|
107
|
+
}
|
|
108
|
+
input.onProgress?.({ stage, elapsed_ms: Math.round(performance.now() - started), file_id: state?.fileId });
|
|
109
|
+
};
|
|
110
|
+
const sleep = () => new Promise((resolve, reject) => {
|
|
111
|
+
deadline.throwIfAborted();
|
|
112
|
+
const id = setTimeout(() => {
|
|
113
|
+
deadline.removeEventListener("abort", abort);
|
|
114
|
+
resolve();
|
|
115
|
+
}, 300);
|
|
116
|
+
const abort = () => {
|
|
117
|
+
clearTimeout(id);
|
|
118
|
+
reject(deadline.reason);
|
|
119
|
+
};
|
|
120
|
+
deadline.addEventListener("abort", abort, { once: true });
|
|
121
|
+
});
|
|
122
|
+
const postPath = `/articles/${encodeURIComponent(input.articleId)}/document`;
|
|
123
|
+
try {
|
|
124
|
+
const [post, options, collaboration, policy] = await Promise.all([
|
|
125
|
+
request(postPath),
|
|
126
|
+
request(`/article-workflow/options?article_id=${encodeURIComponent(input.articleId)}`),
|
|
127
|
+
request(`/articles/${encodeURIComponent(input.articleId)}/collaboration`).then(() => true).catch((error) => {
|
|
128
|
+
if (error.status === 404 && error.message === "Story collaboration is not enabled")
|
|
129
|
+
return false;
|
|
130
|
+
throw error;
|
|
131
|
+
}),
|
|
132
|
+
request(`/v1/photos/policies/article/editor?publication_id=${encodeURIComponent(t.publicationId)}`)
|
|
133
|
+
]);
|
|
134
|
+
if (post.status !== "published" || post.visibility !== "public" || options.workflow_version !== 1)
|
|
135
|
+
throw new ArticlePhotoOperationError("ARTICLE_NOT_PUBLIC", stage, "Select a currently published public article");
|
|
136
|
+
if (collaboration)
|
|
137
|
+
throw new ArticlePhotoOperationError("REVISION_WORKFLOW_REQUIRED", stage, "This story requires an immutable media revision and editorial release; no upload or public mutation was made");
|
|
138
|
+
if (state?.payload && mediaMatches(post, state.payload)) {
|
|
139
|
+
if (await hash(stableArticle(post)) !== state.articleHash)
|
|
140
|
+
throw new ArticlePhotoOperationError("ARTICLE_CHANGED", stage, "The photo is attached, but other article content changed; inspect before continuing", state.fileId);
|
|
141
|
+
state.attachedVersion = post.version;
|
|
142
|
+
await progress("verified");
|
|
143
|
+
return { article_id: input.articleId, file_id: state.fileId, version: post.version, state: "already_applied", elapsed_ms: Math.round(performance.now() - started) };
|
|
144
|
+
}
|
|
145
|
+
if (post.version !== input.expectedVersion)
|
|
146
|
+
throw new ArticlePhotoOperationError("ARTICLE_CHANGED", stage, "The article version changed; existing text and media were preserved", state?.fileId);
|
|
147
|
+
const articleHash = await hash(stableArticle(post));
|
|
148
|
+
if (state && state.articleHash !== articleHash)
|
|
149
|
+
throw new ArticlePhotoOperationError("ARTICLE_CHANGED", stage, "The article changed while the operation was paused", state.fileId);
|
|
150
|
+
state ??= { version: 1, identity, stage, expectedVersion: input.expectedVersion, articleHash };
|
|
151
|
+
await progress("upload");
|
|
152
|
+
if (!state.etag) {
|
|
153
|
+
const filename = `article-${identity}.${mime === "image/jpeg" ? "jpg" : mime.split("/")[1]}`;
|
|
154
|
+
const reservation = await request("/v1/storage/signed-url/multipart/start", "POST", {
|
|
155
|
+
filename,
|
|
156
|
+
content_type: mime,
|
|
157
|
+
size_bytes: bytes.length,
|
|
158
|
+
visibility: "anonymous_visible",
|
|
159
|
+
chunk_size: 25 * 1024 * 1024,
|
|
160
|
+
client_upload_key: identity,
|
|
161
|
+
metadata: { purpose: "article-photo", publication_id: t.publicationId, article_id: input.articleId, operation_hash: identity, caption: input.caption, credit: input.credit, alt: input.alt, rights_confirmed: true, ai_generated: input.aiGenerated, label: input.label }
|
|
162
|
+
});
|
|
163
|
+
if (state.fileId && state.fileId !== reservation.file_id || reservation.total_parts !== 1)
|
|
164
|
+
throw new ArticlePhotoOperationError("UPLOAD_SESSION_CHANGED", stage, "The original upload session expired or changed; no duplicate attachment was attempted", state.fileId);
|
|
165
|
+
state.fileId = reservation.file_id;
|
|
166
|
+
state.sessionId = reservation.upload_session_id;
|
|
167
|
+
state.completionToken = reservation.completion_token;
|
|
168
|
+
await progress("upload");
|
|
169
|
+
const info = await request(`/v1/storage/files/${state.fileId}/info`);
|
|
170
|
+
if (info.filename !== filename)
|
|
171
|
+
throw new ArticlePhotoOperationError("UPLOAD_IDENTITY_MISMATCH", stage, "The upload session does not match these source bytes", state.fileId);
|
|
172
|
+
const part = reservation.part_urls?.find((p) => p.part_number === 1);
|
|
173
|
+
const u = new URL(part?.url);
|
|
174
|
+
if (u.protocol !== "https:" || u.username || u.password)
|
|
175
|
+
throw Error("Invalid managed upload destination");
|
|
176
|
+
const response = await t.fetch(u, { method: "PUT", body: input.file, redirect: "error", signal: deadline });
|
|
177
|
+
const etag = response.headers.get("etag");
|
|
178
|
+
await response.body?.cancel();
|
|
179
|
+
if (!response.ok || !etag)
|
|
180
|
+
throw Error("Managed upload did not acknowledge its part; resume this operation");
|
|
181
|
+
state.etag = etag;
|
|
182
|
+
await progress("complete");
|
|
183
|
+
}
|
|
184
|
+
if (!state.completed) {
|
|
185
|
+
await request("/v1/storage/signed-url/multipart/complete", "POST", { file_id: state.fileId, upload_session_id: state.sessionId, completion_token: state.completionToken, parts: [{ part_number: 1, etag: state.etag }] });
|
|
186
|
+
state.completed = true;
|
|
187
|
+
delete state.completionToken;
|
|
188
|
+
await progress("scan");
|
|
189
|
+
}
|
|
190
|
+
for (; ; ) {
|
|
191
|
+
const status = await request(`/v1/storage/files/${state.fileId}/status`);
|
|
192
|
+
if (["threat", "quarantined", "error"].includes(status.scan?.status))
|
|
193
|
+
throw new ArticlePhotoOperationError("SCAN_REJECTED", stage, "Storage did not clear this image; the previous cover remains", state.fileId);
|
|
194
|
+
if (status.scan?.status === "clean" && status.urls?.cdn_url)
|
|
195
|
+
break;
|
|
196
|
+
await sleep();
|
|
197
|
+
}
|
|
198
|
+
await progress("photo");
|
|
199
|
+
const batched = policy.capabilities?.batched_article_renditions === true;
|
|
200
|
+
let prepared;
|
|
201
|
+
if (!state.registered || batched) {
|
|
202
|
+
if (batched) await progress("renditions");
|
|
203
|
+
const registered = await request("/v1/photos/register", "POST", { file_id: state.fileId, content_kind: "article", role: "lead", publication_id: t.publicationId, prepare_article_renditions: batched });
|
|
204
|
+
if (batched && registered.prepared_article_renditions !== true) throw Error("Photo did not confirm the measured rendition batch; resume the same upload");
|
|
205
|
+
prepared = batched ? registered : void 0;
|
|
206
|
+
state.registered = true;
|
|
207
|
+
await progress("renditions");
|
|
208
|
+
}
|
|
209
|
+
const source = prepared?.source ?? await request(`/v1/photos/public/${state.fileId}/manifest?preset=hero`);
|
|
210
|
+
if (source.file_id !== state.fileId || !Number.isInteger(source.width) || source.width <= 0)
|
|
211
|
+
throw Error("Photo source identity or measured dimensions are missing");
|
|
212
|
+
const requirements = ["lead", "thumbnail", "social"].flatMap((role) => {
|
|
213
|
+
const specs = policy.roles?.[role]?.renditions;
|
|
214
|
+
if (!Array.isArray(specs) || !specs.length)
|
|
215
|
+
throw Error("Publication Photo policy is incomplete");
|
|
216
|
+
return specs.filter((r) => r.fit !== "scaledown" || r.width <= source.width).map((spec) => ({ role, spec }));
|
|
217
|
+
});
|
|
218
|
+
if (!requirements.length || requirements.length > 48)
|
|
219
|
+
throw Error("Publication Photo policy exceeds operation limits");
|
|
220
|
+
const outputs = [];
|
|
221
|
+
const preparations = /* @__PURE__ */ new Map();
|
|
222
|
+
let next = 0;
|
|
223
|
+
await Promise.all(Array.from({ length: Math.min(4, requirements.length) }, async () => {
|
|
224
|
+
while (next < requirements.length) {
|
|
225
|
+
const { role, spec: r } = requirements[next++];
|
|
226
|
+
if (!Number.isInteger(r.width) || r.width < 1 || r.width > 12e3 || !["cover", "contain", "scaledown"].includes(r.fit) || !["jpeg", "webp", "avif"].includes(r.format) || !Number.isInteger(r.quality) || r.quality < 1 || r.quality > 100 || r.height != null && (!Number.isInteger(r.height) || r.height < 1 || r.height > 12e3))
|
|
227
|
+
throw Error("Invalid publication Photo policy");
|
|
228
|
+
const key = `w${r.width}_h${r.height ?? 0}_${r.fit}_q${r.quality}_${r.format}`;
|
|
229
|
+
let preparation = preparations.get(key);
|
|
230
|
+
if (!preparation) {
|
|
231
|
+
preparation = (async () => {
|
|
232
|
+
const path = `/v1/photos/public/${state.fileId}/manifest?rendition=${key}`;
|
|
233
|
+
let receipt2 = batched ? prepared?.article_renditions?.find((v) => v.key === key && v.role === role && v.format === r.format) : await request(path);
|
|
234
|
+
if (batched && !measured(receipt2, state.fileId)) throw Error("Photo batch is missing a required measured receipt; resume the same uploaded file");
|
|
235
|
+
if (!measured(receipt2, state.fileId)) {
|
|
236
|
+
const q = new URLSearchParams({ width: String(r.width), fit: r.fit, quality: String(r.quality), format: r.format });
|
|
237
|
+
if (r.height)
|
|
238
|
+
q.set("height", String(r.height));
|
|
239
|
+
const response = await t.fetch(new URL(t.apiUrl.replace(/\/$/, "") + `/v1/photos/public/${state.fileId}/transform?${q}`), { method: "GET", redirect: "manual", signal: deadline });
|
|
240
|
+
const ok = response.ok && response.headers.get("content-type")?.startsWith("image/") || [302, 303, 307, 308].includes(response.status);
|
|
241
|
+
await response.body?.cancel();
|
|
242
|
+
if (!ok)
|
|
243
|
+
throw Error("Photo could not prepare a required rendition");
|
|
244
|
+
while (!measured(receipt2, state.fileId)) {
|
|
245
|
+
receipt2 = await request(path);
|
|
246
|
+
if (!measured(receipt2, state.fileId))
|
|
247
|
+
await sleep();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return receipt2;
|
|
251
|
+
})();
|
|
252
|
+
preparations.set(key, preparation);
|
|
253
|
+
}
|
|
254
|
+
const receipt = await preparation;
|
|
255
|
+
outputs.push({ role, file_id: state.fileId, url: receipt.url, width: receipt.width, height: receipt.height, format: r.format });
|
|
256
|
+
}
|
|
257
|
+
}));
|
|
258
|
+
const leads = outputs.filter((o) => o.role === "lead").sort((a, b) => a.width - b.width || a.format.localeCompare(b.format));
|
|
259
|
+
const master = [...leads].reverse().find((o) => o.format === "webp") ?? leads.at(-1);
|
|
260
|
+
const social = outputs.find((o) => o.role === "social" && o.format === "jpeg") ?? outputs.find((o) => o.role === "social");
|
|
261
|
+
if (!master || !social || leads.length > 20)
|
|
262
|
+
throw Error("Photo policy has no usable lead or social output");
|
|
263
|
+
state.payload = {
|
|
264
|
+
expected_version: state.expectedVersion,
|
|
265
|
+
cover_image_url: master.url,
|
|
266
|
+
og_image_url: social.url,
|
|
267
|
+
cover_image: { file_id: state.fileId, master_url: master.url, width: master.width, height: master.height, aspect_ratio: `${master.width}:${master.height}`, alt: input.alt, caption: input.caption, attribution: input.credit, ai_generated: input.aiGenerated, label: input.label, focal_point: input.focalPoint ?? { x: 0.5, y: 0.5 }, variants: leads.map(({ url, width, height, format }) => ({ url, width, height, format })) },
|
|
268
|
+
open_graph_image: { file_id: state.fileId, url: social.url, width: social.width, height: social.height, aspect_ratio: `${social.width}:${social.height}` }
|
|
269
|
+
};
|
|
270
|
+
await progress("attach");
|
|
271
|
+
const result = await request(`/articles/${encodeURIComponent(input.articleId)}/media`, "PUT", state.payload);
|
|
272
|
+
state.attachedVersion = result.version;
|
|
273
|
+
await progress("verify");
|
|
274
|
+
const after = await request(postPath);
|
|
275
|
+
if (!mediaMatches(after, state.payload) || await hash(stableArticle(after)) !== state.articleHash)
|
|
276
|
+
throw new ArticlePhotoOperationError("VERIFICATION_CONFLICT", stage, "The current article differs from this operation; inspect it before making another change", state.fileId);
|
|
277
|
+
await progress("verified");
|
|
278
|
+
return { article_id: input.articleId, file_id: state.fileId, version: after.version, state: "verified", elapsed_ms: Math.round(performance.now() - started) };
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (error instanceof ArticlePhotoOperationError)
|
|
281
|
+
throw error;
|
|
282
|
+
const status = typeof error?.status === "number" ? error.status : void 0;
|
|
283
|
+
const code = deadline.aborted ? "DEADLINE_EXCEEDED" : status === 409 && stage === "attach" ? "ARTICLE_CHANGED" : status === 401 || status === 403 ? "PERMISSION_DENIED" : "STEP_FAILED";
|
|
284
|
+
throw new ArticlePhotoOperationError(code, stage, deadline.aborted ? "Operation reached its time limit; resume the same operation and checkpoint. Success was not asserted." : error instanceof Error ? error.message : "Article photo operation failed", state?.fileId, status);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
1
288
|
// src/article-workflow-client.ts
|
|
2
289
|
var ArticleWorkflowError = class extends Error {
|
|
3
290
|
constructor(message, status, articleId) {
|
|
@@ -19,7 +306,7 @@ var PublicationArticleClient = class {
|
|
|
19
306
|
}
|
|
20
307
|
config;
|
|
21
308
|
base;
|
|
22
|
-
async request(path, method = "GET", body, extra) {
|
|
309
|
+
async request(path, method = "GET", body, extra, signal) {
|
|
23
310
|
const headers = new Headers(extra);
|
|
24
311
|
headers.set("accept", "application/json");
|
|
25
312
|
if (this.config.apiKey) headers.set("x-api-key", this.config.apiKey);
|
|
@@ -37,7 +324,7 @@ var PublicationArticleClient = class {
|
|
|
37
324
|
method,
|
|
38
325
|
headers,
|
|
39
326
|
body: body === void 0 ? void 0 : form ? body : JSON.stringify(body),
|
|
40
|
-
signal: AbortSignal.timeout(form ? 9e4 : 3e4),
|
|
327
|
+
signal: signal ?? AbortSignal.timeout(form ? 9e4 : 3e4),
|
|
41
328
|
cache: "no-store",
|
|
42
329
|
redirect: "error"
|
|
43
330
|
});
|
|
@@ -70,11 +357,18 @@ var PublicationArticleClient = class {
|
|
|
70
357
|
`/articles/${encodeURIComponent(articleId)}/collaboration`
|
|
71
358
|
);
|
|
72
359
|
}
|
|
73
|
-
processing(articleId) {
|
|
360
|
+
processing(articleId, signal) {
|
|
74
361
|
return this.request(
|
|
75
|
-
`/articles/${encodeURIComponent(articleId)}/processing
|
|
362
|
+
`/articles/${encodeURIComponent(articleId)}/processing`,
|
|
363
|
+
void 0,
|
|
364
|
+
void 0,
|
|
365
|
+
void 0,
|
|
366
|
+
signal
|
|
76
367
|
);
|
|
77
368
|
}
|
|
369
|
+
uploadAndReplacePublishedCover(input) {
|
|
370
|
+
return runArticlePhoto({ publicationId: this.config.publicationId, apiUrl: this.config.apiUrl, fetch: this.config.fetch ?? fetch, request: this.request.bind(this) }, input);
|
|
371
|
+
}
|
|
78
372
|
/** Replace a legacy publication cover using ready, measured public Photo renditions.
|
|
79
373
|
* This never enrolls a story, publishes text, creates narration or retries a conflict.
|
|
80
374
|
* Collaboration-managed stories retain their server-enforced immutable revision workflow.
|
|
@@ -260,7 +554,7 @@ var PublicationArticleClient = class {
|
|
|
260
554
|
const selected = [];
|
|
261
555
|
for (const asset of input.assets ?? []) {
|
|
262
556
|
let current = await this.get(created.article_id);
|
|
263
|
-
const
|
|
557
|
+
const digest2 = Array.from(
|
|
264
558
|
new Uint8Array(
|
|
265
559
|
await crypto.subtle.digest(
|
|
266
560
|
"SHA-256",
|
|
@@ -269,7 +563,7 @@ var PublicationArticleClient = class {
|
|
|
269
563
|
)
|
|
270
564
|
).map((v) => v.toString(16).padStart(2, "0")).join("");
|
|
271
565
|
const existing = current.contributions.find(
|
|
272
|
-
(c) => c.kind === "MEDIA" && c.state !== "REJECTED" && c.state !== "WITHDRAWN" && c.payload.sha256 ===
|
|
566
|
+
(c) => c.kind === "MEDIA" && c.state !== "REJECTED" && c.state !== "WITHDRAWN" && c.payload.sha256 === digest2 && Object.entries(asset.metadata).every(
|
|
273
567
|
([k, v]) => c.payload[k] === v
|
|
274
568
|
)
|
|
275
569
|
);
|
|
@@ -350,7 +644,7 @@ var PublicationArticleClient = class {
|
|
|
350
644
|
}
|
|
351
645
|
};
|
|
352
646
|
function sameDocument(a, b) {
|
|
353
|
-
const
|
|
647
|
+
const canonical2 = (d) => [
|
|
354
648
|
d.title,
|
|
355
649
|
d.subtitle || "",
|
|
356
650
|
d.content_markdown,
|
|
@@ -368,7 +662,7 @@ function sameDocument(a, b) {
|
|
|
368
662
|
m.position
|
|
369
663
|
])
|
|
370
664
|
];
|
|
371
|
-
return JSON.stringify(
|
|
665
|
+
return JSON.stringify(canonical2(a)) === JSON.stringify(canonical2(b));
|
|
372
666
|
}
|
|
373
667
|
|
|
374
|
-
export { ArticleWorkflowError, PublicationArticleClient };
|
|
668
|
+
export { ArticlePhotoOperationError, ArticleWorkflowError, PublicationArticleClient, articlePhotoHtmlMatches };
|
package/dist/index.d.cts
CHANGED
|
@@ -18,7 +18,7 @@ export { C as ContentBlock, S as StructuredDocument, a as StructuredMedia, f as
|
|
|
18
18
|
import { M as Measurement } from './sponsorships-CtnBe3S8.cjs';
|
|
19
19
|
export { a as Campaign, b as CampaignStatus, c as CampaignTarget, C as Creative, d as Placement, e as PlacementType, P as PublicAcknowledgment, S as SPONSORSHIP_OFFERINGS, f as SPONSORSHIP_POLICY, g as SponsorParty, p as placementAvailable, s as safeSponsorUrl } from './sponsorships-CtnBe3S8.cjs';
|
|
20
20
|
export { R as READER_SSO_EVENTS, a as READER_SSO_PROPERTY_KEYS, b as ReaderSsoEventDefinition, c as ReaderSsoEventName } from './reader-sso-events-DBw8pGSM.cjs';
|
|
21
|
-
export { A as ArticleFormat,
|
|
21
|
+
export { A as ArticleFormat, b as ArticleLayout, a as ArticlePresentation, c as articleFormatLabel, d as articleHeroRatio, e as articlePresentation } from './article-presentation-0d1FiDgA.cjs';
|
|
22
22
|
import './pets.cjs';
|
|
23
23
|
|
|
24
24
|
/** Walnut Creek Times broadsheet colors. A paper overrides only what it changes. */
|
package/dist/index.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ export { C as ContentBlock, S as StructuredDocument, a as StructuredMedia, f as
|
|
|
18
18
|
import { M as Measurement } from './sponsorships-CtnBe3S8.js';
|
|
19
19
|
export { a as Campaign, b as CampaignStatus, c as CampaignTarget, C as Creative, d as Placement, e as PlacementType, P as PublicAcknowledgment, S as SPONSORSHIP_OFFERINGS, f as SPONSORSHIP_POLICY, g as SponsorParty, p as placementAvailable, s as safeSponsorUrl } from './sponsorships-CtnBe3S8.js';
|
|
20
20
|
export { R as READER_SSO_EVENTS, a as READER_SSO_PROPERTY_KEYS, b as ReaderSsoEventDefinition, c as ReaderSsoEventName } from './reader-sso-events-DBw8pGSM.js';
|
|
21
|
-
export { A as ArticleFormat,
|
|
21
|
+
export { A as ArticleFormat, b as ArticleLayout, a as ArticlePresentation, c as articleFormatLabel, d as articleHeroRatio, e as articlePresentation } from './article-presentation-0d1FiDgA.js';
|
|
22
22
|
import './pets.js';
|
|
23
23
|
|
|
24
24
|
/** Walnut Creek Times broadsheet colors. A paper overrides only what it changes. */
|
|
@@ -78,9 +78,13 @@ function silentSignInUrl(startPath, pathname, search) {
|
|
|
78
78
|
return `${startPath}?${new URLSearchParams({ mode: "silent", next: pathname + search })}`;
|
|
79
79
|
}
|
|
80
80
|
function isFormField(element) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
let focused = element;
|
|
82
|
+
while (focused?.shadowRoot?.activeElement && focused.shadowRoot.activeElement !== focused) {
|
|
83
|
+
focused = focused.shadowRoot.activeElement;
|
|
84
|
+
}
|
|
85
|
+
if (!focused) return false;
|
|
86
|
+
const tag = focused.tagName;
|
|
87
|
+
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || focused.isContentEditable === true;
|
|
84
88
|
}
|
|
85
89
|
var ATTEMPT_KEY = "reader-sso-attempt-v1";
|
|
86
90
|
function readAttempt() {
|
|
@@ -76,9 +76,13 @@ function silentSignInUrl(startPath, pathname, search) {
|
|
|
76
76
|
return `${startPath}?${new URLSearchParams({ mode: "silent", next: pathname + search })}`;
|
|
77
77
|
}
|
|
78
78
|
function isFormField(element) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
let focused = element;
|
|
80
|
+
while (focused?.shadowRoot?.activeElement && focused.shadowRoot.activeElement !== focused) {
|
|
81
|
+
focused = focused.shadowRoot.activeElement;
|
|
82
|
+
}
|
|
83
|
+
if (!focused) return false;
|
|
84
|
+
const tag = focused.tagName;
|
|
85
|
+
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || focused.isContentEditable === true;
|
|
82
86
|
}
|
|
83
87
|
var ATTEMPT_KEY = "reader-sso-attempt-v1";
|
|
84
88
|
function readAttempt() {
|
package/dist/story-review.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as ArticleFormat,
|
|
1
|
+
import { A as ArticleFormat, a as ArticlePresentation } from './article-presentation-0d1FiDgA.cjs';
|
|
2
2
|
import React__default from 'react';
|
|
3
3
|
import { C as ContentBlock } from './article-blocks-DsabIDmH.cjs';
|
|
4
4
|
|
package/dist/story-review.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as ArticleFormat,
|
|
1
|
+
import { A as ArticleFormat, a as ArticlePresentation } from './article-presentation-0d1FiDgA.js';
|
|
2
2
|
import React__default from 'react';
|
|
3
3
|
import { C as ContentBlock } from './article-blocks-DsabIDmH.js';
|
|
4
4
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scalemule/news-publication",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.80",
|
|
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": {
|