@rightkit/social-ui 0.0.0-stage → 0.1.0
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/CHANGELOG.md +5 -0
- package/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +40 -2
- package/dist/appName.d.ts +2 -0
- package/dist/appName.js +8 -0
- package/dist/components/ScopeSelect.d.ts +7 -0
- package/dist/components/ScopeSelect.js +6 -0
- package/dist/components/Shared.d.ts +34 -0
- package/dist/components/Shared.js +55 -0
- package/dist/data/derive.d.ts +21 -0
- package/dist/data/derive.js +47 -0
- package/dist/data/draftRules.d.ts +10 -0
- package/dist/data/draftRules.js +20 -0
- package/dist/data/engine/engineDataSource.d.ts +194 -0
- package/dist/data/engine/engineDataSource.js +836 -0
- package/dist/data/engine/errors.d.ts +13 -0
- package/dist/data/engine/errors.js +21 -0
- package/dist/data/engine/mapping.d.ts +172 -0
- package/dist/data/engine/mapping.js +1487 -0
- package/dist/data/engine/protocol.d.ts +910 -0
- package/dist/data/engine/protocol.js +67 -0
- package/dist/data/index.d.ts +18 -0
- package/dist/data/index.js +23 -0
- package/dist/data/mock/devSource.d.ts +7 -0
- package/dist/data/mock/devSource.js +10 -0
- package/dist/data/mock/fixtures-analytics.d.ts +7 -0
- package/dist/data/mock/fixtures-analytics.js +123 -0
- package/dist/data/mock/fixtures-briefs.d.ts +4 -0
- package/dist/data/mock/fixtures-briefs.js +230 -0
- package/dist/data/mock/fixtures-content.d.ts +4 -0
- package/dist/data/mock/fixtures-content.js +89 -0
- package/dist/data/mock/fixtures-digests.d.ts +3 -0
- package/dist/data/mock/fixtures-digests.js +40 -0
- package/dist/data/mock/fixtures-drafts.d.ts +2 -0
- package/dist/data/mock/fixtures-drafts.js +74 -0
- package/dist/data/mock/fixtures-gate.d.ts +3 -0
- package/dist/data/mock/fixtures-gate.js +49 -0
- package/dist/data/mock/fixtures-hooks.d.ts +2 -0
- package/dist/data/mock/fixtures-hooks.js +103 -0
- package/dist/data/mock/fixtures-inbox.d.ts +11 -0
- package/dist/data/mock/fixtures-inbox.js +213 -0
- package/dist/data/mock/fixtures-links.d.ts +5 -0
- package/dist/data/mock/fixtures-links.js +89 -0
- package/dist/data/mock/fixtures-memory.d.ts +3 -0
- package/dist/data/mock/fixtures-memory.js +148 -0
- package/dist/data/mock/fixtures-ops.d.ts +15 -0
- package/dist/data/mock/fixtures-ops.js +403 -0
- package/dist/data/mock/fixtures-optimise.d.ts +18 -0
- package/dist/data/mock/fixtures-optimise.js +221 -0
- package/dist/data/mock/fixtures-pacing.d.ts +5 -0
- package/dist/data/mock/fixtures-pacing.js +285 -0
- package/dist/data/mock/fixtures-publish.d.ts +2 -0
- package/dist/data/mock/fixtures-publish.js +129 -0
- package/dist/data/mock/fixtures-store.d.ts +10 -0
- package/dist/data/mock/fixtures-store.js +13 -0
- package/dist/data/mock/fixtures-variants.d.ts +5 -0
- package/dist/data/mock/fixtures-variants.js +138 -0
- package/dist/data/mock/fixtures.d.ts +6 -0
- package/dist/data/mock/fixtures.js +420 -0
- package/dist/data/mock/mockDataSource.d.ts +98 -0
- package/dist/data/mock/mockDataSource.js +903 -0
- package/dist/data/money.d.ts +14 -0
- package/dist/data/money.js +36 -0
- package/dist/data/platforms.d.ts +12 -0
- package/dist/data/platforms.js +26 -0
- package/dist/data/source.d.ts +196 -0
- package/dist/data/source.js +1 -0
- package/dist/data/time.d.ts +23 -0
- package/dist/data/time.js +86 -0
- package/dist/data/types.d.ts +1092 -0
- package/dist/data/types.js +3 -0
- package/dist/env.d.ts +59 -0
- package/dist/hooks/useAsync.d.ts +19 -0
- package/dist/hooks/useAsync.js +25 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +58 -0
- package/dist/lib/host.d.ts +21 -0
- package/dist/lib/host.js +32 -0
- package/dist/lib/shell.d.ts +156 -0
- package/dist/lib/shell.js +16 -0
- package/dist/screens/AccountsScreen.d.ts +8 -0
- package/dist/screens/AccountsScreen.js +100 -0
- package/dist/screens/AnalyticsScreen.d.ts +7 -0
- package/dist/screens/AnalyticsScreen.js +64 -0
- package/dist/screens/ApprovalsScreen.d.ts +10 -0
- package/dist/screens/ApprovalsScreen.js +144 -0
- package/dist/screens/BriefsScreen.d.ts +7 -0
- package/dist/screens/BriefsScreen.js +83 -0
- package/dist/screens/CalendarScreen.d.ts +10 -0
- package/dist/screens/CalendarScreen.js +143 -0
- package/dist/screens/CampaignsScreen.d.ts +7 -0
- package/dist/screens/CampaignsScreen.js +52 -0
- package/dist/screens/ChannelMemoryScreen.d.ts +7 -0
- package/dist/screens/ChannelMemoryScreen.js +109 -0
- package/dist/screens/ContentScreen.d.ts +7 -0
- package/dist/screens/ContentScreen.js +64 -0
- package/dist/screens/DigestsScreen.d.ts +7 -0
- package/dist/screens/DigestsScreen.js +53 -0
- package/dist/screens/DraftsScreen.d.ts +10 -0
- package/dist/screens/DraftsScreen.js +108 -0
- package/dist/screens/FailuresScreen.d.ts +8 -0
- package/dist/screens/FailuresScreen.js +111 -0
- package/dist/screens/HookTestsScreen.d.ts +7 -0
- package/dist/screens/HookTestsScreen.js +54 -0
- package/dist/screens/InboxScreen.d.ts +12 -0
- package/dist/screens/InboxScreen.js +76 -0
- package/dist/screens/LinksScreen.d.ts +7 -0
- package/dist/screens/LinksScreen.js +52 -0
- package/dist/screens/PacingScreen.d.ts +7 -0
- package/dist/screens/PacingScreen.js +88 -0
- package/dist/screens/PublishJobsScreen.d.ts +7 -0
- package/dist/screens/PublishJobsScreen.js +66 -0
- package/dist/screens/SuggestionsScreen.d.ts +8 -0
- package/dist/screens/SuggestionsScreen.js +94 -0
- package/dist/screens/TextGateScreen.d.ts +7 -0
- package/dist/screens/TextGateScreen.js +111 -0
- package/dist/screens/ThreadsScreen.d.ts +8 -0
- package/dist/screens/ThreadsScreen.js +113 -0
- package/dist/screens/TypedBriefsScreen.d.ts +7 -0
- package/dist/screens/TypedBriefsScreen.js +75 -0
- package/dist/screens/labels.d.ts +121 -0
- package/dist/screens/labels.js +272 -0
- package/package.json +56 -3
|
@@ -0,0 +1,1487 @@
|
|
|
1
|
+
import { isKnownPlatform, isSocialPlatform } from "../platforms.js";
|
|
2
|
+
import { EngineReadError } from "./errors.js";
|
|
3
|
+
import { hostAppName } from "../../appName.js";
|
|
4
|
+
// "remake" is the format an accepted optimise suggestion gives its draft calendar entry.
|
|
5
|
+
const FORMATS = ["reel", "carousel", "photo", "short", "video", "text_post", "remake"];
|
|
6
|
+
const JOB_STATES = [
|
|
7
|
+
"draft",
|
|
8
|
+
"media_pending",
|
|
9
|
+
"ready",
|
|
10
|
+
"scheduled",
|
|
11
|
+
"published",
|
|
12
|
+
"failed",
|
|
13
|
+
"late",
|
|
14
|
+
"paused",
|
|
15
|
+
];
|
|
16
|
+
const REJECT_REASONS = ["wrong_content", "wrong_account", "timing", "media_issue", "other"];
|
|
17
|
+
/** The engine's default preparation window (calendar.list without prep_minutes). */
|
|
18
|
+
const PREP_MINUTES = 30;
|
|
19
|
+
function isOneOf(list, value) {
|
|
20
|
+
return list.includes(value);
|
|
21
|
+
}
|
|
22
|
+
function mappingError(message) {
|
|
23
|
+
return new EngineReadError("engine_mapping", message);
|
|
24
|
+
}
|
|
25
|
+
/** A platform id as stored, when the desktop shows it (an account or draft-only platform). Any other id is an engine_mapping error. */
|
|
26
|
+
function platformText(platform, field) {
|
|
27
|
+
if (isKnownPlatform(platform))
|
|
28
|
+
return platform;
|
|
29
|
+
throw mappingError(`${field} platform "${platform}" is not shown by the desktop yet`);
|
|
30
|
+
}
|
|
31
|
+
// ---- Keys -------------------------------------------------------------------------------------
|
|
32
|
+
// A PublishJob and its account carry the engine scope inside their ids, so a later call can rebuild
|
|
33
|
+
// the scope without a lookup. Each part is URI-encoded, then the parts are joined with "/".
|
|
34
|
+
/** The desktop's account key: the engine scope (venture, channel, account). */
|
|
35
|
+
export function accountKey(scope) {
|
|
36
|
+
return [scope.venture, scope.channel, scope.account].map((part) => encodeURIComponent(part)).join("/");
|
|
37
|
+
}
|
|
38
|
+
/** The desktop's job id: the account key plus the engine entry id. */
|
|
39
|
+
export function jobKey(scope, entryId) {
|
|
40
|
+
return `${accountKey(scope)}/${encodeURIComponent(entryId)}`;
|
|
41
|
+
}
|
|
42
|
+
/** Reverses jobKey. Null when the id is not one this data source issued. */
|
|
43
|
+
export function parseJobKey(id) {
|
|
44
|
+
const parts = id.split("/");
|
|
45
|
+
if (parts.length !== 4)
|
|
46
|
+
return null;
|
|
47
|
+
try {
|
|
48
|
+
const [venture, channel, account, entryId] = parts.map((part) => decodeURIComponent(part));
|
|
49
|
+
if (!venture || !channel || !account || !entryId)
|
|
50
|
+
return null;
|
|
51
|
+
return { scope: { venture, channel, account }, entryId };
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The desktop's account id: the engine scope plus the platform. The engine keys an account by scope
|
|
59
|
+
* and platform. Jobs carry only the scope (`accountKey`), so a job does not name one of its
|
|
60
|
+
* channel's accounts (see "Engine gaps" in the design doc).
|
|
61
|
+
*/
|
|
62
|
+
export function accountId(scope, platform) {
|
|
63
|
+
return `${accountKey(scope)}/${encodeURIComponent(platform)}`;
|
|
64
|
+
}
|
|
65
|
+
/** Reverses accountId. Null when the id is not one this data source issued. */
|
|
66
|
+
export function parseAccountId(id) {
|
|
67
|
+
const parts = id.split("/");
|
|
68
|
+
if (parts.length !== 4)
|
|
69
|
+
return null;
|
|
70
|
+
try {
|
|
71
|
+
const [venture, channel, account, platform] = parts.map((part) => decodeURIComponent(part));
|
|
72
|
+
if (!venture || !channel || !account || !platform)
|
|
73
|
+
return null;
|
|
74
|
+
return { scope: { venture, channel, account }, platform };
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* A calendar entry reference (a suggestion's, a link's, a digest application's, an attribution row's). It is the
|
|
82
|
+
* entry's job key, the id PublishJob.id carries, so parseJobKey reads it back. Null when the engine names no entry.
|
|
83
|
+
*/
|
|
84
|
+
function calendarJobKey(scope, entryId) {
|
|
85
|
+
return entryId === null ? null : jobKey(scope, entryId);
|
|
86
|
+
}
|
|
87
|
+
// ---- Manual drafts ----------------------------------------------------------------------------
|
|
88
|
+
// A draft id has the same shape as a job id: the engine scope plus the engine's own draft id, so a
|
|
89
|
+
// later call rebuilds the scope without a lookup.
|
|
90
|
+
const DRAFT_STATES = ["drafted", "approved", "posted_by_operator", "skipped", "stale"];
|
|
91
|
+
const DRAFT_KINDS = ["post", "comment", "reply"];
|
|
92
|
+
/** The desktop's draft id: the engine scope plus the engine draft id. */
|
|
93
|
+
export function draftKey(scope, draftId) {
|
|
94
|
+
return jobKey(scope, draftId);
|
|
95
|
+
}
|
|
96
|
+
/** Reverses draftKey. Null when the id is not one this data source issued. */
|
|
97
|
+
export function parseDraftKey(id) {
|
|
98
|
+
const key = parseJobKey(id);
|
|
99
|
+
return key ? { scope: key.scope, draftId: key.entryId } : null;
|
|
100
|
+
}
|
|
101
|
+
function draftState(state) {
|
|
102
|
+
if (isOneOf(DRAFT_STATES, state))
|
|
103
|
+
return state;
|
|
104
|
+
throw mappingError(`draft state "${state}" is not shown by the desktop yet`);
|
|
105
|
+
}
|
|
106
|
+
function draftKind(kind) {
|
|
107
|
+
if (isOneOf(DRAFT_KINDS, kind))
|
|
108
|
+
return kind;
|
|
109
|
+
throw mappingError(`draft kind "${kind}" is not shown by the desktop yet`);
|
|
110
|
+
}
|
|
111
|
+
/** An engine draft as the desktop's ManualDraft. A state or kind the desktop does not show is an engine_mapping error. */
|
|
112
|
+
export function draftOf(engine) {
|
|
113
|
+
return {
|
|
114
|
+
id: draftKey(engine.scope, engine.draft_id),
|
|
115
|
+
channelLabel: `${engine.scope.venture} / ${engine.scope.channel} / ${engine.scope.account}`,
|
|
116
|
+
platform: platformText(engine.platform, "draft"),
|
|
117
|
+
kind: draftKind(engine.kind),
|
|
118
|
+
target: engine.target,
|
|
119
|
+
title: engine.title,
|
|
120
|
+
body: engine.body,
|
|
121
|
+
bodyChars: engine.body_chars,
|
|
122
|
+
disclosureRequired: engine.disclosure_required,
|
|
123
|
+
state: draftState(engine.state),
|
|
124
|
+
revision: engine.revision,
|
|
125
|
+
postedUrl: engine.posted_url,
|
|
126
|
+
postedAtUtc: engine.posted_at,
|
|
127
|
+
skipReason: engine.skip_reason,
|
|
128
|
+
sourceNote: engine.source_note,
|
|
129
|
+
updatedAtUtc: engine.updated_at,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// ---- Instants and digests ---------------------------------------------------------------------
|
|
133
|
+
/** The engine's instant form: YYYY-MM-DDTHH:MM:SSZ (no milliseconds). */
|
|
134
|
+
export function engineInstant(iso) {
|
|
135
|
+
return new Date(iso).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* FNV-1a over the caption's UTF-16 code units. The engine stores no caption digest, so this is a
|
|
139
|
+
* display fingerprint computed here. It is not the daemon's SHA-256 and cannot be compared with it.
|
|
140
|
+
*/
|
|
141
|
+
export function captionFingerprint(caption) {
|
|
142
|
+
let hash = 0x811c9dc5;
|
|
143
|
+
for (let i = 0; i < caption.length; i += 1) {
|
|
144
|
+
hash ^= caption.charCodeAt(i);
|
|
145
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
146
|
+
}
|
|
147
|
+
return `fnv1a:${hash.toString(16).padStart(8, "0")}`;
|
|
148
|
+
}
|
|
149
|
+
// ---- Rejections -------------------------------------------------------------------------------
|
|
150
|
+
// The engine stores one reason string. The desktop writes "<code>: <note>" (or just "<code>") and
|
|
151
|
+
// reads it back the same way. A string it did not write maps to "other" with the text as the note.
|
|
152
|
+
export function encodeRejection(code, note) {
|
|
153
|
+
const trimmed = note.trim();
|
|
154
|
+
return trimmed === "" ? code : `${code}: ${trimmed}`;
|
|
155
|
+
}
|
|
156
|
+
export function parseRejection(text) {
|
|
157
|
+
const sep = text.indexOf(": ");
|
|
158
|
+
const head = sep === -1 ? text : text.slice(0, sep);
|
|
159
|
+
if (isOneOf(REJECT_REASONS, head)) {
|
|
160
|
+
return { reason: head, note: sep === -1 ? null : text.slice(sep + 2) };
|
|
161
|
+
}
|
|
162
|
+
return { reason: "other", note: text === "" ? null : text };
|
|
163
|
+
}
|
|
164
|
+
// ---- Entry fields -----------------------------------------------------------------------------
|
|
165
|
+
function required(value, field, entry) {
|
|
166
|
+
if (value === null)
|
|
167
|
+
throw mappingError(`entry ${entry.entry_id} has no ${field}`);
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
function jobFormat(format) {
|
|
171
|
+
if (isOneOf(FORMATS, format))
|
|
172
|
+
return format;
|
|
173
|
+
throw mappingError(`format "${format}" is not shown by the desktop yet`);
|
|
174
|
+
}
|
|
175
|
+
function jobState(state) {
|
|
176
|
+
if (isOneOf(JOB_STATES, state))
|
|
177
|
+
return state;
|
|
178
|
+
throw mappingError(`state "${state}" is not shown by the desktop yet`);
|
|
179
|
+
}
|
|
180
|
+
function mediaKind(kind) {
|
|
181
|
+
if (kind === "video" || kind === "image")
|
|
182
|
+
return kind;
|
|
183
|
+
throw mappingError(`media kind "${kind}" is not shown by the desktop yet`);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The engine stores no media status. "missing" is the fail-closed value: the screens then treat the
|
|
187
|
+
* media as not ready and do not offer approval. It is not a claim that the file is absent.
|
|
188
|
+
*/
|
|
189
|
+
function mediaItem(item) {
|
|
190
|
+
return {
|
|
191
|
+
hash: item.hash,
|
|
192
|
+
kind: mediaKind(item.kind),
|
|
193
|
+
durationSeconds: item.duration_ms === null ? null : item.duration_ms / 1000,
|
|
194
|
+
durationMs: item.duration_ms === null ? null : countOf(item.duration_ms, "media duration"),
|
|
195
|
+
dimensions: null,
|
|
196
|
+
status: "missing",
|
|
197
|
+
uploadPercent: null,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* approvedAtUtc is the entry's updated_at. The engine does not store the approval time, so a pause
|
|
202
|
+
* or resume after approval moves this value. The invalidation cause is not stored by the engine;
|
|
203
|
+
* "caption_changed" is a placeholder that no screen shows.
|
|
204
|
+
*/
|
|
205
|
+
function approvalOf(entry) {
|
|
206
|
+
switch (entry.approval_state) {
|
|
207
|
+
case "none":
|
|
208
|
+
return { status: "none" };
|
|
209
|
+
case "pending":
|
|
210
|
+
return { status: "pending" };
|
|
211
|
+
case "approved":
|
|
212
|
+
return {
|
|
213
|
+
status: "approved",
|
|
214
|
+
approvedRevision: required(entry.approved_entry_revision, "approved_entry_revision", entry),
|
|
215
|
+
policyRevision: required(entry.policy_revision, "policy_revision", entry),
|
|
216
|
+
approvedAtUtc: entry.updated_at,
|
|
217
|
+
};
|
|
218
|
+
case "rejected": {
|
|
219
|
+
const { reason, note } = parseRejection(entry.rejection_reason ?? "");
|
|
220
|
+
return { status: "rejected", reason, note };
|
|
221
|
+
}
|
|
222
|
+
case "invalidated":
|
|
223
|
+
return {
|
|
224
|
+
status: "invalidated",
|
|
225
|
+
approvedRevision: required(entry.approved_entry_revision, "approved_entry_revision", entry),
|
|
226
|
+
policyRevision: required(entry.policy_revision, "policy_revision", entry),
|
|
227
|
+
cause: "caption_changed",
|
|
228
|
+
};
|
|
229
|
+
default:
|
|
230
|
+
throw mappingError(`approval state "${entry.approval_state}" is not shown by the desktop yet`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/** Late is derived by the engine from the preparation window; the deadline is recomputed here. */
|
|
234
|
+
function latenessOf(entry) {
|
|
235
|
+
if (entry.effective_state !== "late")
|
|
236
|
+
return null;
|
|
237
|
+
const deadline = Date.parse(entry.scheduled_at) - PREP_MINUTES * 60_000;
|
|
238
|
+
return { mediaNotReadyBy: engineInstant(new Date(deadline).toISOString()), policy: "held_for_review" };
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* One engine entry as the operator's PublishJob. Fields the engine does not hold are set as the
|
|
242
|
+
* design doc's "Engine gaps" section records: null or empty means "not reported", not "none".
|
|
243
|
+
*/
|
|
244
|
+
export function entryToJob(entry) {
|
|
245
|
+
return {
|
|
246
|
+
id: jobKey(entry.scope, entry.entry_id),
|
|
247
|
+
// The account this entry belongs to, built exactly as accountOf builds an Account id (scope plus platform).
|
|
248
|
+
// A job whose account the accounts list lacks keeps this id; it matches no Account row.
|
|
249
|
+
accountId: accountId(entry.scope, entry.platform),
|
|
250
|
+
briefId: entry.brief_id,
|
|
251
|
+
format: jobFormat(entry.format),
|
|
252
|
+
revision: entry.revision,
|
|
253
|
+
caption: entry.caption,
|
|
254
|
+
captionDigest: captionFingerprint(entry.caption),
|
|
255
|
+
firstComment: null,
|
|
256
|
+
settings: [],
|
|
257
|
+
mediaItems: entry.media.map((item) => mediaItem(item)),
|
|
258
|
+
scheduledAtUtc: entry.scheduled_at,
|
|
259
|
+
timeZone: entry.timezone,
|
|
260
|
+
dst: { kind: "none" },
|
|
261
|
+
state: jobState(entry.effective_state),
|
|
262
|
+
approval: approvalOf(entry),
|
|
263
|
+
policyRevision: entry.policy_revision ?? null,
|
|
264
|
+
lateness: latenessOf(entry),
|
|
265
|
+
conflict: null,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
// ---- Accounts ---------------------------------------------------------------------------------
|
|
269
|
+
// The engine holds scope, platform, handle, connection, entitlements, cooldown and pause. The UI
|
|
270
|
+
// type also needs a time zone, a venture name, a display name, an entitlement stage and a colour
|
|
271
|
+
// band; the engine holds none of those, so each is set below to a named fallback (see the report in
|
|
272
|
+
// docs/CLI-POSTRIGHT.md, "Accounts"). A value the engine does not report is never shown as valid.
|
|
273
|
+
/**
|
|
274
|
+
* The platforms the Account type covers are SOCIAL_PLATFORMS, the engine's PLATFORMS (crates/protocol/src/accounts.rs).
|
|
275
|
+
* An account on any other platform is an unmapped account: its error is typed `unknown_platform`, and
|
|
276
|
+
* listAccounts keeps it apart from the mapped accounts.
|
|
277
|
+
*/
|
|
278
|
+
function socialPlatform(platform) {
|
|
279
|
+
if (isSocialPlatform(platform))
|
|
280
|
+
return platform;
|
|
281
|
+
throw new EngineReadError("unknown_platform", `platform "${platform}" is not shown by the desktop yet`);
|
|
282
|
+
}
|
|
283
|
+
/** The milliseconds of an engine instant. A value that does not parse as an instant is a mapping error, never a guess. */
|
|
284
|
+
function instantMs(value, field) {
|
|
285
|
+
const ms = Date.parse(value);
|
|
286
|
+
if (Number.isNaN(ms))
|
|
287
|
+
throw mappingError(`${field} "${value}" is not an instant`);
|
|
288
|
+
return ms;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Token health from the connection state and the expiry. A connected account with no reported
|
|
292
|
+
* expiry, or an expired token, reads as refresh_failed: the desktop does not claim a valid token it
|
|
293
|
+
* cannot see. not_connected maps to revoked, the closest existing value. `unknown` fails closed.
|
|
294
|
+
*/
|
|
295
|
+
function tokenHealth(engine, nowUtc) {
|
|
296
|
+
switch (engine.connection_state) {
|
|
297
|
+
case "connected": {
|
|
298
|
+
const expires = engine.token_expires_at;
|
|
299
|
+
if (expires === null || instantMs(expires, "token expiry") <= instantMs(nowUtc, "clock")) {
|
|
300
|
+
return { status: "refresh_failed" };
|
|
301
|
+
}
|
|
302
|
+
return { status: "valid", expiresOn: expires.slice(0, 10) };
|
|
303
|
+
}
|
|
304
|
+
case "not_connected":
|
|
305
|
+
return { status: "revoked" };
|
|
306
|
+
case "reauth_required":
|
|
307
|
+
case "unknown":
|
|
308
|
+
return { status: "refresh_failed" };
|
|
309
|
+
default:
|
|
310
|
+
throw mappingError(`connection state "${engine.connection_state}" is not shown by the desktop yet`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* The single entitlement stage the screens show: the first of the three independent states that has
|
|
315
|
+
* not passed. When all three have passed, the stage is audience_visible.
|
|
316
|
+
*/
|
|
317
|
+
function entitlementOf(engine) {
|
|
318
|
+
if (engine.sandbox_mechanics !== "passed")
|
|
319
|
+
return "sandbox_mechanics";
|
|
320
|
+
if (engine.review_submission !== "passed")
|
|
321
|
+
return "review_submitted";
|
|
322
|
+
return "audience_visible";
|
|
323
|
+
}
|
|
324
|
+
/** A pause record only while paused. The engine stores no actor, so the desktop is named (the only writer). */
|
|
325
|
+
function pauseOf(engine) {
|
|
326
|
+
if (!engine.paused)
|
|
327
|
+
return null;
|
|
328
|
+
if (engine.paused_at === null) {
|
|
329
|
+
throw mappingError(`account ${engine.platform} is paused without a pause time`);
|
|
330
|
+
}
|
|
331
|
+
return { by: "desktop", atUtc: engine.paused_at, reason: engine.paused_reason ?? "" };
|
|
332
|
+
}
|
|
333
|
+
/** A stable colour band (0 to 5) from the account id. Derived here, not reported by the engine. */
|
|
334
|
+
function bandOf(id) {
|
|
335
|
+
let hash = 0x811c9dc5;
|
|
336
|
+
for (let i = 0; i < id.length; i += 1) {
|
|
337
|
+
hash ^= id.charCodeAt(i);
|
|
338
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
339
|
+
}
|
|
340
|
+
return hash % 6;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* One engine account as the operator's Account. `nowUtc` is the caller's clock (the data source
|
|
344
|
+
* passes its own); this function reads no clock. A cooldown reads as active only while it is after
|
|
345
|
+
* `nowUtc`.
|
|
346
|
+
*/
|
|
347
|
+
export function accountOf(engine, nowUtc) {
|
|
348
|
+
const id = accountId(engine.scope, engine.platform);
|
|
349
|
+
const until = engine.quota_cooldown_until;
|
|
350
|
+
const cooldown = until !== null && instantMs(until, "quota cooldown") > instantMs(nowUtc, "clock") ? until : null;
|
|
351
|
+
return {
|
|
352
|
+
id,
|
|
353
|
+
// Engine gap: the venture has no name in the engine, so its id is shown.
|
|
354
|
+
venture: { id: engine.scope.venture, name: engine.scope.venture },
|
|
355
|
+
platform: socialPlatform(engine.platform),
|
|
356
|
+
handle: engine.handle,
|
|
357
|
+
// Engine gap: no display name is stored for most accounts, so the handle is shown.
|
|
358
|
+
displayName: engine.display_name === "" ? engine.handle : engine.display_name,
|
|
359
|
+
// The handle is the fixture's platform account id, so its last four characters are the suffix.
|
|
360
|
+
idSuffix: engine.handle.slice(-4),
|
|
361
|
+
// Engine gap: the engine holds no time zone for an account. UTC is a named fallback, not a fact.
|
|
362
|
+
timeZone: "UTC",
|
|
363
|
+
band: bandOf(id),
|
|
364
|
+
readiness: {
|
|
365
|
+
token: tokenHealth(engine, nowUtc),
|
|
366
|
+
// Engine gap: the engine stores no re-authorisation date.
|
|
367
|
+
reauthNeededBy: null,
|
|
368
|
+
entitlement: entitlementOf(engine),
|
|
369
|
+
quotaCooldownUntil: cooldown,
|
|
370
|
+
// The engine stores only a dated instant, so a cooldown is shown as dated.
|
|
371
|
+
quotaCooldownSource: cooldown === null ? null : "dated_retry_after",
|
|
372
|
+
paused: pauseOf(engine),
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
// ---- Publish jobs -----------------------------------------------------------------------------
|
|
377
|
+
// The engine's publish job is read-only here. Its seven states map one to one onto
|
|
378
|
+
// PublishProgressState. Publish reads (publish.get, publish.list) run in every engine role, so these
|
|
379
|
+
// functions run on the engine's real jobs. Writes stay daemon-only. See "Publish jobs" in the design doc.
|
|
380
|
+
const PUBLISH_STATES = [
|
|
381
|
+
"queued",
|
|
382
|
+
"running",
|
|
383
|
+
"waiting",
|
|
384
|
+
"published",
|
|
385
|
+
"failed",
|
|
386
|
+
"outcome_unknown",
|
|
387
|
+
"cancelled",
|
|
388
|
+
];
|
|
389
|
+
/** The engine's latest error code as one plain sentence: what happened. Unlisted codes use the engine's message. */
|
|
390
|
+
const PUBLISH_CAUSE = {
|
|
391
|
+
token_expired: "The platform rejected the account's token, so the post did not go out.",
|
|
392
|
+
platform_rejected: "The platform refused this post.",
|
|
393
|
+
container_failed: "The platform could not process the uploaded media.",
|
|
394
|
+
container_timeout: "The platform did not finish processing the media in time.",
|
|
395
|
+
retries_exhausted: `The platform kept failing or limiting requests, so ${hostAppName()} stopped.`,
|
|
396
|
+
approval_invalidated: "The post changed or lost its approval before it went out.",
|
|
397
|
+
account_paused: "The account was paused before the post went out.",
|
|
398
|
+
};
|
|
399
|
+
/** The single next action for each latest error code. The daemon host runs any retry; the desktop never does. */
|
|
400
|
+
const PUBLISH_NEXT = {
|
|
401
|
+
token_expired: "Reconnect this account on the daemon host. Nothing is retried until then.",
|
|
402
|
+
platform_rejected: "Change the post, then approve the new revision.",
|
|
403
|
+
container_failed: "Check the media on the daemon host, then approve the post again.",
|
|
404
|
+
container_timeout: "Check the account on the platform, then approve the post again if it should go out.",
|
|
405
|
+
retries_exhausted: "Check the account on the platform before approving the post again.",
|
|
406
|
+
approval_invalidated: "Review the current revision and approve it again if it should go out.",
|
|
407
|
+
account_paused: "Resume the account, then approve the post again.",
|
|
408
|
+
};
|
|
409
|
+
/**
|
|
410
|
+
* The Failures card shows the required explanation ("The platform may have accepted this post...")
|
|
411
|
+
* for every outcome_unknown item, so this summary states only the fact.
|
|
412
|
+
*/
|
|
413
|
+
const OUTCOME_UNKNOWN_SUMMARY = "The daemon's publish attempt ended without a confirmed result.";
|
|
414
|
+
const OUTCOME_UNKNOWN_NEXT = "Wait for the daemon's platform check. The desktop cannot start it.";
|
|
415
|
+
function causeOf(code, message) {
|
|
416
|
+
if (code === null)
|
|
417
|
+
return "The publish stopped and the engine gave no reason.";
|
|
418
|
+
return PUBLISH_CAUSE[code] ?? message ?? `The publish stopped (${code}).`;
|
|
419
|
+
}
|
|
420
|
+
function nextOf(code) {
|
|
421
|
+
return (code === null ? undefined : PUBLISH_NEXT[code]) ?? "Review the post on the daemon host before approving it again.";
|
|
422
|
+
}
|
|
423
|
+
/** One engine publish job as the desktop's progress view. Throws on a state the desktop does not show. */
|
|
424
|
+
export function publishProgressOf(engine) {
|
|
425
|
+
if (!isOneOf(PUBLISH_STATES, engine.state)) {
|
|
426
|
+
throw mappingError(`publish state "${engine.state}" is not shown by the desktop yet`);
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
state: engine.state,
|
|
430
|
+
permalink: engine.remote.permalink,
|
|
431
|
+
resumeAtUtc: engine.state === "waiting" ? engine.next_attempt_at : null,
|
|
432
|
+
errorCode: engine.last_error_code,
|
|
433
|
+
errorMessage: engine.last_error_message,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* The publish job for one calendar entry at its current revision, or null when there is none. A job
|
|
438
|
+
* for an older revision is not shown: the entry changed after that job was approved.
|
|
439
|
+
*/
|
|
440
|
+
export function publishProgressFor(jobs, entry) {
|
|
441
|
+
const job = jobs.find((j) => j.entry_id === entry.entry_id && j.entry_revision === entry.revision);
|
|
442
|
+
return job ? publishProgressOf(job) : null;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* A failure row for a publish job that needs the operator: failed, outcome_unknown, or cancelled with
|
|
446
|
+
* a reason. Queued, running, waiting and published jobs need no failure row (null).
|
|
447
|
+
*/
|
|
448
|
+
export function publishFailureOf(engine) {
|
|
449
|
+
const progress = publishProgressOf(engine);
|
|
450
|
+
const base = {
|
|
451
|
+
id: `publish-${engine.job_id}`,
|
|
452
|
+
alertKind: null,
|
|
453
|
+
accountId: accountId(engine.scope, engine.platform),
|
|
454
|
+
jobId: jobKey(engine.scope, engine.entry_id),
|
|
455
|
+
atUtc: engine.updated_at,
|
|
456
|
+
attempts: engine.attempts,
|
|
457
|
+
status: "open",
|
|
458
|
+
reconcile: null,
|
|
459
|
+
resolution: null,
|
|
460
|
+
resolutionNote: null,
|
|
461
|
+
};
|
|
462
|
+
switch (progress.state) {
|
|
463
|
+
case "failed":
|
|
464
|
+
return {
|
|
465
|
+
...base,
|
|
466
|
+
kind: "dead_letter",
|
|
467
|
+
summary: causeOf(progress.errorCode, progress.errorMessage),
|
|
468
|
+
nextAction: nextOf(progress.errorCode),
|
|
469
|
+
};
|
|
470
|
+
case "outcome_unknown":
|
|
471
|
+
return { ...base, kind: "outcome_unknown", summary: OUTCOME_UNKNOWN_SUMMARY, nextAction: OUTCOME_UNKNOWN_NEXT };
|
|
472
|
+
case "cancelled":
|
|
473
|
+
if (progress.errorCode === null)
|
|
474
|
+
return null;
|
|
475
|
+
return {
|
|
476
|
+
...base,
|
|
477
|
+
kind: "dead_letter",
|
|
478
|
+
summary: causeOf(progress.errorCode, progress.errorMessage),
|
|
479
|
+
nextAction: nextOf(progress.errorCode),
|
|
480
|
+
};
|
|
481
|
+
case "queued":
|
|
482
|
+
case "running":
|
|
483
|
+
case "waiting":
|
|
484
|
+
case "published":
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// ---- Publish jobs, read only (the Publish jobs screen) ----------------------------------------
|
|
489
|
+
const PUBLISH_DECISIONS = ["posted", "not_posted"];
|
|
490
|
+
/** What a person recorded for an outcome_unknown job. Null on every other job. An unknown decision is a mapping error. */
|
|
491
|
+
function publishResolutionOf(engine) {
|
|
492
|
+
if (engine === null || engine === undefined)
|
|
493
|
+
return null;
|
|
494
|
+
if (!isOneOf(PUBLISH_DECISIONS, engine.decision)) {
|
|
495
|
+
throw mappingError(`publish resolution "${engine.decision}" is not shown by the desktop yet`);
|
|
496
|
+
}
|
|
497
|
+
return {
|
|
498
|
+
decision: engine.decision,
|
|
499
|
+
resolvedBy: engine.resolved_by,
|
|
500
|
+
resolvedAtUtc: engine.resolved_at,
|
|
501
|
+
note: engine.note,
|
|
502
|
+
remoteIdEnteredByHand: engine.remote_id_entered_by_hand,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
/** One engine publish job as the desktop's read-only row. publishProgressOf refuses a state the desktop does not show. */
|
|
506
|
+
export function publishJobRecordOf(engine) {
|
|
507
|
+
const progress = publishProgressOf(engine);
|
|
508
|
+
return {
|
|
509
|
+
scopeId: accountKey(engine.scope),
|
|
510
|
+
jobId: engine.job_id,
|
|
511
|
+
// The calendar entry's id is its job key, the same id PublishJob.id carries, so it parses back with parseJobKey.
|
|
512
|
+
entryId: jobKey(engine.scope, engine.entry_id),
|
|
513
|
+
entryRevision: countOf(engine.entry_revision, "entry revision"),
|
|
514
|
+
platform: platformText(engine.platform, "publish job"),
|
|
515
|
+
state: progress.state,
|
|
516
|
+
phase: engine.phase,
|
|
517
|
+
remotePostId: engine.remote.post_id,
|
|
518
|
+
permalink: engine.remote.permalink,
|
|
519
|
+
attempts: countOf(engine.attempts, "publish attempts"),
|
|
520
|
+
nextAttemptAtUtc: engine.next_attempt_at,
|
|
521
|
+
errorCode: engine.last_error_code,
|
|
522
|
+
errorMessage: engine.last_error_message,
|
|
523
|
+
resolution: publishResolutionOf(engine.resolution),
|
|
524
|
+
createdAtUtc: engine.created_at,
|
|
525
|
+
updatedAtUtc: engine.updated_at,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
// ---- Suggestions and spend ----------------------------------------------------------------------
|
|
529
|
+
// A scope id is the account key (venture, channel, account), each part URI-encoded. Amounts pass only
|
|
530
|
+
// when they are safe integers. Anything else is an engine_mapping error, never a rounded value.
|
|
531
|
+
/** The scope a suggestion or spend id names. Null when the id is not one this data source issued. */
|
|
532
|
+
export function parseScopeKey(id) {
|
|
533
|
+
const parts = id.split("/");
|
|
534
|
+
if (parts.length !== 3)
|
|
535
|
+
return null;
|
|
536
|
+
try {
|
|
537
|
+
const [venture, channel, account] = parts.map((part) => decodeURIComponent(part));
|
|
538
|
+
if (!venture || !channel || !account)
|
|
539
|
+
return null;
|
|
540
|
+
return { venture, channel, account };
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const OPTIMISE_STATES = ["proposed", "accepted", "dismissed"];
|
|
547
|
+
const INTEGER_TEXT = /^-?\d+$/;
|
|
548
|
+
/** The engine sends value and threshold as exact integer text. Anything else is refused, not rounded. */
|
|
549
|
+
function wholeText(text, field, engine) {
|
|
550
|
+
if (!INTEGER_TEXT.test(text)) {
|
|
551
|
+
throw mappingError(`proposal ${engine.proposal_id} has a ${field} that is not a whole number`);
|
|
552
|
+
}
|
|
553
|
+
return text;
|
|
554
|
+
}
|
|
555
|
+
/** One optimise proposal as the desktop's OptimiseProposal. Only the remake rule and the three states are shown. */
|
|
556
|
+
export function proposalOf(engine) {
|
|
557
|
+
if (engine.kind !== "remake")
|
|
558
|
+
throw mappingError(`optimise rule "${engine.kind}" is not shown by the desktop yet`);
|
|
559
|
+
if (!isOneOf(OPTIMISE_STATES, engine.state)) {
|
|
560
|
+
throw mappingError(`optimise state "${engine.state}" is not shown by the desktop yet`);
|
|
561
|
+
}
|
|
562
|
+
return {
|
|
563
|
+
id: engine.proposal_id,
|
|
564
|
+
scopeId: accountKey(engine.scope),
|
|
565
|
+
kind: "remake",
|
|
566
|
+
state: engine.state,
|
|
567
|
+
revision: engine.revision,
|
|
568
|
+
sourceJobId: engine.source_job_id,
|
|
569
|
+
platform: platformText(engine.platform, "optimise"),
|
|
570
|
+
metric: engine.metric,
|
|
571
|
+
window: engine.window,
|
|
572
|
+
observedAtUtc: engine.observed_at,
|
|
573
|
+
value: wholeText(engine.value, "value", engine),
|
|
574
|
+
threshold: wholeText(engine.threshold, "threshold", engine),
|
|
575
|
+
sampleSize: engine.sample_size,
|
|
576
|
+
decileSize: engine.decile_size,
|
|
577
|
+
dueAtUtc: engine.due_at,
|
|
578
|
+
lineageJobIds: engine.lineage_job_ids,
|
|
579
|
+
lineagePullIds: engine.lineage_pull_ids,
|
|
580
|
+
decisionNote: engine.decision_note,
|
|
581
|
+
decidedAtUtc: engine.decided_at,
|
|
582
|
+
calendarEntryId: calendarJobKey(engine.scope, engine.calendar_entry_id),
|
|
583
|
+
createdAtUtc: engine.created_at,
|
|
584
|
+
updatedAtUtc: engine.updated_at,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
/** A money amount from the engine. Only a safe integer passes; any other value is a mapping error. */
|
|
588
|
+
function micro(value, field) {
|
|
589
|
+
if (!Number.isSafeInteger(value))
|
|
590
|
+
throw mappingError(`${field} is not a safe whole number of micro-units`);
|
|
591
|
+
return value;
|
|
592
|
+
}
|
|
593
|
+
export function allowanceOf(engine) {
|
|
594
|
+
return {
|
|
595
|
+
month: engine.month,
|
|
596
|
+
currency: engine.currency,
|
|
597
|
+
limitMicro: micro(engine.limit_micro, "limit"),
|
|
598
|
+
reservedMicro: micro(engine.reserved_micro, "reserved"),
|
|
599
|
+
committedMicro: micro(engine.committed_micro, "committed"),
|
|
600
|
+
remainingMicro: micro(engine.remaining_micro, "remaining"),
|
|
601
|
+
revision: engine.revision,
|
|
602
|
+
updatedAtUtc: engine.updated_at,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
export function spendReportOf(engine) {
|
|
606
|
+
return {
|
|
607
|
+
month: engine.month,
|
|
608
|
+
currency: engine.currency,
|
|
609
|
+
limitMicro: micro(engine.limit_micro, "limit"),
|
|
610
|
+
reservedMicro: micro(engine.reserved_micro, "reserved"),
|
|
611
|
+
committedMicro: micro(engine.committed_micro, "committed"),
|
|
612
|
+
remainingMicro: micro(engine.remaining_micro, "remaining"),
|
|
613
|
+
reconciles: engine.reconciles,
|
|
614
|
+
routes: engine.routes.map((route) => ({
|
|
615
|
+
routeId: route.route_id,
|
|
616
|
+
countReserved: route.count_reserved,
|
|
617
|
+
countCommitted: route.count_committed,
|
|
618
|
+
countReleased: route.count_released,
|
|
619
|
+
countExpired: route.count_expired,
|
|
620
|
+
openMicro: micro(route.open_micro, "open estimate"),
|
|
621
|
+
forecastMicro: micro(route.forecast_micro, "forecast"),
|
|
622
|
+
measuredMicro: micro(route.measured_micro, "measured"),
|
|
623
|
+
varianceMicro: micro(route.variance_micro, "variance"),
|
|
624
|
+
overReservations: route.over_reservations,
|
|
625
|
+
})),
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
export function priceOf(engine) {
|
|
629
|
+
return {
|
|
630
|
+
routeId: engine.route_id,
|
|
631
|
+
version: engine.version,
|
|
632
|
+
provider: engine.provider,
|
|
633
|
+
modelSlug: engine.model_slug,
|
|
634
|
+
unit: engine.unit,
|
|
635
|
+
amountMicro: micro(engine.amount_micro, "price"),
|
|
636
|
+
currency: engine.currency,
|
|
637
|
+
sourceUrl: engine.source_url,
|
|
638
|
+
sourcePathLine: engine.source_path_line,
|
|
639
|
+
fetchedAtUtc: engine.fetched_at,
|
|
640
|
+
verifiedBy: engine.verified_by,
|
|
641
|
+
qualified: engine.qualified,
|
|
642
|
+
reason: engine.reason,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
// ---- Campaigns, tracked links and attribution -------------------------------------------------
|
|
646
|
+
// Each row carries its engine scope, which the desktop keeps as the scope id (accountKey). Campaign, link and
|
|
647
|
+
// hook test ids are the engine's own ids, shown as stored. A row from another scope is never returned by
|
|
648
|
+
// the scoped reads, so no id here is joined across channels.
|
|
649
|
+
/** A count from the engine: a safe, non-negative whole number. Anything else is a mapping error. */
|
|
650
|
+
function countOf(value, field) {
|
|
651
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
652
|
+
throw mappingError(`${field} is not a whole count`);
|
|
653
|
+
return value;
|
|
654
|
+
}
|
|
655
|
+
/** One engine campaign as the desktop's Campaign. */
|
|
656
|
+
export function campaignOf(engine) {
|
|
657
|
+
return {
|
|
658
|
+
id: engine.campaign_id,
|
|
659
|
+
scopeId: accountKey(engine.scope),
|
|
660
|
+
name: engine.name,
|
|
661
|
+
utmCampaign: engine.utm_campaign,
|
|
662
|
+
revision: countOf(engine.revision, "campaign revision"),
|
|
663
|
+
createdAtUtc: engine.created_at,
|
|
664
|
+
updatedAtUtc: engine.updated_at,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
/** One engine tracked link as the desktop's TrackedLink. The UTM parts and final URL are shown as stored. */
|
|
668
|
+
export function linkOf(engine) {
|
|
669
|
+
return {
|
|
670
|
+
id: engine.link_id,
|
|
671
|
+
scopeId: accountKey(engine.scope),
|
|
672
|
+
campaignId: engine.campaign_id,
|
|
673
|
+
variantId: engine.variant_id,
|
|
674
|
+
platform: platformText(engine.platform, "link"),
|
|
675
|
+
destination: engine.destination,
|
|
676
|
+
calendarEntryId: calendarJobKey(engine.scope, engine.calendar_entry_id),
|
|
677
|
+
utmSource: engine.utm_source,
|
|
678
|
+
utmMedium: engine.utm_medium,
|
|
679
|
+
utmCampaign: engine.utm_campaign,
|
|
680
|
+
utmContent: engine.utm_content,
|
|
681
|
+
finalUrl: engine.final_url,
|
|
682
|
+
createdAtUtc: engine.created_at,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* The visit counts of one campaign. The engine's total must equal the sum of its rows; a mismatch is
|
|
687
|
+
* refused, not shown.
|
|
688
|
+
*/
|
|
689
|
+
export function attributionOf(engine) {
|
|
690
|
+
const links = engine.links.map((row) => ({
|
|
691
|
+
linkId: row.link_id,
|
|
692
|
+
variantId: row.variant_id,
|
|
693
|
+
platform: platformText(row.platform, "link attribution"),
|
|
694
|
+
calendarEntryId: calendarJobKey(engine.scope, row.calendar_entry_id),
|
|
695
|
+
visits: countOf(row.visits, "visit count"),
|
|
696
|
+
}));
|
|
697
|
+
const total = countOf(engine.total_visits, "total visits");
|
|
698
|
+
const sum = links.reduce((acc, row) => acc + row.visits, 0);
|
|
699
|
+
if (sum !== total)
|
|
700
|
+
throw mappingError(`attribution for ${engine.campaign_id} does not add up to its total`);
|
|
701
|
+
return { campaignId: engine.campaign_id, totalVisits: total, links };
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* The links of one campaign, each with the campaign's name and its count from the attribution reply of the same
|
|
705
|
+
* campaign. A link the attribution reply does not list has no count (null), never a zero.
|
|
706
|
+
*/
|
|
707
|
+
export function channelLinksOf(campaignName, links, attribution) {
|
|
708
|
+
const counts = new Map(attributionOf(attribution).links.map((row) => [row.linkId, row.visits]));
|
|
709
|
+
return links.map((link) => ({
|
|
710
|
+
link: linkOf(link),
|
|
711
|
+
campaignName,
|
|
712
|
+
visits: counts.get(link.link_id) ?? null,
|
|
713
|
+
}));
|
|
714
|
+
}
|
|
715
|
+
// ---- Hook tests -------------------------------------------------------------------------------
|
|
716
|
+
const HOOK_STATES = ["open", "decided"];
|
|
717
|
+
const HOOK_ROLES = ["promoted", "archived"];
|
|
718
|
+
function hookState(state) {
|
|
719
|
+
if (isOneOf(HOOK_STATES, state))
|
|
720
|
+
return state;
|
|
721
|
+
throw mappingError(`hook test state "${state}" is not shown by the desktop yet`);
|
|
722
|
+
}
|
|
723
|
+
function hookRole(role) {
|
|
724
|
+
if (role === null)
|
|
725
|
+
return null;
|
|
726
|
+
if (isOneOf(HOOK_ROLES, role))
|
|
727
|
+
return role;
|
|
728
|
+
throw mappingError(`hook test role "${role}" is not shown by the desktop yet`);
|
|
729
|
+
}
|
|
730
|
+
/** A hook test value is exact integer text. Anything else is refused, not rounded. */
|
|
731
|
+
function hookValue(value, setId, index) {
|
|
732
|
+
if (value === null)
|
|
733
|
+
return null;
|
|
734
|
+
if (!INTEGER_TEXT.test(value)) {
|
|
735
|
+
throw mappingError(`hook test ${setId} opening ${index} has a value that is not a whole number`);
|
|
736
|
+
}
|
|
737
|
+
return value;
|
|
738
|
+
}
|
|
739
|
+
function hookOpeningOf(engine, setId) {
|
|
740
|
+
return {
|
|
741
|
+
index: countOf(engine.opening_index, "opening index"),
|
|
742
|
+
text: engine.text,
|
|
743
|
+
jobId: engine.job_id,
|
|
744
|
+
boundAtUtc: engine.bound_at,
|
|
745
|
+
role: hookRole(engine.role),
|
|
746
|
+
value: hookValue(engine.value, setId, engine.opening_index),
|
|
747
|
+
observedAtUtc: engine.observed_at,
|
|
748
|
+
pullId: engine.pull_id,
|
|
749
|
+
publishedAtUtc: engine.published_at,
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* One engine hook test set as the desktop's HookTestSet. A decided set must name its winner and its
|
|
754
|
+
* decision instant; a set missing either is a mapping error.
|
|
755
|
+
*/
|
|
756
|
+
export function hookTestOf(engine) {
|
|
757
|
+
const state = hookState(engine.state);
|
|
758
|
+
const winner = engine.winner_opening === null ? null : countOf(engine.winner_opening, "winner opening");
|
|
759
|
+
if (state === "decided" && (winner === null || engine.decided_at === null)) {
|
|
760
|
+
throw mappingError(`hook test ${engine.set_id} is decided without a winner or a decision time`);
|
|
761
|
+
}
|
|
762
|
+
return {
|
|
763
|
+
id: engine.set_id,
|
|
764
|
+
scopeId: accountKey(engine.scope),
|
|
765
|
+
briefRef: engine.brief_ref,
|
|
766
|
+
body: engine.body,
|
|
767
|
+
metric: engine.metric,
|
|
768
|
+
window: engine.window,
|
|
769
|
+
state,
|
|
770
|
+
revision: countOf(engine.revision, "hook test revision"),
|
|
771
|
+
winnerIndex: winner,
|
|
772
|
+
decidedAtUtc: engine.decided_at,
|
|
773
|
+
createdAtUtc: engine.created_at,
|
|
774
|
+
updatedAtUtc: engine.updated_at,
|
|
775
|
+
openings: engine.openings.map((opening) => hookOpeningOf(opening, engine.set_id)),
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
// ---- Research digests and content library -----------------------------------------------------
|
|
779
|
+
// Text the engine stores (topic, finding, source, platform, format) is shown as stored. Counts and
|
|
780
|
+
// durations are whole numbers; anything else is an engine_mapping error, never a rounded value.
|
|
781
|
+
const CONTENT_STATES = ["active", "withdrawn", "expired"];
|
|
782
|
+
const CONTENT_MEDIA_KINDS = ["image", "video"];
|
|
783
|
+
const CONTENT_VARIANT_KINDS = ["caption", "cta"];
|
|
784
|
+
const CONTENT_VERDICTS = ["insufficient_evidence", "evidence_sufficient"];
|
|
785
|
+
/** One digest entry as the desktop's DigestEntry. */
|
|
786
|
+
function digestEntryOf(engine) {
|
|
787
|
+
return {
|
|
788
|
+
id: engine.entry_id,
|
|
789
|
+
topic: engine.topic,
|
|
790
|
+
finding: engine.finding,
|
|
791
|
+
source: engine.source,
|
|
792
|
+
fetchedOn: engine.fetched_on,
|
|
793
|
+
platform: engine.platform,
|
|
794
|
+
format: engine.format,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
/** One stored digest as the desktop's ResearchDigest. The id is the engine's digest id, within its scope. */
|
|
798
|
+
export function digestOf(engine) {
|
|
799
|
+
return {
|
|
800
|
+
id: engine.digest_id,
|
|
801
|
+
scopeId: accountKey(engine.scope),
|
|
802
|
+
digestDate: engine.digest_date,
|
|
803
|
+
entries: engine.entries.map((entry) => digestEntryOf(entry)),
|
|
804
|
+
createdAtUtc: engine.created_at,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
/** One applied digest entry as the desktop's DigestApplication. The entry's scope comes from the digest.applied reply. */
|
|
808
|
+
export function digestApplicationOf(engine, scope) {
|
|
809
|
+
return {
|
|
810
|
+
digestId: engine.digest_id,
|
|
811
|
+
digestEntryId: engine.digest_entry_id,
|
|
812
|
+
briefId: engine.brief_id,
|
|
813
|
+
calendarEntryId: jobKey(scope, engine.entry_id),
|
|
814
|
+
appliedAtUtc: engine.applied_at,
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
function contentStateOf(state) {
|
|
818
|
+
if (isOneOf(CONTENT_STATES, state))
|
|
819
|
+
return state;
|
|
820
|
+
throw mappingError(`content state "${state}" is not shown by the desktop yet`);
|
|
821
|
+
}
|
|
822
|
+
function contentMediaKindOf(kind) {
|
|
823
|
+
if (isOneOf(CONTENT_MEDIA_KINDS, kind))
|
|
824
|
+
return kind;
|
|
825
|
+
throw mappingError(`content media kind "${kind}" is not shown by the desktop yet`);
|
|
826
|
+
}
|
|
827
|
+
function contentVariantKindOf(kind) {
|
|
828
|
+
if (isOneOf(CONTENT_VARIANT_KINDS, kind))
|
|
829
|
+
return kind;
|
|
830
|
+
throw mappingError(`content variant kind "${kind}" is not shown by the desktop yet`);
|
|
831
|
+
}
|
|
832
|
+
function contentVerdictOf(verdict) {
|
|
833
|
+
if (isOneOf(CONTENT_VERDICTS, verdict))
|
|
834
|
+
return verdict;
|
|
835
|
+
throw mappingError(`content verdict "${verdict}" is not shown by the desktop yet`);
|
|
836
|
+
}
|
|
837
|
+
function contentMediaOf(item) {
|
|
838
|
+
return {
|
|
839
|
+
hash: item.hash,
|
|
840
|
+
kind: contentMediaKindOf(item.kind),
|
|
841
|
+
durationMs: item.duration_ms === null ? null : countOf(item.duration_ms, "media duration"),
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
/** One stored content record as the desktop's ContentRecord. The state is the engine's derived state. */
|
|
845
|
+
export function contentRecordOf(engine) {
|
|
846
|
+
return {
|
|
847
|
+
id: engine.content_id,
|
|
848
|
+
scopeId: accountKey(engine.scope),
|
|
849
|
+
title: engine.title,
|
|
850
|
+
media: engine.media.map((item) => contentMediaOf(item)),
|
|
851
|
+
baseCaption: engine.base_caption,
|
|
852
|
+
evergreen: engine.evergreen,
|
|
853
|
+
cooldownDays: countOf(engine.cooldown_days, "cooldown days"),
|
|
854
|
+
expiresAtUtc: engine.expires_at,
|
|
855
|
+
state: contentStateOf(engine.state),
|
|
856
|
+
withdrawalReason: engine.withdrawal_reason,
|
|
857
|
+
revision: countOf(engine.revision, "content revision"),
|
|
858
|
+
createdAtUtc: engine.created_at,
|
|
859
|
+
updatedAtUtc: engine.updated_at,
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
/** One content variant as the desktop's ContentVariant. */
|
|
863
|
+
export function contentVariantOf(engine) {
|
|
864
|
+
return {
|
|
865
|
+
id: engine.variant_id,
|
|
866
|
+
scopeId: accountKey(engine.scope),
|
|
867
|
+
contentId: engine.content_id,
|
|
868
|
+
kind: contentVariantKindOf(engine.kind),
|
|
869
|
+
text: engine.text,
|
|
870
|
+
createdAtUtc: engine.created_at,
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
function contentVariantCountOf(engine) {
|
|
874
|
+
return {
|
|
875
|
+
variantId: engine.variant_id,
|
|
876
|
+
text: engine.text,
|
|
877
|
+
planned: countOf(engine.planned, "planned count"),
|
|
878
|
+
published: countOf(engine.published, "published count"),
|
|
879
|
+
cancelled: countOf(engine.cancelled, "cancelled count"),
|
|
880
|
+
meetsMinimum: engine.meets_minimum,
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
/** The record and variants of one content.get reply, as the desktop's ContentDetail. */
|
|
884
|
+
export function contentDetailOf(engine) {
|
|
885
|
+
return {
|
|
886
|
+
record: contentRecordOf(engine.record),
|
|
887
|
+
variants: engine.variants.map((variant) => contentVariantOf(variant)),
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
/** The variant comparison of one content.experiment_summary reply. The verdict is checked, never reworded. */
|
|
891
|
+
export function experimentSummaryOf(engine) {
|
|
892
|
+
return {
|
|
893
|
+
scopeId: accountKey(engine.scope),
|
|
894
|
+
contentId: engine.content_id,
|
|
895
|
+
minOccurrences: countOf(engine.min_occurrences, "minimum occurrences"),
|
|
896
|
+
verdict: contentVerdictOf(engine.verdict),
|
|
897
|
+
reason: engine.reason,
|
|
898
|
+
caption: engine.caption.map((row) => contentVariantCountOf(row)),
|
|
899
|
+
cta: engine.cta.map((row) => contentVariantCountOf(row)),
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
// ---- Text gate, briefs, deliveries and dispatch attempts (read only) ---------------------------
|
|
903
|
+
// Thresholds are integers in thousandths and pass through as whole numbers; the screens write them as
|
|
904
|
+
// decimals. Every enum is checked against its list: an unknown value is an engine_mapping error, never a
|
|
905
|
+
// guess. A document field is read only when it has the expected type; a field it lacks is null or empty.
|
|
906
|
+
const GATE_DISPOSITIONS = ["pass", "hold", "block"];
|
|
907
|
+
const GATE_VERDICTS = ["pass", "fail", "inconclusive", "unverified", "hold", "not_applicable"];
|
|
908
|
+
const BRIEF_STATES = ["issued", "partial", "delivered"];
|
|
909
|
+
const DISPATCH_OUTCOMES = [
|
|
910
|
+
"running",
|
|
911
|
+
"delivered",
|
|
912
|
+
"partial",
|
|
913
|
+
"awaiting_approval",
|
|
914
|
+
"engine_failed",
|
|
915
|
+
"timed_out",
|
|
916
|
+
"delivery_invalid",
|
|
917
|
+
"engine_missing",
|
|
918
|
+
"interrupted",
|
|
919
|
+
];
|
|
920
|
+
/** A whole number the engine sends. Negative values are kept, because some fields may be negative. */
|
|
921
|
+
function safeIntOf(value, field) {
|
|
922
|
+
if (!Number.isSafeInteger(value))
|
|
923
|
+
throw mappingError(`${field} is not a whole number`);
|
|
924
|
+
return value;
|
|
925
|
+
}
|
|
926
|
+
function gateDispositionOf(value) {
|
|
927
|
+
if (isOneOf(GATE_DISPOSITIONS, value))
|
|
928
|
+
return value;
|
|
929
|
+
throw mappingError(`gate disposition "${value}" is not shown by the desktop yet`);
|
|
930
|
+
}
|
|
931
|
+
function gateVerdictOf(value) {
|
|
932
|
+
if (isOneOf(GATE_VERDICTS, value))
|
|
933
|
+
return value;
|
|
934
|
+
throw mappingError(`gate verdict "${value}" is not shown by the desktop yet`);
|
|
935
|
+
}
|
|
936
|
+
function briefStateOf(value) {
|
|
937
|
+
if (isOneOf(BRIEF_STATES, value))
|
|
938
|
+
return value;
|
|
939
|
+
throw mappingError(`brief state "${value}" is not shown by the desktop yet`);
|
|
940
|
+
}
|
|
941
|
+
function dispatchOutcomeOf(value) {
|
|
942
|
+
if (isOneOf(DISPATCH_OUTCOMES, value))
|
|
943
|
+
return value;
|
|
944
|
+
throw mappingError(`dispatch outcome "${value}" is not shown by the desktop yet`);
|
|
945
|
+
}
|
|
946
|
+
/** The latest policy revision as the desktop's GatePolicy. */
|
|
947
|
+
export function gatePolicyOf(engine) {
|
|
948
|
+
const body = engine.policy;
|
|
949
|
+
return {
|
|
950
|
+
scopeId: accountKey(engine.scope),
|
|
951
|
+
revision: countOf(engine.revision, "gate policy revision"),
|
|
952
|
+
createdAtUtc: engine.created_at,
|
|
953
|
+
bannedPhrases: body.banned_phrases,
|
|
954
|
+
stockOpeners: body.stock_openers,
|
|
955
|
+
minWords: countOf(body.min_words, "minimum words"),
|
|
956
|
+
dashMaxPer100WordsMilli: countOf(body.dash_max_per_100_words_milli, "dash maximum"),
|
|
957
|
+
semicolonMaxPer100WordsMilli: countOf(body.semicolon_max_per_100_words_milli, "semicolon maximum"),
|
|
958
|
+
varianceMinMilli: safeIntOf(body.variance_min_milli, "variance minimum"),
|
|
959
|
+
varianceMinSentences: countOf(body.variance_min_sentences, "variance minimum sentences"),
|
|
960
|
+
tricolonMaxPer100SentencesMilli: countOf(body.tricolon_max_per_100_sentences_milli, "tricolon maximum"),
|
|
961
|
+
tricolonMinSentences: countOf(body.tricolon_min_sentences, "tricolon minimum sentences"),
|
|
962
|
+
required: {
|
|
963
|
+
bannedPhrase: body.required.banned_phrase,
|
|
964
|
+
dashDensity: body.required.dash_density,
|
|
965
|
+
semicolonDensity: body.required.semicolon_density,
|
|
966
|
+
sentenceLengthVariance: body.required.sentence_length_variance,
|
|
967
|
+
tricolonFrequency: body.required.tricolon_frequency,
|
|
968
|
+
stockOpener: body.required.stock_opener,
|
|
969
|
+
},
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
/** One gate.check reply as the desktop's GateCheck. The desktop never asks the engine to record, so nothing is stored. */
|
|
973
|
+
export function gateCheckOf(engine) {
|
|
974
|
+
return {
|
|
975
|
+
subjectId: engine.subject_id,
|
|
976
|
+
disposition: gateDispositionOf(engine.disposition),
|
|
977
|
+
policyRevision: countOf(engine.policy_revision, "gate policy revision"),
|
|
978
|
+
textHash: engine.text_hash,
|
|
979
|
+
rules: engine.rules.map((rule) => ({
|
|
980
|
+
ruleId: rule.rule_id,
|
|
981
|
+
required: rule.required,
|
|
982
|
+
verdict: gateVerdictOf(rule.verdict),
|
|
983
|
+
blocks: rule.blocks,
|
|
984
|
+
reason: rule.reason,
|
|
985
|
+
})),
|
|
986
|
+
blockingRules: engine.blocking_rules,
|
|
987
|
+
proofIdsUsed: engine.proof_ids_used,
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
/** One stored verdict record. The scope is the one the read was made in, because the record carries none. */
|
|
991
|
+
export function gateRecordOf(engine, scopeId) {
|
|
992
|
+
return {
|
|
993
|
+
scopeId,
|
|
994
|
+
recordSeq: countOf(engine.record_seq, "record sequence"),
|
|
995
|
+
subjectId: engine.subject_id,
|
|
996
|
+
textHash: engine.text_hash,
|
|
997
|
+
policyRevision: countOf(engine.policy_revision, "gate policy revision"),
|
|
998
|
+
disposition: gateDispositionOf(engine.disposition),
|
|
999
|
+
checkedAtUtc: engine.checked_at,
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
/** One field of a JSON object. Undefined when the value is not an object or the field is absent. */
|
|
1003
|
+
function fieldOf(value, key) {
|
|
1004
|
+
if (value === undefined || value === null || typeof value !== "object" || Array.isArray(value))
|
|
1005
|
+
return undefined;
|
|
1006
|
+
return value[key];
|
|
1007
|
+
}
|
|
1008
|
+
function textOf(value) {
|
|
1009
|
+
return typeof value === "string" ? value : null;
|
|
1010
|
+
}
|
|
1011
|
+
function wholeOf(value) {
|
|
1012
|
+
return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
|
|
1013
|
+
}
|
|
1014
|
+
/** The strings of a list. A list item that is not a string is kept as its JSON text, never dropped. */
|
|
1015
|
+
function textsOf(value) {
|
|
1016
|
+
return Array.isArray(value) ? value.map((item) => (typeof item === "string" ? item : JSON.stringify(item))) : [];
|
|
1017
|
+
}
|
|
1018
|
+
/** The fields of a brief document the desktop shows. Anything the document lacks stays null or empty. */
|
|
1019
|
+
export function briefSummaryOf(document) {
|
|
1020
|
+
const review = fieldOf(document, "review");
|
|
1021
|
+
const stages = fieldOf(document, "stages");
|
|
1022
|
+
return {
|
|
1023
|
+
briefType: textOf(fieldOf(document, "briefType")),
|
|
1024
|
+
platforms: textsOf(fieldOf(document, "platforms")),
|
|
1025
|
+
deliverables: textsOf(fieldOf(document, "deliverables")),
|
|
1026
|
+
hook: textOf(fieldOf(document, "hook")),
|
|
1027
|
+
reviewMode: textOf(fieldOf(review, "mode")),
|
|
1028
|
+
policyRevision: wholeOf(fieldOf(review, "policyRevision")),
|
|
1029
|
+
stageCount: Array.isArray(stages) ? stages.length : 0,
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
/** One outgoing brief as the list shows it. The document is left out of the list. */
|
|
1033
|
+
export function briefOf(engine) {
|
|
1034
|
+
return {
|
|
1035
|
+
scopeId: accountKey(engine.scope),
|
|
1036
|
+
requestId: engine.request_id,
|
|
1037
|
+
briefId: engine.brief_id,
|
|
1038
|
+
revision: countOf(engine.revision, "brief revision"),
|
|
1039
|
+
state: briefStateOf(engine.state),
|
|
1040
|
+
createdAtUtc: engine.created_at,
|
|
1041
|
+
updatedAtUtc: engine.updated_at,
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
/** One outgoing brief with its summary and the document as stored. */
|
|
1045
|
+
export function briefDetailOf(engine) {
|
|
1046
|
+
return { ...briefOf(engine), summary: briefSummaryOf(engine.document), document: engine.document };
|
|
1047
|
+
}
|
|
1048
|
+
/** One accepted delivery envelope as the desktop's DeliveryEnvelope. */
|
|
1049
|
+
export function deliveryOf(engine) {
|
|
1050
|
+
return {
|
|
1051
|
+
scopeId: accountKey(engine.scope),
|
|
1052
|
+
envelopeId: engine.envelope_id,
|
|
1053
|
+
requestId: engine.request_id,
|
|
1054
|
+
deliverable: engine.deliverable,
|
|
1055
|
+
briefId: engine.brief_id,
|
|
1056
|
+
briefRevision: countOf(engine.brief_revision, "brief revision"),
|
|
1057
|
+
acceptedAtUtc: engine.accepted_at,
|
|
1058
|
+
document: engine.document,
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
/** One dispatch attempt. The message and stderr tail are read from the engine's detail object when present. */
|
|
1062
|
+
export function dispatchAttemptOf(engine, scopeId) {
|
|
1063
|
+
return {
|
|
1064
|
+
scopeId,
|
|
1065
|
+
attemptId: countOf(engine.attempt_id, "attempt id"),
|
|
1066
|
+
requestId: engine.request_id,
|
|
1067
|
+
engine: engine.engine,
|
|
1068
|
+
startedAtUtc: engine.started_at,
|
|
1069
|
+
finishedAtUtc: engine.finished_at,
|
|
1070
|
+
exitCode: engine.exit_code === null ? null : safeIntOf(engine.exit_code, "exit code"),
|
|
1071
|
+
outcome: dispatchOutcomeOf(engine.outcome),
|
|
1072
|
+
stderrTail: textOf(fieldOf(engine.detail, "stderr_tail")),
|
|
1073
|
+
message: textOf(fieldOf(engine.detail, "message")),
|
|
1074
|
+
producedDeliverables: textsOf(fieldOf(engine.detail, "deliverables")),
|
|
1075
|
+
skipped: skippedOf(fieldOf(engine.detail, "skipped")),
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
/** The engine's `skipped` list as recorded on an attempt. An entry that is not an object is kept as an unreadable row. */
|
|
1079
|
+
function skippedOf(value) {
|
|
1080
|
+
if (!Array.isArray(value))
|
|
1081
|
+
return [];
|
|
1082
|
+
const entries = [];
|
|
1083
|
+
for (const entry of value) {
|
|
1084
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
1085
|
+
entries.push({ deliverable: null, code: null, message: "The engine sent a skipped entry the desktop cannot read." });
|
|
1086
|
+
continue;
|
|
1087
|
+
}
|
|
1088
|
+
entries.push({
|
|
1089
|
+
deliverable: textOf(entry["deliverable"] ?? entry["preset"]),
|
|
1090
|
+
code: textOf(entry["code"]),
|
|
1091
|
+
message: textOf(entry["message"]),
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
return entries;
|
|
1095
|
+
}
|
|
1096
|
+
// ---- Channel memory and the proof bank (read only) --------------------------------------------
|
|
1097
|
+
// memory.get returns one revision with its document; memory.history returns the revision rows without
|
|
1098
|
+
// documents. proof.list returns each proof's value as exact decimal text, which is kept verbatim. A status the
|
|
1099
|
+
// desktop does not show is an engine_mapping error.
|
|
1100
|
+
const PROOF_STATUSES = ["collected", "approved", "withdrawn", "superseded"];
|
|
1101
|
+
/** One stored memory revision without its document. The scope is the one the read was made in. */
|
|
1102
|
+
export function memoryRevisionOf(engine, scopeId) {
|
|
1103
|
+
return {
|
|
1104
|
+
scopeId,
|
|
1105
|
+
revision: countOf(engine.revision, "memory revision"),
|
|
1106
|
+
supersedes: engine.supersedes === null ? null : countOf(engine.supersedes, "superseded memory revision"),
|
|
1107
|
+
author: engine.author,
|
|
1108
|
+
createdAtUtc: engine.created_at,
|
|
1109
|
+
contentHash: engine.content_hash,
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
/** The latest memory revision with its document. A document that is not a JSON object is a mapping error. */
|
|
1113
|
+
export function memoryDetailOf(engine) {
|
|
1114
|
+
const { document } = engine;
|
|
1115
|
+
if (document === null || typeof document !== "object" || Array.isArray(document)) {
|
|
1116
|
+
throw mappingError(`memory revision ${engine.revision} has no document object`);
|
|
1117
|
+
}
|
|
1118
|
+
return { ...memoryRevisionOf(engine, accountKey(engine.scope)), document };
|
|
1119
|
+
}
|
|
1120
|
+
function proofStatusOf(status) {
|
|
1121
|
+
if (isOneOf(PROOF_STATUSES, status))
|
|
1122
|
+
return status;
|
|
1123
|
+
throw mappingError(`proof status "${status}" is not shown by the desktop yet`);
|
|
1124
|
+
}
|
|
1125
|
+
/** One proof row. The value is kept as the exact decimal text the engine sent; it is never parsed. */
|
|
1126
|
+
export function proofOf(engine) {
|
|
1127
|
+
if (typeof engine.value !== "string")
|
|
1128
|
+
throw mappingError(`proof ${engine.proof_id} has a value that is not text`);
|
|
1129
|
+
return {
|
|
1130
|
+
scopeId: accountKey({ venture: engine.venture, channel: engine.channel, account: engine.account }),
|
|
1131
|
+
answerId: engine.answer_id,
|
|
1132
|
+
proofId: engine.proof_id,
|
|
1133
|
+
status: proofStatusOf(engine.status),
|
|
1134
|
+
subject: engine.subject,
|
|
1135
|
+
value: engine.value,
|
|
1136
|
+
units: engine.units,
|
|
1137
|
+
origin: engine.origin,
|
|
1138
|
+
observedFrom: engine.observed_from,
|
|
1139
|
+
observedTo: engine.observed_to,
|
|
1140
|
+
supersededBy: engine.superseded_by,
|
|
1141
|
+
withdrawalNote: engine.withdrawal_note,
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
// ---- Typed briefs and platform variants (read only) --------------------------------------------
|
|
1145
|
+
// A brief type or link placement the desktop does not show is an engine_mapping error, never a guess. A caption
|
|
1146
|
+
// is kept exactly as the engine stored it: the desktop does not split hashtags out or count the caption length.
|
|
1147
|
+
const BRIEF_TYPES = [
|
|
1148
|
+
"talking_head",
|
|
1149
|
+
"faceless_cartoon_short",
|
|
1150
|
+
"explainer",
|
|
1151
|
+
"carousel",
|
|
1152
|
+
"ugc_character_review",
|
|
1153
|
+
"product_pitch",
|
|
1154
|
+
"product_demo",
|
|
1155
|
+
];
|
|
1156
|
+
const LINK_PLACEMENTS = ["caption", "bio", "first_comment", "none"];
|
|
1157
|
+
function briefTypeOf(value) {
|
|
1158
|
+
if (isOneOf(BRIEF_TYPES, value))
|
|
1159
|
+
return value;
|
|
1160
|
+
throw mappingError(`brief type "${value}" is not shown by the desktop yet`);
|
|
1161
|
+
}
|
|
1162
|
+
function linkPlacementOf(value) {
|
|
1163
|
+
if (isOneOf(LINK_PLACEMENTS, value))
|
|
1164
|
+
return value;
|
|
1165
|
+
throw mappingError(`link placement "${value}" is not shown by the desktop yet`);
|
|
1166
|
+
}
|
|
1167
|
+
function sceneOf(engine) {
|
|
1168
|
+
return {
|
|
1169
|
+
teaches: engine.teaches,
|
|
1170
|
+
frame: engine.frame,
|
|
1171
|
+
motion: engine.motion,
|
|
1172
|
+
words: engine.words,
|
|
1173
|
+
sound: engine.sound,
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
function explainerOf(engine) {
|
|
1177
|
+
return {
|
|
1178
|
+
question: sceneOf(engine.question),
|
|
1179
|
+
model: sceneOf(engine.model),
|
|
1180
|
+
proof: sceneOf(engine.proof),
|
|
1181
|
+
turn: sceneOf(engine.turn),
|
|
1182
|
+
payoff: sceneOf(engine.payoff),
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
/** One stored typed brief as the desktop's TypedBrief. An explainer with no beats is a mapping error. */
|
|
1186
|
+
export function typedBriefOf(engine) {
|
|
1187
|
+
const briefType = briefTypeOf(engine.brief_type);
|
|
1188
|
+
if (briefType === "explainer" && engine.explainer === null) {
|
|
1189
|
+
throw mappingError(`typed brief ${engine.brief_id} is an explainer with no beats`);
|
|
1190
|
+
}
|
|
1191
|
+
return {
|
|
1192
|
+
scopeId: accountKey(engine.scope),
|
|
1193
|
+
briefId: engine.brief_id,
|
|
1194
|
+
briefType,
|
|
1195
|
+
hook: engine.hook,
|
|
1196
|
+
body: engine.body,
|
|
1197
|
+
explainer: engine.explainer === null ? null : explainerOf(engine.explainer),
|
|
1198
|
+
demoSteps: engine.demo_steps.map((step) => ({ say: step.say })),
|
|
1199
|
+
revision: countOf(engine.revision, "typed brief revision"),
|
|
1200
|
+
createdAtUtc: engine.created_at,
|
|
1201
|
+
updatedAtUtc: engine.updated_at,
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
/** One platform variant as the desktop's BriefVariant. The link is kept as given; no slot is re-derived. */
|
|
1205
|
+
export function briefVariantOf(engine) {
|
|
1206
|
+
return {
|
|
1207
|
+
scopeId: accountKey(engine.scope),
|
|
1208
|
+
variantId: engine.variant_id,
|
|
1209
|
+
briefId: engine.brief_id,
|
|
1210
|
+
briefRevision: countOf(engine.brief_revision, "brief revision"),
|
|
1211
|
+
platform: platformText(engine.platform, "brief variant"),
|
|
1212
|
+
ruleRevision: engine.rule_revision,
|
|
1213
|
+
linkPlacement: linkPlacementOf(engine.link_placement),
|
|
1214
|
+
caption: engine.caption,
|
|
1215
|
+
link: engine.link,
|
|
1216
|
+
createdAtUtc: engine.created_at,
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
// ---- Analytics and manual metric imports (read only) ------------------------------------------
|
|
1220
|
+
// A stored value is an exact decimal string and is kept as the engine wrote it. The state and the value must agree:
|
|
1221
|
+
// a value state carries text, and the unavailable state carries none. Anything else is an engine_mapping error.
|
|
1222
|
+
const ANALYTICS_SOURCES = ["api", "manual_export"];
|
|
1223
|
+
const DECIMAL_TEXT = /^-?\d+(\.\d+)?$/;
|
|
1224
|
+
function analyticsValueOf(state, value, metric) {
|
|
1225
|
+
if (state === "unavailable") {
|
|
1226
|
+
if (value === null || value === undefined)
|
|
1227
|
+
return { state: "unavailable", value: null };
|
|
1228
|
+
throw mappingError(`metric ${metric} is unavailable but carries a value`);
|
|
1229
|
+
}
|
|
1230
|
+
if (state !== "value")
|
|
1231
|
+
throw mappingError(`analytics state "${state}" is not shown by the desktop yet`);
|
|
1232
|
+
if (typeof value === "string" && DECIMAL_TEXT.test(value))
|
|
1233
|
+
return { state: "value", value };
|
|
1234
|
+
throw mappingError(`metric ${metric} has a value that is not an exact decimal`);
|
|
1235
|
+
}
|
|
1236
|
+
function analyticsSourceOf(value) {
|
|
1237
|
+
if (isOneOf(ANALYTICS_SOURCES, value))
|
|
1238
|
+
return value;
|
|
1239
|
+
throw mappingError(`analytics source "${value}" is not shown by the desktop yet`);
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* The published posts of one scope, from analytics.summary. The summary names no source: a metric with an
|
|
1243
|
+
* import id is a manual row, and one without is an API row.
|
|
1244
|
+
*/
|
|
1245
|
+
export function analyticsPostsOf(engine) {
|
|
1246
|
+
const scopeId = accountKey(engine.scope);
|
|
1247
|
+
return engine.jobs.map((job) => ({
|
|
1248
|
+
scopeId,
|
|
1249
|
+
jobId: job.job_id,
|
|
1250
|
+
platform: platformText(job.platform, "analytics post"),
|
|
1251
|
+
remotePostId: job.remote_post_id,
|
|
1252
|
+
publishedAtUtc: job.published_at,
|
|
1253
|
+
metrics: job.metrics.map((metric) => {
|
|
1254
|
+
const stored = analyticsValueOf(metric.state, metric.value, metric.metric);
|
|
1255
|
+
const importId = metric.import_id ?? null;
|
|
1256
|
+
const source = importId === null ? "api" : "manual_export";
|
|
1257
|
+
return {
|
|
1258
|
+
metric: metric.metric,
|
|
1259
|
+
state: stored.state,
|
|
1260
|
+
value: stored.value,
|
|
1261
|
+
observedAtUtc: metric.observed_at,
|
|
1262
|
+
window: metric.window,
|
|
1263
|
+
source,
|
|
1264
|
+
pullId: metric.pull_id,
|
|
1265
|
+
importId,
|
|
1266
|
+
postAgeSeconds: safeIntOf(metric.post_age_secs, "post age"),
|
|
1267
|
+
};
|
|
1268
|
+
}),
|
|
1269
|
+
}));
|
|
1270
|
+
}
|
|
1271
|
+
/** One stored observation, from analytics.list. The scope is the one the read was made in. */
|
|
1272
|
+
export function analyticsObservationOf(engine, scopeId) {
|
|
1273
|
+
const stored = analyticsValueOf(engine.state, engine.value, engine.metric);
|
|
1274
|
+
return {
|
|
1275
|
+
scopeId,
|
|
1276
|
+
jobId: engine.job_id,
|
|
1277
|
+
metric: engine.metric,
|
|
1278
|
+
state: stored.state,
|
|
1279
|
+
value: stored.value,
|
|
1280
|
+
observedAtUtc: engine.observed_at,
|
|
1281
|
+
window: engine.window,
|
|
1282
|
+
source: analyticsSourceOf(engine.source),
|
|
1283
|
+
pullId: engine.pull_id,
|
|
1284
|
+
importId: engine.import_id ?? null,
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
/** One committed manual import. Only the manual_export source is shown; any other source is a mapping error. */
|
|
1288
|
+
export function metricImportOf(engine) {
|
|
1289
|
+
if (engine.source !== "manual_export") {
|
|
1290
|
+
throw mappingError(`metric import ${engine.import_id} has source "${engine.source}", not shown by the desktop yet`);
|
|
1291
|
+
}
|
|
1292
|
+
return {
|
|
1293
|
+
scopeId: accountKey(engine.scope),
|
|
1294
|
+
importId: engine.import_id,
|
|
1295
|
+
platform: platformText(engine.platform, "metric import"),
|
|
1296
|
+
sourceNote: engine.source_note,
|
|
1297
|
+
source: "manual_export",
|
|
1298
|
+
createdAtUtc: engine.created_at,
|
|
1299
|
+
rowsWritten: countOf(engine.rows_written, "import rows"),
|
|
1300
|
+
observationsWritten: countOf(engine.observations_written, "import observations"),
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
// ---- Store health (read only) -----------------------------------------------------------------
|
|
1304
|
+
// The states and ages are the engine's, shown as given: nothing is recomputed here. A state the desktop does not
|
|
1305
|
+
// show is an engine_mapping error. A schema the engine did not record is null, never zero.
|
|
1306
|
+
const HEARTBEAT_STATES = ["alive", "stale", "missing", "unreadable"];
|
|
1307
|
+
function heartbeatStateOf(state) {
|
|
1308
|
+
if (isOneOf(HEARTBEAT_STATES, state))
|
|
1309
|
+
return state;
|
|
1310
|
+
throw mappingError(`heartbeat state "${state}" is not shown by the desktop yet`);
|
|
1311
|
+
}
|
|
1312
|
+
/** The heartbeat status as the desktop's HeartbeatStatus: one record, no per-source list. */
|
|
1313
|
+
export function heartbeatStatusOf(engine) {
|
|
1314
|
+
const note = engine.record_state;
|
|
1315
|
+
return {
|
|
1316
|
+
nowUtc: engine.now,
|
|
1317
|
+
staleAfterSeconds: safeIntOf(engine.stale_after_secs, "stale threshold"),
|
|
1318
|
+
allAlive: engine.all_alive,
|
|
1319
|
+
state: heartbeatStateOf(engine.state),
|
|
1320
|
+
pid: engine.pid === null ? null : safeIntOf(engine.pid, "heartbeat pid"),
|
|
1321
|
+
updatedMs: engine.updated_ms === null ? null : safeIntOf(engine.updated_ms, "heartbeat update time"),
|
|
1322
|
+
ageSeconds: engine.age_secs === null ? null : safeIntOf(engine.age_secs, "heartbeat age"),
|
|
1323
|
+
recordState: note === null || note === undefined ? null : typeof note === "string" ? note : JSON.stringify(note),
|
|
1324
|
+
error: engine.error,
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
/** One restore receipt as the desktop's RestoreReceipt. */
|
|
1328
|
+
function restoreReceiptOf(engine) {
|
|
1329
|
+
const before = engine.schema_before ?? null;
|
|
1330
|
+
return {
|
|
1331
|
+
receiptId: engine.receipt_id,
|
|
1332
|
+
restoredAtUtc: engine.restored_at,
|
|
1333
|
+
manifestDigest: engine.manifest_digest,
|
|
1334
|
+
schemaBefore: before === null ? null : safeIntOf(before, "schema before"),
|
|
1335
|
+
schemaAfter: safeIntOf(engine.schema_after, "schema after"),
|
|
1336
|
+
previousStore: engine.previous_store ?? null,
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
/** The restore receipts as the desktop's RestoreReceiptList, newest first as the engine sends them. */
|
|
1340
|
+
export function restoreReceiptListOf(engine) {
|
|
1341
|
+
return {
|
|
1342
|
+
receipts: engine.receipts.map((receipt) => restoreReceiptOf(receipt)),
|
|
1343
|
+
unreadable: engine.unreadable,
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
// ---- Webhook inbox and publish pacing (read only) -----------------------------------------------
|
|
1347
|
+
// A row the desktop cannot read is kept as an UnreadableRow with its reason and its raw JSON. No row is dropped,
|
|
1348
|
+
// and an unknown kind, state, reason or publish state is never guessed at. Event text is untrusted: it is copied
|
|
1349
|
+
// as given and shown as plain text by the screens.
|
|
1350
|
+
const INBOX_KINDS = ["comment", "message", "mention", "other"];
|
|
1351
|
+
const INBOX_STATES = ["open", "handled", "ignored"];
|
|
1352
|
+
const PACING_REASONS = [
|
|
1353
|
+
"not_due",
|
|
1354
|
+
"outcome_unknown_on_account",
|
|
1355
|
+
"account_not_ready",
|
|
1356
|
+
"account_paused",
|
|
1357
|
+
"rate_limited_until",
|
|
1358
|
+
"daily_cap",
|
|
1359
|
+
"account_gap",
|
|
1360
|
+
];
|
|
1361
|
+
/** A value as pretty printed JSON text. Used for raw rows and raw event items. */
|
|
1362
|
+
export function rawTextOf(value) {
|
|
1363
|
+
const text = JSON.stringify(value, null, 2);
|
|
1364
|
+
return text ?? String(value);
|
|
1365
|
+
}
|
|
1366
|
+
/** Maps one row. A row whose mapping throws is kept unread, with the reason and its raw JSON. It is never dropped. */
|
|
1367
|
+
export function rowOf(raw, map) {
|
|
1368
|
+
try {
|
|
1369
|
+
return map();
|
|
1370
|
+
}
|
|
1371
|
+
catch (error) {
|
|
1372
|
+
return { readable: false, reason: error instanceof Error ? error.message : String(error), rawJson: rawTextOf(raw) };
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
/** One inbox event. An unknown kind or state is a mapping error, so the row is kept unread (see rowOf). */
|
|
1376
|
+
export function inboxEventOf(engine) {
|
|
1377
|
+
if (!isOneOf(INBOX_KINDS, engine.kind)) {
|
|
1378
|
+
throw mappingError(`inbox kind "${engine.kind}" is not shown by the desktop yet`);
|
|
1379
|
+
}
|
|
1380
|
+
if (!isOneOf(INBOX_STATES, engine.state)) {
|
|
1381
|
+
throw mappingError(`inbox state "${engine.state}" is not shown by the desktop yet`);
|
|
1382
|
+
}
|
|
1383
|
+
return {
|
|
1384
|
+
readable: true,
|
|
1385
|
+
eventId: engine.event_id,
|
|
1386
|
+
scopeId: engine.scope === null ? null : accountKey(engine.scope),
|
|
1387
|
+
unmatched: engine.unmatched_account,
|
|
1388
|
+
unmatchedReason: engine.unmatched_reason,
|
|
1389
|
+
platform: engine.platform,
|
|
1390
|
+
kind: engine.kind,
|
|
1391
|
+
recipientId: engine.recipient_id,
|
|
1392
|
+
platformEventId: engine.platform_event_id,
|
|
1393
|
+
senderId: engine.sender_id,
|
|
1394
|
+
text: engine.text,
|
|
1395
|
+
eventTimeAsGiven: engine.event_time,
|
|
1396
|
+
oversize: engine.oversize,
|
|
1397
|
+
receivedAtUtc: engine.received_at,
|
|
1398
|
+
state: engine.state,
|
|
1399
|
+
stateBy: engine.state_by,
|
|
1400
|
+
stateAtUtc: engine.state_at,
|
|
1401
|
+
stateNote: engine.state_note,
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
/** The raw item JSON of one event, shown as text. */
|
|
1405
|
+
export function inboxRawOf(engine) {
|
|
1406
|
+
return { eventId: engine.event.event_id, rawJson: rawTextOf(engine.raw) };
|
|
1407
|
+
}
|
|
1408
|
+
function inboxStatsGroupOf(engine) {
|
|
1409
|
+
return {
|
|
1410
|
+
scopeId: engine.scope === null ? null : accountKey(engine.scope),
|
|
1411
|
+
kind: engine.kind,
|
|
1412
|
+
state: engine.state,
|
|
1413
|
+
count: countOf(engine.count, "inbox count"),
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
/** The inbox counts as the engine stores them. Each group is kept, even one whose count cannot be read. */
|
|
1417
|
+
export function inboxStatsOf(engine) {
|
|
1418
|
+
return {
|
|
1419
|
+
totalEvents: countOf(engine.total_events, "inbox events"),
|
|
1420
|
+
unmatchedEvents: countOf(engine.unmatched_events, "unmatched events"),
|
|
1421
|
+
deliveriesParsed: countOf(engine.deliveries_parsed, "deliveries parsed"),
|
|
1422
|
+
deliveriesUnparseable: countOf(engine.deliveries_unparseable, "deliveries unparseable"),
|
|
1423
|
+
refusedTotal: countOf(engine.refused_total, "refused deliveries"),
|
|
1424
|
+
groups: engine.groups.map((group) => rowOf(group, () => inboxStatsGroupOf(group))),
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
function pacingReasonOf(value) {
|
|
1428
|
+
if (isOneOf(PACING_REASONS, value))
|
|
1429
|
+
return value;
|
|
1430
|
+
throw mappingError(`publish pacing reason "${value}" is not shown by the desktop yet`);
|
|
1431
|
+
}
|
|
1432
|
+
/** A job publish.next names. Its state must be one the desktop shows. */
|
|
1433
|
+
function pacingJobOf(engine) {
|
|
1434
|
+
if (!isOneOf(PUBLISH_STATES, engine.state)) {
|
|
1435
|
+
throw mappingError(`publish state "${engine.state}" is not shown by the desktop yet`);
|
|
1436
|
+
}
|
|
1437
|
+
return {
|
|
1438
|
+
readable: true,
|
|
1439
|
+
jobId: engine.job_id,
|
|
1440
|
+
scopeId: accountKey(engine.scope),
|
|
1441
|
+
platform: engine.platform,
|
|
1442
|
+
remoteAccount: engine.remote_account,
|
|
1443
|
+
entryId: engine.entry_id,
|
|
1444
|
+
state: engine.state,
|
|
1445
|
+
phase: engine.phase,
|
|
1446
|
+
scheduledAtUtc: engine.scheduled_at,
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
function pacingBlockedOf(engine) {
|
|
1450
|
+
return {
|
|
1451
|
+
...pacingJobOf(engine),
|
|
1452
|
+
reason: pacingReasonOf(engine.reason),
|
|
1453
|
+
reasons: engine.reasons.map((reason) => pacingReasonOf(reason)),
|
|
1454
|
+
untilUtc: engine.until ?? null,
|
|
1455
|
+
detail: engine.detail,
|
|
1456
|
+
behindJobId: engine.behind ?? null,
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
/** publish.next as the desktop's view. Every row is kept: a row it cannot read is an UnreadableRow. */
|
|
1460
|
+
export function publishNextOf(engine) {
|
|
1461
|
+
return {
|
|
1462
|
+
nowUtc: engine.now,
|
|
1463
|
+
ready: engine.ready.map((job) => rowOf(job, () => pacingJobOf(job))),
|
|
1464
|
+
readyTotal: countOf(engine.ready_total, "ready total"),
|
|
1465
|
+
blocked: engine.blocked.map((job) => rowOf(job, () => pacingBlockedOf(job))),
|
|
1466
|
+
inProgress: engine.in_progress.map((job) => rowOf(job, () => pacingJobOf(job))),
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
/** One account's pacing as stored. The limits are the stored ones, or the host app defaults when opted_in is false. */
|
|
1470
|
+
export function publishQuotaOf(engine) {
|
|
1471
|
+
return {
|
|
1472
|
+
scopeId: accountKey(engine.scope),
|
|
1473
|
+
platform: engine.platform,
|
|
1474
|
+
remoteAccount: engine.remote_account,
|
|
1475
|
+
optedIn: engine.opted_in,
|
|
1476
|
+
minGapSecs: countOf(engine.min_gap_secs, "minimum gap"),
|
|
1477
|
+
maxPerDay: countOf(engine.max_per_day, "maximum per day"),
|
|
1478
|
+
defaultMinGapSecs: countOf(engine.default_min_gap_secs, "default gap"),
|
|
1479
|
+
defaultMaxPerDay: countOf(engine.default_max_per_day, "default per day"),
|
|
1480
|
+
pausedUntilUtc: engine.paused_until ?? null,
|
|
1481
|
+
lastCreateAtUtc: engine.last_create_at ?? null,
|
|
1482
|
+
createsToday: countOf(engine.creates_today, "creates today"),
|
|
1483
|
+
dayResetsAtUtc: engine.day_resets_at,
|
|
1484
|
+
rateLimitedAtUtc: engine.rate_limited_at ?? null,
|
|
1485
|
+
rateLimitedUntilUtc: engine.rate_limited_until ?? null,
|
|
1486
|
+
};
|
|
1487
|
+
}
|