@scalemule/news-publication 0.0.79 → 0.0.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -0
- package/bin/scalemule-article.mjs +161 -6
- 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 +677 -8
- package/dist/article-workflow-client.d.cts +111 -3
- package/dist/article-workflow-client.d.ts +111 -3
- package/dist/article-workflow-client.js +674 -9
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/story-review.d.cts +1 -1
- package/dist/story-review.d.ts +1 -1
- package/package.json +1 -1
|
@@ -1,5 +1,652 @@
|
|
|
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-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
|
+
|
|
56
|
+
// src/article-photo-operation.ts
|
|
57
|
+
var ArticlePhotoOperationError = class extends Error {
|
|
58
|
+
constructor(code, stage, message, fileId, status) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.code = code;
|
|
61
|
+
this.stage = stage;
|
|
62
|
+
this.fileId = fileId;
|
|
63
|
+
this.status = status;
|
|
64
|
+
this.name = "ArticlePhotoOperationError";
|
|
65
|
+
}
|
|
66
|
+
code;
|
|
67
|
+
stage;
|
|
68
|
+
fileId;
|
|
69
|
+
status;
|
|
70
|
+
};
|
|
71
|
+
var digest = async (value) => [...new Uint8Array(await crypto.subtle.digest("SHA-256", value))].map((v) => v.toString(16).padStart(2, "0")).join("");
|
|
72
|
+
var hash = (value) => digest(new TextEncoder().encode(JSON.stringify(value)));
|
|
73
|
+
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;
|
|
74
|
+
var stableArticle = (p) => [
|
|
75
|
+
p.id,
|
|
76
|
+
p.slug,
|
|
77
|
+
p.title,
|
|
78
|
+
p.subtitle,
|
|
79
|
+
p.content_markdown,
|
|
80
|
+
p.content_html,
|
|
81
|
+
p.status,
|
|
82
|
+
p.visibility,
|
|
83
|
+
p.published_at,
|
|
84
|
+
p.author_user_id,
|
|
85
|
+
p.audio_file_id,
|
|
86
|
+
Object.fromEntries(Object.entries(p.metadata ?? {}).filter(([k]) => !["cover_image", "open_graph_image", "visual_desk_receipt"].includes(k)).sort(([a], [b]) => a.localeCompare(b)))
|
|
87
|
+
];
|
|
88
|
+
function photoType(bytes) {
|
|
89
|
+
if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255)
|
|
90
|
+
return "image/jpeg";
|
|
91
|
+
const ascii = (n, length) => String.fromCharCode(...bytes.subarray(n, n + length));
|
|
92
|
+
const png = ascii(0, 8) === "\x89PNG\r\n\n";
|
|
93
|
+
const webp = ascii(0, 4) === "RIFF" && ascii(8, 4) === "WEBP";
|
|
94
|
+
if (!png && !webp)
|
|
95
|
+
throw Error("Use a still JPEG, PNG or WebP photo; animations use the Video workflow");
|
|
96
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
97
|
+
for (let n = png ? 8 : 12, chunks = 0; n < bytes.length; chunks++) {
|
|
98
|
+
if (chunks >= 1e4 || n + 8 > bytes.length)
|
|
99
|
+
throw Error("Invalid or truncated photo container");
|
|
100
|
+
const kind = ascii(png ? n + 4 : n, 4);
|
|
101
|
+
if (["acTL", "ANIM", "ANMF"].includes(kind))
|
|
102
|
+
throw Error("Animated photos require the Video workflow");
|
|
103
|
+
const size = view.getUint32(png ? n : n + 4, webp);
|
|
104
|
+
n += size + (png ? 12 : 8 + size % 2);
|
|
105
|
+
if (n > bytes.length)
|
|
106
|
+
throw Error("Truncated photo container");
|
|
107
|
+
if (png && kind === "IEND")
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
return png ? "image/png" : "image/webp";
|
|
111
|
+
}
|
|
112
|
+
function immutableUrl(raw) {
|
|
113
|
+
try {
|
|
114
|
+
const u = new URL(String(raw));
|
|
115
|
+
return u.protocol === "https:" && !u.username && !u.password && !u.search && !u.hash;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
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;
|
|
121
|
+
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));
|
|
122
|
+
async function runArticlePhoto(t, input) {
|
|
123
|
+
const started = performance.now();
|
|
124
|
+
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())
|
|
125
|
+
throw Error("Supply a stable operation key, current article version, confirmed rights, factual alt and explicit provenance");
|
|
126
|
+
if (typeof input.caption !== "string" || typeof input.credit !== "string" || !(input.label === null || typeof input.label === "string"))
|
|
127
|
+
throw Error("Supply explicit caption, credit and provenance label");
|
|
128
|
+
for (const [name, value, limit] of [["caption", input.caption, 2e3], ["credit", input.credit, 8e3], ["alt", input.alt, 1e3], ["label", input.label ?? "", 200]]) {
|
|
129
|
+
if ([...value].length > limit) throw Error(`Photo ${name} exceeds ${limit} characters`);
|
|
130
|
+
}
|
|
131
|
+
const focal = input.focalPoint ?? { x: 0.5, y: 0.5 };
|
|
132
|
+
if (![focal.x, focal.y].every((v) => Number.isFinite(v) && v >= 0 && v <= 1))
|
|
133
|
+
throw Error("Focal point must be within the image");
|
|
134
|
+
if (!input.file.size || input.file.size > 25 * 1024 * 1024)
|
|
135
|
+
throw Error("A photo must be between 1 byte and 25 MiB");
|
|
136
|
+
const bytes = new Uint8Array(await input.file.arrayBuffer());
|
|
137
|
+
const mime = photoType(bytes);
|
|
138
|
+
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 }]);
|
|
139
|
+
if (input.checkpoint && (input.checkpoint.version !== 1 || input.checkpoint.identity !== identity))
|
|
140
|
+
throw new ArticlePhotoOperationError("OPERATION_CONFLICT", "validation", "The saved operation belongs to different input; do not overwrite it");
|
|
141
|
+
let state = input.checkpoint ? structuredClone(input.checkpoint) : void 0;
|
|
142
|
+
let stage = "preflight";
|
|
143
|
+
const deadline = AbortSignal.timeout(Math.min(Math.max(input.timeoutMs ?? 3e4, 100), 12e4));
|
|
144
|
+
const request = (path, method, body) => t.request(path, method, body, void 0, deadline);
|
|
145
|
+
const progress = async (next) => {
|
|
146
|
+
stage = next;
|
|
147
|
+
if (state) {
|
|
148
|
+
state.stage = next;
|
|
149
|
+
await input.saveCheckpoint(structuredClone(state));
|
|
150
|
+
}
|
|
151
|
+
input.onProgress?.({ stage, elapsed_ms: Math.round(performance.now() - started), file_id: state?.fileId });
|
|
152
|
+
};
|
|
153
|
+
const sleep = () => new Promise((resolve, reject) => {
|
|
154
|
+
deadline.throwIfAborted();
|
|
155
|
+
const id = setTimeout(() => {
|
|
156
|
+
deadline.removeEventListener("abort", abort);
|
|
157
|
+
resolve();
|
|
158
|
+
}, 300);
|
|
159
|
+
const abort = () => {
|
|
160
|
+
clearTimeout(id);
|
|
161
|
+
reject(deadline.reason);
|
|
162
|
+
};
|
|
163
|
+
deadline.addEventListener("abort", abort, { once: true });
|
|
164
|
+
});
|
|
165
|
+
const postPath = `/articles/${encodeURIComponent(input.articleId)}/document`;
|
|
166
|
+
try {
|
|
167
|
+
const [post, options, collaboration, policy] = await Promise.all([
|
|
168
|
+
request(postPath),
|
|
169
|
+
request(`/article-workflow/options?article_id=${encodeURIComponent(input.articleId)}`),
|
|
170
|
+
request(`/articles/${encodeURIComponent(input.articleId)}/collaboration`).then(() => true).catch((error) => {
|
|
171
|
+
if (error.status === 404 && error.message === "Story collaboration is not enabled")
|
|
172
|
+
return false;
|
|
173
|
+
throw error;
|
|
174
|
+
}),
|
|
175
|
+
request(`/v1/photos/policies/article/editor?publication_id=${encodeURIComponent(t.publicationId)}`)
|
|
176
|
+
]);
|
|
177
|
+
if (post.status !== "published" || post.visibility !== "public" || options.workflow_version !== 1)
|
|
178
|
+
throw new ArticlePhotoOperationError("ARTICLE_NOT_PUBLIC", stage, "Select a currently published public article");
|
|
179
|
+
if (collaboration)
|
|
180
|
+
throw new ArticlePhotoOperationError("REVISION_WORKFLOW_REQUIRED", stage, "This story requires an immutable media revision and editorial release; no upload or public mutation was made");
|
|
181
|
+
if (state?.payload && mediaMatches(post, state.payload)) {
|
|
182
|
+
if (await hash(stableArticle(post)) !== state.articleHash)
|
|
183
|
+
throw new ArticlePhotoOperationError("ARTICLE_CHANGED", stage, "The photo is attached, but other article content changed; inspect before continuing", state.fileId);
|
|
184
|
+
state.attachedVersion = post.version;
|
|
185
|
+
await progress("verified");
|
|
186
|
+
return { article_id: input.articleId, file_id: state.fileId, version: post.version, state: "already_applied", elapsed_ms: Math.round(performance.now() - started) };
|
|
187
|
+
}
|
|
188
|
+
if (post.version !== input.expectedVersion)
|
|
189
|
+
throw new ArticlePhotoOperationError("ARTICLE_CHANGED", stage, "The article version changed; existing text and media were preserved", state?.fileId);
|
|
190
|
+
const articleHash = await hash(stableArticle(post));
|
|
191
|
+
if (state && state.articleHash !== articleHash)
|
|
192
|
+
throw new ArticlePhotoOperationError("ARTICLE_CHANGED", stage, "The article changed while the operation was paused", state.fileId);
|
|
193
|
+
state ??= { version: 1, identity, stage, expectedVersion: input.expectedVersion, articleHash };
|
|
194
|
+
await progress("upload");
|
|
195
|
+
if (!state.etag) {
|
|
196
|
+
const filename = `article-${identity}.${mime === "image/jpeg" ? "jpg" : mime.split("/")[1]}`;
|
|
197
|
+
const reservation = await request("/v1/storage/signed-url/multipart/start", "POST", {
|
|
198
|
+
filename,
|
|
199
|
+
content_type: mime,
|
|
200
|
+
size_bytes: bytes.length,
|
|
201
|
+
visibility: "anonymous_visible",
|
|
202
|
+
chunk_size: 25 * 1024 * 1024,
|
|
203
|
+
client_upload_key: identity,
|
|
204
|
+
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 }
|
|
205
|
+
});
|
|
206
|
+
if (state.fileId && state.fileId !== reservation.file_id || reservation.total_parts !== 1)
|
|
207
|
+
throw new ArticlePhotoOperationError("UPLOAD_SESSION_CHANGED", stage, "The original upload session expired or changed; no duplicate attachment was attempted", state.fileId);
|
|
208
|
+
state.fileId = reservation.file_id;
|
|
209
|
+
state.sessionId = reservation.upload_session_id;
|
|
210
|
+
state.completionToken = reservation.completion_token;
|
|
211
|
+
await progress("upload");
|
|
212
|
+
const info = await request(`/v1/storage/files/${state.fileId}/info`);
|
|
213
|
+
if (info.filename !== filename)
|
|
214
|
+
throw new ArticlePhotoOperationError("UPLOAD_IDENTITY_MISMATCH", stage, "The upload session does not match these source bytes", state.fileId);
|
|
215
|
+
const part = reservation.part_urls?.find((p) => p.part_number === 1);
|
|
216
|
+
const u = new URL(part?.url);
|
|
217
|
+
if (u.protocol !== "https:" || u.username || u.password)
|
|
218
|
+
throw Error("Invalid managed upload destination");
|
|
219
|
+
const response = await t.fetch(u, { method: "PUT", body: input.file, redirect: "error", signal: deadline });
|
|
220
|
+
const etag = response.headers.get("etag");
|
|
221
|
+
await response.body?.cancel();
|
|
222
|
+
if (!response.ok || !etag)
|
|
223
|
+
throw Error("Managed upload did not acknowledge its part; resume this operation");
|
|
224
|
+
state.etag = etag;
|
|
225
|
+
await progress("complete");
|
|
226
|
+
}
|
|
227
|
+
if (!state.completed) {
|
|
228
|
+
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 }] });
|
|
229
|
+
state.completed = true;
|
|
230
|
+
delete state.completionToken;
|
|
231
|
+
await progress("scan");
|
|
232
|
+
}
|
|
233
|
+
for (; ; ) {
|
|
234
|
+
const status = await request(`/v1/storage/files/${state.fileId}/status`);
|
|
235
|
+
if (["threat", "quarantined", "error"].includes(status.scan?.status))
|
|
236
|
+
throw new ArticlePhotoOperationError("SCAN_REJECTED", stage, "Storage did not clear this image; the previous cover remains", state.fileId);
|
|
237
|
+
if (status.scan?.status === "clean" && status.urls?.cdn_url)
|
|
238
|
+
break;
|
|
239
|
+
await sleep();
|
|
240
|
+
}
|
|
241
|
+
await progress("photo");
|
|
242
|
+
const batched = policy.capabilities?.batched_article_renditions === true;
|
|
243
|
+
let prepared;
|
|
244
|
+
if (!state.registered || batched) {
|
|
245
|
+
if (batched) await progress("renditions");
|
|
246
|
+
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 });
|
|
247
|
+
if (batched && registered.prepared_article_renditions !== true) throw Error("Photo did not confirm the measured rendition batch; resume the same upload");
|
|
248
|
+
prepared = batched ? registered : void 0;
|
|
249
|
+
state.registered = true;
|
|
250
|
+
await progress("renditions");
|
|
251
|
+
}
|
|
252
|
+
const source = prepared?.source ?? await request(`/v1/photos/public/${state.fileId}/manifest?preset=hero`);
|
|
253
|
+
if (source.file_id !== state.fileId || !Number.isInteger(source.width) || source.width <= 0)
|
|
254
|
+
throw Error("Photo source identity or measured dimensions are missing");
|
|
255
|
+
const requirements = ["lead", "thumbnail", "social"].flatMap((role) => {
|
|
256
|
+
const specs = policy.roles?.[role]?.renditions;
|
|
257
|
+
if (!Array.isArray(specs) || !specs.length)
|
|
258
|
+
throw Error("Publication Photo policy is incomplete");
|
|
259
|
+
return specs.filter((r) => r.fit !== "scaledown" || r.width <= source.width).map((spec) => ({ role, spec }));
|
|
260
|
+
});
|
|
261
|
+
if (!requirements.length || requirements.length > 48)
|
|
262
|
+
throw Error("Publication Photo policy exceeds operation limits");
|
|
263
|
+
const outputs = [];
|
|
264
|
+
const preparations = /* @__PURE__ */ new Map();
|
|
265
|
+
let next = 0;
|
|
266
|
+
await Promise.all(Array.from({ length: Math.min(4, requirements.length) }, async () => {
|
|
267
|
+
while (next < requirements.length) {
|
|
268
|
+
const { role, spec: r } = requirements[next++];
|
|
269
|
+
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))
|
|
270
|
+
throw Error("Invalid publication Photo policy");
|
|
271
|
+
const key = `w${r.width}_h${r.height ?? 0}_${r.fit}_q${r.quality}_${r.format}`;
|
|
272
|
+
let preparation = preparations.get(key);
|
|
273
|
+
if (!preparation) {
|
|
274
|
+
preparation = (async () => {
|
|
275
|
+
const path = `/v1/photos/public/${state.fileId}/manifest?rendition=${key}`;
|
|
276
|
+
let receipt2 = batched ? prepared?.article_renditions?.find((v) => v.key === key && v.role === role && v.format === r.format) : await request(path);
|
|
277
|
+
if (batched && !measured(receipt2, state.fileId)) throw Error("Photo batch is missing a required measured receipt; resume the same uploaded file");
|
|
278
|
+
if (!measured(receipt2, state.fileId)) {
|
|
279
|
+
const q = new URLSearchParams({ width: String(r.width), fit: r.fit, quality: String(r.quality), format: r.format });
|
|
280
|
+
if (r.height)
|
|
281
|
+
q.set("height", String(r.height));
|
|
282
|
+
const response = await t.fetch(new URL(t.apiUrl.replace(/\/$/, "") + `/v1/photos/public/${state.fileId}/transform?${q}`), { method: "GET", redirect: "manual", signal: deadline });
|
|
283
|
+
const ok = response.ok && response.headers.get("content-type")?.startsWith("image/") || [302, 303, 307, 308].includes(response.status);
|
|
284
|
+
await response.body?.cancel();
|
|
285
|
+
if (!ok)
|
|
286
|
+
throw Error("Photo could not prepare a required rendition");
|
|
287
|
+
while (!measured(receipt2, state.fileId)) {
|
|
288
|
+
receipt2 = await request(path);
|
|
289
|
+
if (!measured(receipt2, state.fileId))
|
|
290
|
+
await sleep();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return receipt2;
|
|
294
|
+
})();
|
|
295
|
+
preparations.set(key, preparation);
|
|
296
|
+
}
|
|
297
|
+
const receipt = await preparation;
|
|
298
|
+
outputs.push({ role, file_id: state.fileId, url: receipt.url, width: receipt.width, height: receipt.height, format: r.format });
|
|
299
|
+
}
|
|
300
|
+
}));
|
|
301
|
+
const leads = outputs.filter((o) => o.role === "lead").sort((a, b) => a.width - b.width || a.format.localeCompare(b.format));
|
|
302
|
+
const master = [...leads].reverse().find((o) => o.format === "webp") ?? leads.at(-1);
|
|
303
|
+
const social = outputs.find((o) => o.role === "social" && o.format === "jpeg") ?? outputs.find((o) => o.role === "social");
|
|
304
|
+
if (!master || !social || leads.length > 20)
|
|
305
|
+
throw Error("Photo policy has no usable lead or social output");
|
|
306
|
+
state.payload = {
|
|
307
|
+
expected_version: state.expectedVersion,
|
|
308
|
+
cover_image_url: master.url,
|
|
309
|
+
og_image_url: social.url,
|
|
310
|
+
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 })) },
|
|
311
|
+
open_graph_image: { file_id: state.fileId, url: social.url, width: social.width, height: social.height, aspect_ratio: `${social.width}:${social.height}` }
|
|
312
|
+
};
|
|
313
|
+
await progress("attach");
|
|
314
|
+
const result = await request(`/articles/${encodeURIComponent(input.articleId)}/media`, "PUT", state.payload);
|
|
315
|
+
state.attachedVersion = result.version;
|
|
316
|
+
await progress("verify");
|
|
317
|
+
const after = await request(postPath);
|
|
318
|
+
if (!mediaMatches(after, state.payload) || await hash(stableArticle(after)) !== state.articleHash)
|
|
319
|
+
throw new ArticlePhotoOperationError("VERIFICATION_CONFLICT", stage, "The current article differs from this operation; inspect it before making another change", state.fileId);
|
|
320
|
+
await progress("verified");
|
|
321
|
+
return { article_id: input.articleId, file_id: state.fileId, version: after.version, state: "verified", elapsed_ms: Math.round(performance.now() - started) };
|
|
322
|
+
} catch (error) {
|
|
323
|
+
if (error instanceof ArticlePhotoOperationError)
|
|
324
|
+
throw error;
|
|
325
|
+
const status = typeof error?.status === "number" ? error.status : void 0;
|
|
326
|
+
const code = deadline.aborted ? "DEADLINE_EXCEEDED" : status === 409 && stage === "attach" ? "ARTICLE_CHANGED" : status === 401 || status === 403 ? "PERMISSION_DENIED" : "STEP_FAILED";
|
|
327
|
+
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);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
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
|
+
|
|
3
650
|
// src/article-workflow-client.ts
|
|
4
651
|
var ArticleWorkflowError = class extends Error {
|
|
5
652
|
constructor(message, status, articleId) {
|
|
@@ -21,7 +668,7 @@ var PublicationArticleClient = class {
|
|
|
21
668
|
}
|
|
22
669
|
config;
|
|
23
670
|
base;
|
|
24
|
-
async request(path, method = "GET", body, extra) {
|
|
671
|
+
async request(path, method = "GET", body, extra, signal) {
|
|
25
672
|
const headers = new Headers(extra);
|
|
26
673
|
headers.set("accept", "application/json");
|
|
27
674
|
if (this.config.apiKey) headers.set("x-api-key", this.config.apiKey);
|
|
@@ -39,7 +686,7 @@ var PublicationArticleClient = class {
|
|
|
39
686
|
method,
|
|
40
687
|
headers,
|
|
41
688
|
body: body === void 0 ? void 0 : form ? body : JSON.stringify(body),
|
|
42
|
-
signal: AbortSignal.timeout(form ? 9e4 : 3e4),
|
|
689
|
+
signal: signal ?? AbortSignal.timeout(form ? 9e4 : 3e4),
|
|
43
690
|
cache: "no-store",
|
|
44
691
|
redirect: "error"
|
|
45
692
|
});
|
|
@@ -56,6 +703,9 @@ var PublicationArticleClient = class {
|
|
|
56
703
|
`/article-workflow/options${articleId ? `?article_id=${encodeURIComponent(articleId)}` : ""}`
|
|
57
704
|
);
|
|
58
705
|
}
|
|
706
|
+
publicArticle(slug, signal) {
|
|
707
|
+
return this.request("/article-content/" + encodeURIComponent(slug), void 0, void 0, void 0, signal);
|
|
708
|
+
}
|
|
59
709
|
/** Explicit approver-authorized onboarding for a legacy article; never called by get().
|
|
60
710
|
* Existing schedules are cancelled by the server; public content remains unchanged.
|
|
61
711
|
* Missing immutable media identities must be resolved through newsroom media controls.
|
|
@@ -72,11 +722,22 @@ var PublicationArticleClient = class {
|
|
|
72
722
|
`/articles/${encodeURIComponent(articleId)}/collaboration`
|
|
73
723
|
);
|
|
74
724
|
}
|
|
75
|
-
processing(articleId) {
|
|
725
|
+
processing(articleId, signal) {
|
|
76
726
|
return this.request(
|
|
77
|
-
`/articles/${encodeURIComponent(articleId)}/processing
|
|
727
|
+
`/articles/${encodeURIComponent(articleId)}/processing`,
|
|
728
|
+
void 0,
|
|
729
|
+
void 0,
|
|
730
|
+
void 0,
|
|
731
|
+
signal
|
|
78
732
|
);
|
|
79
733
|
}
|
|
734
|
+
uploadAndReplacePublishedCover(input) {
|
|
735
|
+
return runArticlePhoto({ publicationId: this.config.publicationId, apiUrl: this.config.apiUrl, fetch: this.config.fetch ?? fetch, request: this.request.bind(this) }, input);
|
|
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
|
+
}
|
|
80
741
|
/** Replace a legacy publication cover using ready, measured public Photo renditions.
|
|
81
742
|
* This never enrolls a story, publishes text, creates narration or retries a conflict.
|
|
82
743
|
* Collaboration-managed stories retain their server-enforced immutable revision workflow.
|
|
@@ -262,7 +923,7 @@ var PublicationArticleClient = class {
|
|
|
262
923
|
const selected = [];
|
|
263
924
|
for (const asset of input.assets ?? []) {
|
|
264
925
|
let current = await this.get(created.article_id);
|
|
265
|
-
const
|
|
926
|
+
const digest3 = Array.from(
|
|
266
927
|
new Uint8Array(
|
|
267
928
|
await crypto.subtle.digest(
|
|
268
929
|
"SHA-256",
|
|
@@ -271,7 +932,7 @@ var PublicationArticleClient = class {
|
|
|
271
932
|
)
|
|
272
933
|
).map((v) => v.toString(16).padStart(2, "0")).join("");
|
|
273
934
|
const existing = current.contributions.find(
|
|
274
|
-
(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(
|
|
275
936
|
([k, v]) => c.payload[k] === v
|
|
276
937
|
)
|
|
277
938
|
);
|
|
@@ -352,10 +1013,14 @@ var PublicationArticleClient = class {
|
|
|
352
1013
|
}
|
|
353
1014
|
};
|
|
354
1015
|
function sameDocument(a, b) {
|
|
355
|
-
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) => [
|
|
356
1018
|
d.title,
|
|
357
1019
|
d.subtitle || "",
|
|
358
1020
|
d.content_markdown,
|
|
1021
|
+
d.excerpt ?? null,
|
|
1022
|
+
sorted(d.cards ?? null),
|
|
1023
|
+
sorted(d.citations ?? null),
|
|
359
1024
|
d.section,
|
|
360
1025
|
d.community || "",
|
|
361
1026
|
d.format || null,
|
|
@@ -370,8 +1035,12 @@ function sameDocument(a, b) {
|
|
|
370
1035
|
m.position
|
|
371
1036
|
])
|
|
372
1037
|
];
|
|
373
|
-
return JSON.stringify(
|
|
1038
|
+
return JSON.stringify(canonical3(a)) === JSON.stringify(canonical3(b));
|
|
374
1039
|
}
|
|
375
1040
|
|
|
1041
|
+
exports.ArticleEditOperationError = ArticleEditOperationError;
|
|
1042
|
+
exports.ArticlePhotoOperationError = ArticlePhotoOperationError;
|
|
376
1043
|
exports.ArticleWorkflowError = ArticleWorkflowError;
|
|
377
1044
|
exports.PublicationArticleClient = PublicationArticleClient;
|
|
1045
|
+
exports.articleEditHtmlMatches = articleEditHtmlMatches;
|
|
1046
|
+
exports.articlePhotoHtmlMatches = articlePhotoHtmlMatches;
|