@opendatalabs/personal-server-ts-core 1.25.1 → 1.27.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/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +10 -0
- package/dist/api/index.js.map +1 -1
- package/dist/contracts/data.d.ts +5 -0
- package/dist/contracts/data.d.ts.map +1 -1
- package/dist/contracts/data.js +3 -1
- package/dist/contracts/data.js.map +1 -1
- package/dist/derivatives/scheduler.d.ts.map +1 -1
- package/dist/derivatives/scheduler.js +18 -1
- package/dist/derivatives/scheduler.js.map +1 -1
- package/dist/legacy-projection/bindings.d.ts +10 -0
- package/dist/legacy-projection/bindings.d.ts.map +1 -0
- package/dist/legacy-projection/bindings.js +3945 -0
- package/dist/legacy-projection/bindings.js.map +1 -0
- package/dist/legacy-projection/github-browser-bindings.d.ts +3 -0
- package/dist/legacy-projection/github-browser-bindings.d.ts.map +1 -0
- package/dist/legacy-projection/github-browser-bindings.js +128 -0
- package/dist/legacy-projection/github-browser-bindings.js.map +1 -0
- package/dist/legacy-projection/index.d.ts +33 -0
- package/dist/legacy-projection/index.d.ts.map +1 -0
- package/dist/legacy-projection/index.js +189 -0
- package/dist/legacy-projection/index.js.map +1 -0
- package/dist/legacy-projection/meta-040-bindings.d.ts +3 -0
- package/dist/legacy-projection/meta-040-bindings.d.ts.map +1 -0
- package/dist/legacy-projection/meta-040-bindings.js +112 -0
- package/dist/legacy-projection/meta-040-bindings.js.map +1 -0
- package/dist/legacy-projection/oura-browser-bindings.d.ts +3 -0
- package/dist/legacy-projection/oura-browser-bindings.d.ts.map +1 -0
- package/dist/legacy-projection/oura-browser-bindings.js +137 -0
- package/dist/legacy-projection/oura-browser-bindings.js.map +1 -0
- package/dist/legacy-projection/provenance.d.ts +57 -0
- package/dist/legacy-projection/provenance.d.ts.map +1 -0
- package/dist/legacy-projection/provenance.js +2 -0
- package/dist/legacy-projection/provenance.js.map +1 -0
- package/dist/legacy-projection/test-debris.d.ts +2 -0
- package/dist/legacy-projection/test-debris.d.ts.map +1 -0
- package/dist/legacy-projection/test-debris.js +49 -0
- package/dist/legacy-projection/test-debris.js.map +1 -0
- package/dist/legacy-projection/types.d.ts +167 -0
- package/dist/legacy-projection/types.d.ts.map +1 -0
- package/dist/legacy-projection/types.js +2 -0
- package/dist/legacy-projection/types.js.map +1 -0
- package/dist/ports/index.d.ts +6 -0
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/index.js.map +1 -1
- package/dist/storage/blocks/cursor.d.ts +5 -1
- package/dist/storage/blocks/cursor.d.ts.map +1 -1
- package/dist/storage/blocks/cursor.js +13 -1
- package/dist/storage/blocks/cursor.js.map +1 -1
- package/dist/storage/blocks/page.d.ts +18 -0
- package/dist/storage/blocks/page.d.ts.map +1 -0
- package/dist/storage/blocks/page.js +113 -0
- package/dist/storage/blocks/page.js.map +1 -0
- package/dist/storage/legacy-projection.d.ts +59 -0
- package/dist/storage/legacy-projection.d.ts.map +1 -0
- package/dist/storage/legacy-projection.js +371 -0
- package/dist/storage/legacy-projection.js.map +1 -0
- package/package.json +8 -1
|
@@ -0,0 +1,3945 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every entry cites source evidence in ./declarations. Published OCI profiles
|
|
3
|
+
* carry immutable artifact digests; where the OCI manifest pin is unavailable,
|
|
4
|
+
* the generated collection-profile output digest is recorded explicitly. No
|
|
5
|
+
* entry here uses a fabricated field. Every legacy field
|
|
6
|
+
* this phase cannot reconstruct is a documented gap (either the whole scope
|
|
7
|
+
* is absent from
|
|
8
|
+
* LEGACY_SCOPE_BINDINGS and appears in the report's gap table, or the field
|
|
9
|
+
* is listed in the binding's `lossy` array and called out in this file's
|
|
10
|
+
* comments plus the report).
|
|
11
|
+
*
|
|
12
|
+
* `pdppSource` is each declaration's own `connector_id`
|
|
13
|
+
* (`https://registry.pdpp.dev/connectors/<key>`), verified byte-identical
|
|
14
|
+
* per source below — never the short `connector_key` a Personal Server does
|
|
15
|
+
* not key sources by.
|
|
16
|
+
*
|
|
17
|
+
* `fieldsRead` and `primaryKey` name exactly the declared fields each
|
|
18
|
+
* binding depends on, per bound stream. The self-check
|
|
19
|
+
* (bindings-self-check.test.ts) compares both against the vendored
|
|
20
|
+
* declaration's own `schema.properties` / `primary_key` — deleting or
|
|
21
|
+
* renaming a field this binding reads, or pointing `primaryKey` at the
|
|
22
|
+
* wrong (but still declared) field, must fail that check (S1).
|
|
23
|
+
*/
|
|
24
|
+
function byStream(records, stream) {
|
|
25
|
+
return records.filter((r) => r.stream === stream).map((r) => r.data);
|
|
26
|
+
}
|
|
27
|
+
function isObjectRow(value) {
|
|
28
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
function ifDefined(record, sourceField, targetField) {
|
|
31
|
+
return record[sourceField] === undefined
|
|
32
|
+
? {}
|
|
33
|
+
: { [targetField]: record[sourceField] };
|
|
34
|
+
}
|
|
35
|
+
function missingStream(scope, expectedStream) {
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
error: { kind: "missing_stream", scope, expectedStream },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A stream the caller never fetched must be a `missing_stream` error, not
|
|
43
|
+
* silently indistinguishable from "fetched, zero rows" (a valid state for a
|
|
44
|
+
* list-shaped stream). "Fetched" is expressed explicitly via
|
|
45
|
+
* `options.fetchedStreams` — the set of streams the caller actually asked
|
|
46
|
+
* the Personal Server for — independent of how many records came back. A
|
|
47
|
+
* stream in `fetchedStreams` with zero matching `records` is a real, empty
|
|
48
|
+
* result (N-B1), not an error.
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* The timestamp a binding stamps on generated fields (`fetched_at`,
|
|
52
|
+
* `fetchedAt`, a null memory `created_at`). A caller that needs the same
|
|
53
|
+
* stored records to project to the same bytes passes `options.now`; without
|
|
54
|
+
* it the wall clock is used, as the legacy connectors did per run.
|
|
55
|
+
*/
|
|
56
|
+
function projectionTime(options) {
|
|
57
|
+
return options.now ?? new Date().toISOString();
|
|
58
|
+
}
|
|
59
|
+
function requireStreams(scope, options, expectedStreams) {
|
|
60
|
+
for (const stream of expectedStreams) {
|
|
61
|
+
if (!options.fetchedStreams.includes(stream)) {
|
|
62
|
+
return missingStream(scope, stream);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Legacy connectors that hit a missing/null value at the pin emit `''`
|
|
69
|
+
* (verified per call site: linkedin-playwright.js `pos.title || ''`,
|
|
70
|
+
* `edu.grade || ''`; amazon-playwright.js `price: ''`; shop-playwright.js
|
|
71
|
+
* DOM-fallback `orderNumber: ''`). A legacy-typed string field therefore
|
|
72
|
+
* defaults to `''`, matching the pinned connector's own behavior, so the
|
|
73
|
+
* projected payload never fails the legacy schema on a null/undefined PDPP
|
|
74
|
+
* field. This is real, cited legacy behavior, not an invented default.
|
|
75
|
+
*/
|
|
76
|
+
function orEmptyString(value) {
|
|
77
|
+
return typeof value === "string" ? value : "";
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Legacy connectors that hit a missing/null count emit `0`
|
|
81
|
+
* (linkedin-playwright.js `sk.endorsementCount || sk.endorsements || 0`).
|
|
82
|
+
*/
|
|
83
|
+
function orZero(value) {
|
|
84
|
+
return typeof value === "number" ? value : 0;
|
|
85
|
+
}
|
|
86
|
+
function youtubeStringOrNull(value) {
|
|
87
|
+
return typeof value === "string" ? value : null;
|
|
88
|
+
}
|
|
89
|
+
function youtubeRequiredString(value) {
|
|
90
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
91
|
+
}
|
|
92
|
+
function youtubeLegacyProjection(scope, skipped, fields, payload) {
|
|
93
|
+
return {
|
|
94
|
+
ok: true,
|
|
95
|
+
payload,
|
|
96
|
+
...(skipped > 0
|
|
97
|
+
? {
|
|
98
|
+
diagnostics: [
|
|
99
|
+
{
|
|
100
|
+
kind: "records_skipped",
|
|
101
|
+
scope,
|
|
102
|
+
count: skipped,
|
|
103
|
+
missingFields: fields,
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
}
|
|
107
|
+
: {}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const youtubeProvenance = [
|
|
111
|
+
{
|
|
112
|
+
kind: "collection-profile",
|
|
113
|
+
ref: "https://github.com/PDP-Connect/data-connectors/tree/fe871c128acd801849551e1d75db0b570b5279df/connectors/youtube",
|
|
114
|
+
digest: "sha256:bc6bcb2704bd52d9c002eb0c1810bceb15c4a07469f07a326ff08553b3b5e41f",
|
|
115
|
+
path: "declarations/youtube.collection-profile.json#streams[name=profile,subscriptions,playlists,playlist_items,likes,watch_later,watch_history]",
|
|
116
|
+
},
|
|
117
|
+
];
|
|
118
|
+
const youtubeProfile = {
|
|
119
|
+
scope: "youtube.profile",
|
|
120
|
+
pdppSource: "https://registry.pdpp.dev/connectors/youtube",
|
|
121
|
+
pdppStreams: ["profile"],
|
|
122
|
+
legacySchemaPath: "youtube.profile.json",
|
|
123
|
+
fieldsRead: {
|
|
124
|
+
profile: [
|
|
125
|
+
"id",
|
|
126
|
+
"channel_url",
|
|
127
|
+
"title",
|
|
128
|
+
"handle",
|
|
129
|
+
"email",
|
|
130
|
+
"joined_at",
|
|
131
|
+
"avatar_url",
|
|
132
|
+
"description",
|
|
133
|
+
"country",
|
|
134
|
+
"subscriber_count",
|
|
135
|
+
"view_count",
|
|
136
|
+
"video_count",
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
primaryKey: { profile: ["id"] },
|
|
140
|
+
lossy: [
|
|
141
|
+
"Browser-visible profile values map to the retained legacy fields; values absent from the signed-in page remain null.",
|
|
142
|
+
],
|
|
143
|
+
provenance: youtubeProvenance,
|
|
144
|
+
project(records, options) {
|
|
145
|
+
const guard = requireStreams("youtube.profile", options, ["profile"]);
|
|
146
|
+
if (guard)
|
|
147
|
+
return guard;
|
|
148
|
+
const profile = byStream(records, "profile")[0];
|
|
149
|
+
if (!profile)
|
|
150
|
+
return { ok: true, payload: {} };
|
|
151
|
+
const channelId = youtubeRequiredString(profile.id);
|
|
152
|
+
if (!channelId)
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
error: {
|
|
156
|
+
kind: "invalid_value",
|
|
157
|
+
scope: "youtube.profile",
|
|
158
|
+
reason: "YouTube profile is missing its declared id",
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
return youtubeLegacyProjection("youtube.profile", 0, [], {
|
|
162
|
+
email: youtubeStringOrNull(profile.email),
|
|
163
|
+
channelUrl: youtubeStringOrNull(profile.channel_url),
|
|
164
|
+
handle: youtubeStringOrNull(profile.handle),
|
|
165
|
+
joinedDate: youtubeStringOrNull(profile.joined_at),
|
|
166
|
+
channelTitle: youtubeStringOrNull(profile.title),
|
|
167
|
+
channelId,
|
|
168
|
+
avatarUrl: youtubeStringOrNull(profile.avatar_url),
|
|
169
|
+
description: youtubeStringOrNull(profile.description),
|
|
170
|
+
country: youtubeStringOrNull(profile.country),
|
|
171
|
+
subscriberCount: typeof profile.subscriber_count === "number"
|
|
172
|
+
? profile.subscriber_count
|
|
173
|
+
: null,
|
|
174
|
+
viewCount: typeof profile.view_count === "number" ? profile.view_count : null,
|
|
175
|
+
videoCount: typeof profile.video_count === "number" ? profile.video_count : null,
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
const youtubeSubscriptions = {
|
|
180
|
+
scope: "youtube.subscriptions",
|
|
181
|
+
pdppSource: youtubeProfile.pdppSource,
|
|
182
|
+
pdppStreams: ["subscriptions"],
|
|
183
|
+
legacySchemaPath: "youtube.subscriptions.json",
|
|
184
|
+
fieldsRead: {
|
|
185
|
+
subscriptions: [
|
|
186
|
+
"channel_id",
|
|
187
|
+
"channel_title",
|
|
188
|
+
"channel_url",
|
|
189
|
+
"handle",
|
|
190
|
+
"avatar_url",
|
|
191
|
+
"subscriber_count",
|
|
192
|
+
"description",
|
|
193
|
+
"is_verified",
|
|
194
|
+
"notifications",
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
primaryKey: { subscriptions: ["id"] },
|
|
198
|
+
lossy: [
|
|
199
|
+
"Rows without legacy-required channelTitle/channelUrl or required notification booleans are skipped and reported.",
|
|
200
|
+
],
|
|
201
|
+
provenance: youtubeProvenance,
|
|
202
|
+
project(records, options) {
|
|
203
|
+
const guard = requireStreams("youtube.subscriptions", options, [
|
|
204
|
+
"subscriptions",
|
|
205
|
+
]);
|
|
206
|
+
if (guard)
|
|
207
|
+
return guard;
|
|
208
|
+
let skipped = 0;
|
|
209
|
+
const missing = new Set();
|
|
210
|
+
const subscriptions = byStream(records, "subscriptions").flatMap((row) => {
|
|
211
|
+
const channelTitle = youtubeRequiredString(row.channel_title);
|
|
212
|
+
const channelUrl = youtubeRequiredString(row.channel_url);
|
|
213
|
+
if (!channelTitle ||
|
|
214
|
+
!channelUrl ||
|
|
215
|
+
typeof row.is_verified !== "boolean" ||
|
|
216
|
+
typeof row.notifications !== "boolean") {
|
|
217
|
+
skipped++;
|
|
218
|
+
if (!channelTitle)
|
|
219
|
+
missing.add("subscriptions.channel_title");
|
|
220
|
+
if (!channelUrl)
|
|
221
|
+
missing.add("subscriptions.channel_url");
|
|
222
|
+
if (typeof row.is_verified !== "boolean")
|
|
223
|
+
missing.add("subscriptions.is_verified");
|
|
224
|
+
if (typeof row.notifications !== "boolean")
|
|
225
|
+
missing.add("subscriptions.notifications");
|
|
226
|
+
return [];
|
|
227
|
+
}
|
|
228
|
+
return [
|
|
229
|
+
{
|
|
230
|
+
channelTitle,
|
|
231
|
+
channelUrl,
|
|
232
|
+
handle: youtubeStringOrNull(row.handle),
|
|
233
|
+
channelId: youtubeStringOrNull(row.channel_id),
|
|
234
|
+
avatarUrl: youtubeStringOrNull(row.avatar_url),
|
|
235
|
+
subscriberCountText: null,
|
|
236
|
+
subscriberCount: typeof row.subscriber_count === "number"
|
|
237
|
+
? row.subscriber_count
|
|
238
|
+
: null,
|
|
239
|
+
description: youtubeStringOrNull(row.description),
|
|
240
|
+
isVerified: row.is_verified,
|
|
241
|
+
isBellNotification: row.notifications,
|
|
242
|
+
},
|
|
243
|
+
];
|
|
244
|
+
});
|
|
245
|
+
return youtubeLegacyProjection("youtube.subscriptions", skipped, [...missing], { subscriptions });
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
const youtubePlaylists = {
|
|
249
|
+
scope: "youtube.playlists",
|
|
250
|
+
pdppSource: youtubeProfile.pdppSource,
|
|
251
|
+
pdppStreams: ["playlists"],
|
|
252
|
+
legacySchemaPath: "youtube.playlists.json",
|
|
253
|
+
fieldsRead: {
|
|
254
|
+
playlists: [
|
|
255
|
+
"id",
|
|
256
|
+
"url",
|
|
257
|
+
"title",
|
|
258
|
+
"owner",
|
|
259
|
+
"owner_url",
|
|
260
|
+
"visibility",
|
|
261
|
+
"video_count",
|
|
262
|
+
"view_count",
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
primaryKey: { playlists: ["id"] },
|
|
266
|
+
lossy: [
|
|
267
|
+
"Rows without a legacy-required URL are skipped and reported. Browser collection is a bounded snapshot.",
|
|
268
|
+
],
|
|
269
|
+
provenance: youtubeProvenance,
|
|
270
|
+
project(records, options) {
|
|
271
|
+
const guard = requireStreams("youtube.playlists", options, ["playlists"]);
|
|
272
|
+
if (guard)
|
|
273
|
+
return guard;
|
|
274
|
+
let skipped = 0;
|
|
275
|
+
const playlists = byStream(records, "playlists").flatMap((row) => {
|
|
276
|
+
const playlistId = youtubeRequiredString(row.id);
|
|
277
|
+
const url = youtubeRequiredString(row.url);
|
|
278
|
+
if (!playlistId || !url) {
|
|
279
|
+
skipped++;
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
const privacy = typeof row.visibility === "string" &&
|
|
283
|
+
["Public", "Private", "Unlisted"].includes(row.visibility)
|
|
284
|
+
? row.visibility
|
|
285
|
+
: null;
|
|
286
|
+
return [
|
|
287
|
+
{
|
|
288
|
+
playlistId,
|
|
289
|
+
url,
|
|
290
|
+
title: youtubeStringOrNull(row.title),
|
|
291
|
+
owner: youtubeStringOrNull(row.owner),
|
|
292
|
+
ownerUrl: youtubeStringOrNull(row.owner_url),
|
|
293
|
+
privacy,
|
|
294
|
+
videoCount: typeof row.video_count === "number" ? row.video_count : null,
|
|
295
|
+
views: typeof row.view_count === "number" ? row.view_count : null,
|
|
296
|
+
},
|
|
297
|
+
];
|
|
298
|
+
});
|
|
299
|
+
return youtubeLegacyProjection("youtube.playlists", skipped, skipped ? ["playlists.id", "playlists.url"] : [], { playlists });
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
function youtubeVideo(row) {
|
|
303
|
+
const videoUrl = youtubeRequiredString(row.video_url);
|
|
304
|
+
const videoTitle = youtubeRequiredString(row.video_title);
|
|
305
|
+
if (!videoUrl || !videoTitle)
|
|
306
|
+
return undefined;
|
|
307
|
+
const duration = typeof row.duration_seconds === "number" ? row.duration_seconds : null;
|
|
308
|
+
const durationText = duration === null
|
|
309
|
+
? null
|
|
310
|
+
: duration >= 3600
|
|
311
|
+
? `${Math.floor(duration / 3600)}:${String(Math.floor((duration % 3600) / 60)).padStart(2, "0")}:${String(duration % 60).padStart(2, "0")}`
|
|
312
|
+
: `${Math.floor(duration / 60)}:${String(duration % 60).padStart(2, "0")}`;
|
|
313
|
+
return {
|
|
314
|
+
videoId: youtubeStringOrNull(row.video_id),
|
|
315
|
+
videoUrl,
|
|
316
|
+
videoTitle,
|
|
317
|
+
channelTitle: youtubeStringOrNull(row.channel_title),
|
|
318
|
+
channelUrl: youtubeStringOrNull(row.channel_url),
|
|
319
|
+
durationText,
|
|
320
|
+
thumbnailUrl: youtubeStringOrNull(row.thumbnail_url),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
const youtubePlaylistItems = {
|
|
324
|
+
scope: "youtube.playlistItems",
|
|
325
|
+
pdppSource: youtubeProfile.pdppSource,
|
|
326
|
+
pdppStreams: ["playlist_items"],
|
|
327
|
+
legacySchemaPath: "youtube.playlistItems.json",
|
|
328
|
+
fieldsRead: {
|
|
329
|
+
playlist_items: [
|
|
330
|
+
"playlist_id",
|
|
331
|
+
"video_id",
|
|
332
|
+
"video_url",
|
|
333
|
+
"video_title",
|
|
334
|
+
"channel_title",
|
|
335
|
+
"channel_url",
|
|
336
|
+
"duration_seconds",
|
|
337
|
+
"thumbnail_url",
|
|
338
|
+
],
|
|
339
|
+
},
|
|
340
|
+
primaryKey: { playlist_items: ["id"] },
|
|
341
|
+
lossy: [
|
|
342
|
+
"Playlist headers are unavailable on item rows. Records missing required videoTitle/videoUrl or playlist_id are skipped and reported. duration_seconds is formatted as m:ss.",
|
|
343
|
+
],
|
|
344
|
+
provenance: youtubeProvenance,
|
|
345
|
+
project(records, options) {
|
|
346
|
+
const guard = requireStreams("youtube.playlistItems", options, [
|
|
347
|
+
"playlist_items",
|
|
348
|
+
]);
|
|
349
|
+
if (guard)
|
|
350
|
+
return guard;
|
|
351
|
+
const grouped = new Map();
|
|
352
|
+
let skipped = 0;
|
|
353
|
+
for (const row of byStream(records, "playlist_items")) {
|
|
354
|
+
const playlistId = youtubeRequiredString(row.playlist_id);
|
|
355
|
+
const video = youtubeVideo(row);
|
|
356
|
+
if (!playlistId || !video) {
|
|
357
|
+
skipped++;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const group = grouped.get(playlistId) ?? { playlistId, items: [] };
|
|
361
|
+
group.items.push(video);
|
|
362
|
+
grouped.set(playlistId, group);
|
|
363
|
+
}
|
|
364
|
+
return youtubeLegacyProjection("youtube.playlistItems", skipped, skipped
|
|
365
|
+
? [
|
|
366
|
+
"playlist_items.playlist_id",
|
|
367
|
+
"playlist_items.video_url",
|
|
368
|
+
"playlist_items.video_title",
|
|
369
|
+
]
|
|
370
|
+
: [], {
|
|
371
|
+
playlists: [...grouped.values()].map((group) => ({
|
|
372
|
+
...group,
|
|
373
|
+
playlistTitle: null,
|
|
374
|
+
playlistUrl: null,
|
|
375
|
+
})),
|
|
376
|
+
});
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
function youtubeNamedVideos(scope, stream, key) {
|
|
380
|
+
return {
|
|
381
|
+
scope,
|
|
382
|
+
pdppSource: youtubeProfile.pdppSource,
|
|
383
|
+
pdppStreams: [stream],
|
|
384
|
+
legacySchemaPath: `${scope}.json`,
|
|
385
|
+
fieldsRead: {
|
|
386
|
+
[stream]: [
|
|
387
|
+
"video_id",
|
|
388
|
+
"video_url",
|
|
389
|
+
"video_title",
|
|
390
|
+
"channel_title",
|
|
391
|
+
"channel_url",
|
|
392
|
+
"duration_seconds",
|
|
393
|
+
"thumbnail_url",
|
|
394
|
+
],
|
|
395
|
+
},
|
|
396
|
+
primaryKey: { [stream]: ["id"] },
|
|
397
|
+
lossy: [
|
|
398
|
+
"Legacy requires videoTitle and videoUrl; incomplete browser rows are skipped and reported.",
|
|
399
|
+
],
|
|
400
|
+
provenance: youtubeProvenance,
|
|
401
|
+
project(records, options) {
|
|
402
|
+
const guard = requireStreams(scope, options, [stream]);
|
|
403
|
+
if (guard)
|
|
404
|
+
return guard;
|
|
405
|
+
let skipped = 0;
|
|
406
|
+
const videos = byStream(records, stream).flatMap((row) => {
|
|
407
|
+
const video = youtubeVideo(row);
|
|
408
|
+
if (!video) {
|
|
409
|
+
skipped++;
|
|
410
|
+
return [];
|
|
411
|
+
}
|
|
412
|
+
return [video];
|
|
413
|
+
});
|
|
414
|
+
return youtubeLegacyProjection(scope, skipped, skipped ? [`${stream}.video_url`, `${stream}.video_title`] : [], { [key]: videos });
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
const youtubeLikes = youtubeNamedVideos("youtube.likes", "likes", "likedVideos");
|
|
419
|
+
const youtubeWatchLater = youtubeNamedVideos("youtube.watchLater", "watch_later", "watchLater");
|
|
420
|
+
const youtubeHistory = {
|
|
421
|
+
scope: "youtube.history",
|
|
422
|
+
pdppSource: youtubeProfile.pdppSource,
|
|
423
|
+
pdppStreams: ["watch_history"],
|
|
424
|
+
legacySchemaPath: "youtube.history.json",
|
|
425
|
+
fieldsRead: {
|
|
426
|
+
watch_history: [
|
|
427
|
+
"position",
|
|
428
|
+
"watched_date",
|
|
429
|
+
"video_id",
|
|
430
|
+
"video_url",
|
|
431
|
+
"video_title",
|
|
432
|
+
"channel_title",
|
|
433
|
+
"channel_url",
|
|
434
|
+
"view_count",
|
|
435
|
+
"description",
|
|
436
|
+
],
|
|
437
|
+
},
|
|
438
|
+
primaryKey: { watch_history: ["id"] },
|
|
439
|
+
lossy: [
|
|
440
|
+
"Preserves page order by position and caps output at 50 entries. watchedAtText carries watched_date at day precision only; no timestamp is inferred. Integer view_count is rendered as '<count> views'; absent view_count remains null. Rows without required videoUrl are skipped and reported. Missing positions fail because page order cannot be preserved.",
|
|
441
|
+
],
|
|
442
|
+
provenance: youtubeProvenance,
|
|
443
|
+
project(records, options) {
|
|
444
|
+
const guard = requireStreams("youtube.history", options, ["watch_history"]);
|
|
445
|
+
if (guard)
|
|
446
|
+
return guard;
|
|
447
|
+
let skipped = 0;
|
|
448
|
+
const sourceRows = byStream(records, "watch_history");
|
|
449
|
+
if (sourceRows.some((row) => typeof row.position !== "number" || !Number.isInteger(row.position))) {
|
|
450
|
+
return {
|
|
451
|
+
ok: false,
|
|
452
|
+
error: {
|
|
453
|
+
kind: "invalid_value",
|
|
454
|
+
scope: "youtube.history",
|
|
455
|
+
reason: "YouTube browser history is missing an integer position; page order cannot be preserved",
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
const rows = sourceRows
|
|
460
|
+
.flatMap((row) => {
|
|
461
|
+
const videoUrl = youtubeRequiredString(row.video_url);
|
|
462
|
+
if (!videoUrl) {
|
|
463
|
+
skipped++;
|
|
464
|
+
return [];
|
|
465
|
+
}
|
|
466
|
+
const viewCount = typeof row.view_count === "number" ? row.view_count : null;
|
|
467
|
+
return [
|
|
468
|
+
{
|
|
469
|
+
watchedAtText: youtubeStringOrNull(row.watched_date),
|
|
470
|
+
videoId: youtubeStringOrNull(row.video_id),
|
|
471
|
+
videoUrl,
|
|
472
|
+
videoTitle: youtubeStringOrNull(row.video_title),
|
|
473
|
+
channelTitle: youtubeStringOrNull(row.channel_title),
|
|
474
|
+
views: viewCount === null ? null : `${viewCount} views`,
|
|
475
|
+
description: youtubeStringOrNull(row.description),
|
|
476
|
+
_position: row.position,
|
|
477
|
+
},
|
|
478
|
+
];
|
|
479
|
+
})
|
|
480
|
+
.sort((a, b) => a._position - b._position);
|
|
481
|
+
const history = rows
|
|
482
|
+
.slice(0, 50)
|
|
483
|
+
.map(({ _position: _ignored, ...row }) => row);
|
|
484
|
+
return youtubeLegacyProjection("youtube.history", skipped, skipped ? ["watch_history.video_url"] : [], { timeWindow: "top 50 most recent items", history });
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
function isNonEmptyString(value) {
|
|
488
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
489
|
+
}
|
|
490
|
+
function isOptionalString(value) {
|
|
491
|
+
return value === undefined || value === null || typeof value === "string";
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Validates that a string is a valid ISO 8601 date-time. Rejects invalid
|
|
495
|
+
* formats so only valid date-times pass through to the payload (F5).
|
|
496
|
+
*/
|
|
497
|
+
function isValidIso8601DateTime(value) {
|
|
498
|
+
if (typeof value !== "string")
|
|
499
|
+
return false;
|
|
500
|
+
const date = new Date(value);
|
|
501
|
+
// The string is valid if it parses to a valid date (not NaN).
|
|
502
|
+
return !isNaN(date.getTime());
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Reformats an ISO-8601 date/date-time string to amazon-playwright.js's
|
|
506
|
+
* captured legacy shape, `"March 5, 2024"` (verified: the connector's own
|
|
507
|
+
* regex only ever matches `MonthName D, YYYY`,
|
|
508
|
+
* connectors/amazon/amazon-playwright.js:184-187 at the pin). Returns `''`
|
|
509
|
+
* (the connector's own no-match fallback) for anything that does not parse
|
|
510
|
+
* as a date, rather than passing through a shape the legacy schema does not
|
|
511
|
+
* expect.
|
|
512
|
+
*/
|
|
513
|
+
const MONTH_NAMES = [
|
|
514
|
+
"January",
|
|
515
|
+
"February",
|
|
516
|
+
"March",
|
|
517
|
+
"April",
|
|
518
|
+
"May",
|
|
519
|
+
"June",
|
|
520
|
+
"July",
|
|
521
|
+
"August",
|
|
522
|
+
"September",
|
|
523
|
+
"October",
|
|
524
|
+
"November",
|
|
525
|
+
"December",
|
|
526
|
+
];
|
|
527
|
+
function toLegacyLongDate(value) {
|
|
528
|
+
if (typeof value !== "string") {
|
|
529
|
+
return "";
|
|
530
|
+
}
|
|
531
|
+
const d = new Date(value);
|
|
532
|
+
if (Number.isNaN(d.getTime())) {
|
|
533
|
+
return "";
|
|
534
|
+
}
|
|
535
|
+
return `${MONTH_NAMES[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Reformats start/end date strings to linkedin-playwright.js's captured
|
|
539
|
+
* legacy `dates` shape for experience and education, e.g.
|
|
540
|
+
* `"1/2024 - Present"` (verified: `extractTimePeriod`,
|
|
541
|
+
* connectors/linkedin/linkedin-playwright.js:129-145 — `(month + '/' ) +
|
|
542
|
+
* year` per side, joined by `' - '`, `'Present'` for a missing end, then
|
|
543
|
+
* `.trim()`-ed so a missing start reads `"- Present"` / `"- 3/2020"`). The
|
|
544
|
+
* signed LinkedIn 0.3.1 declaration uses partial dates (`YYYY` or `YYYY-MM`),
|
|
545
|
+
* so year-only values keep their source precision. Full ISO values keep the
|
|
546
|
+
* earlier month/year behavior.
|
|
547
|
+
*/
|
|
548
|
+
function toLegacyTimePeriod(start, end) {
|
|
549
|
+
const part = (value) => {
|
|
550
|
+
if (typeof value !== "string") {
|
|
551
|
+
return "";
|
|
552
|
+
}
|
|
553
|
+
const yearOnly = value.match(/^(\d{4})$/);
|
|
554
|
+
if (yearOnly) {
|
|
555
|
+
return yearOnly[1];
|
|
556
|
+
}
|
|
557
|
+
const yearMonth = value.match(/^(\d{4})-(\d{2})$/);
|
|
558
|
+
if (yearMonth) {
|
|
559
|
+
return `${Number(yearMonth[2])}/${yearMonth[1]}`;
|
|
560
|
+
}
|
|
561
|
+
const d = new Date(value);
|
|
562
|
+
if (Number.isNaN(d.getTime())) {
|
|
563
|
+
return "";
|
|
564
|
+
}
|
|
565
|
+
return `${d.getUTCMonth() + 1}/${d.getUTCFullYear()}`;
|
|
566
|
+
};
|
|
567
|
+
const startPart = part(start);
|
|
568
|
+
const endPart = typeof end === "string" ? part(end) : "";
|
|
569
|
+
return `${startPart} - ${endPart || "Present"}`.trim();
|
|
570
|
+
}
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
// meta (instagram)
|
|
573
|
+
// declarations/meta.collection-profile.json, streams "profile" and "posts".
|
|
574
|
+
// connector_id (verified byte-identical to the vendored declaration):
|
|
575
|
+
// https://registry.pdpp.dev/connectors/meta
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
const instagramProfile = {
|
|
578
|
+
scope: "instagram.profile",
|
|
579
|
+
pdppSource: "https://registry.pdpp.dev/connectors/meta",
|
|
580
|
+
pdppStreams: ["profile"],
|
|
581
|
+
legacySchemaPath: "instagram.profile.json",
|
|
582
|
+
fieldsRead: {
|
|
583
|
+
profile: [
|
|
584
|
+
"username",
|
|
585
|
+
"full_name",
|
|
586
|
+
"bio",
|
|
587
|
+
"profile_pic_url",
|
|
588
|
+
"external_url",
|
|
589
|
+
"follower_count",
|
|
590
|
+
"following_count",
|
|
591
|
+
"is_private",
|
|
592
|
+
"is_verified",
|
|
593
|
+
"is_business",
|
|
594
|
+
],
|
|
595
|
+
},
|
|
596
|
+
primaryKey: { profile: ["id"] },
|
|
597
|
+
lossy: [
|
|
598
|
+
"media_count (no PDPP equivalent; post_count is a different concept — Instagram's media_count includes non-post media types the posts stream does not cover)",
|
|
599
|
+
],
|
|
600
|
+
provenance: [
|
|
601
|
+
{
|
|
602
|
+
kind: "oci",
|
|
603
|
+
ref: "ghcr.io/pdp-connect/connector/meta:0.4.0",
|
|
604
|
+
digest: "sha256:9805c13ab4ad45caf904b4ce834764e701e426f5c3d2f3697fbab2705d1636ea",
|
|
605
|
+
path: "declarations/meta.collection-profile.json#streams[name=profile]",
|
|
606
|
+
},
|
|
607
|
+
],
|
|
608
|
+
project(records, options) {
|
|
609
|
+
const guard = requireStreams("instagram.profile", options, ["profile"]);
|
|
610
|
+
if (guard) {
|
|
611
|
+
return guard;
|
|
612
|
+
}
|
|
613
|
+
const [p] = byStream(records, "profile");
|
|
614
|
+
if (!p) {
|
|
615
|
+
return {
|
|
616
|
+
ok: false,
|
|
617
|
+
error: { kind: "empty_singleton", scope: "instagram.profile" },
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
const username = p.username;
|
|
621
|
+
if (typeof username !== "string") {
|
|
622
|
+
return {
|
|
623
|
+
ok: false,
|
|
624
|
+
error: {
|
|
625
|
+
kind: "invalid_value",
|
|
626
|
+
scope: "instagram.profile",
|
|
627
|
+
reason: "Meta profile is missing username",
|
|
628
|
+
},
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
const fullName = p.full_name == null
|
|
632
|
+
? ""
|
|
633
|
+
: typeof p.full_name === "string"
|
|
634
|
+
? p.full_name
|
|
635
|
+
: undefined;
|
|
636
|
+
if (fullName === undefined) {
|
|
637
|
+
return {
|
|
638
|
+
ok: false,
|
|
639
|
+
error: {
|
|
640
|
+
kind: "invalid_value",
|
|
641
|
+
scope: "instagram.profile",
|
|
642
|
+
reason: "Meta profile full_name is not a string",
|
|
643
|
+
},
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
const payload = {
|
|
647
|
+
username,
|
|
648
|
+
full_name: fullName,
|
|
649
|
+
};
|
|
650
|
+
if (typeof p.bio === "string")
|
|
651
|
+
payload.bio = p.bio;
|
|
652
|
+
if (typeof p.profile_pic_url === "string") {
|
|
653
|
+
payload.profile_pic_url = p.profile_pic_url;
|
|
654
|
+
}
|
|
655
|
+
if (typeof p.external_url === "string") {
|
|
656
|
+
payload.external_url = p.external_url;
|
|
657
|
+
}
|
|
658
|
+
if (typeof p.follower_count === "number") {
|
|
659
|
+
payload.follower_count = p.follower_count;
|
|
660
|
+
}
|
|
661
|
+
if (typeof p.following_count === "number") {
|
|
662
|
+
payload.following_count = p.following_count;
|
|
663
|
+
}
|
|
664
|
+
if (typeof p.is_private === "boolean")
|
|
665
|
+
payload.is_private = p.is_private;
|
|
666
|
+
if (typeof p.is_verified === "boolean")
|
|
667
|
+
payload.is_verified = p.is_verified;
|
|
668
|
+
if (typeof p.is_business === "boolean")
|
|
669
|
+
payload.is_business = p.is_business;
|
|
670
|
+
return {
|
|
671
|
+
ok: true,
|
|
672
|
+
payload,
|
|
673
|
+
};
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
// ---------------------------------------------------------------------------
|
|
677
|
+
// instagram.posts
|
|
678
|
+
// declarations/meta.collection-profile.json#streams[name=posts]
|
|
679
|
+
// connector_id: https://registry.pdpp.dev/connectors/meta
|
|
680
|
+
// ---------------------------------------------------------------------------
|
|
681
|
+
const instagramPosts = {
|
|
682
|
+
scope: "instagram.posts",
|
|
683
|
+
pdppSource: "https://registry.pdpp.dev/connectors/meta",
|
|
684
|
+
pdppStreams: ["posts", "post_likes"],
|
|
685
|
+
legacySchemaPath: "instagram.posts.json",
|
|
686
|
+
fieldsRead: {
|
|
687
|
+
posts: ["id", "media_url", "caption", "like_count", "taken_at"],
|
|
688
|
+
post_likes: ["post_id", "user_id", "username"],
|
|
689
|
+
},
|
|
690
|
+
primaryKey: { posts: ["id"], post_likes: ["post_id", "liker_ordinal"] },
|
|
691
|
+
lossy: [
|
|
692
|
+
"nullable media_url, caption, and like_count use the retained collector's empty-string and zero sentinels; every source post is retained",
|
|
693
|
+
"media_url is only available for images and the first image in a carousel; video posts and later carousel images are absent from the artifact's parser output",
|
|
694
|
+
"who_liked.profile_pic_url is omitted because the post_likes stream does not declare it",
|
|
695
|
+
"who_liked.pk and who_liked.id both use post_likes.user_id because the source exposes one liker identity field",
|
|
696
|
+
],
|
|
697
|
+
provenance: [
|
|
698
|
+
{
|
|
699
|
+
kind: "oci",
|
|
700
|
+
ref: "ghcr.io/pdp-connect/connector/meta:0.4.0",
|
|
701
|
+
digest: "sha256:9805c13ab4ad45caf904b4ce834764e701e426f5c3d2f3697fbab2705d1636ea",
|
|
702
|
+
path: "declarations/meta.collection-profile.json#streams[name=posts,post_likes]",
|
|
703
|
+
},
|
|
704
|
+
],
|
|
705
|
+
project(records, options) {
|
|
706
|
+
const guard = requireStreams("instagram.posts", options, [
|
|
707
|
+
"posts",
|
|
708
|
+
"post_likes",
|
|
709
|
+
]);
|
|
710
|
+
if (guard) {
|
|
711
|
+
return guard;
|
|
712
|
+
}
|
|
713
|
+
const sourcePosts = byStream(records, "posts");
|
|
714
|
+
const likesByPost = new Map();
|
|
715
|
+
for (const liker of byStream(records, "post_likes")) {
|
|
716
|
+
if (typeof liker.post_id !== "string" ||
|
|
717
|
+
typeof liker.user_id !== "string" ||
|
|
718
|
+
typeof liker.username !== "string") {
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
const whoLiked = likesByPost.get(liker.post_id) ?? [];
|
|
722
|
+
whoLiked.push({
|
|
723
|
+
pk: liker.user_id,
|
|
724
|
+
username: liker.username,
|
|
725
|
+
id: liker.user_id,
|
|
726
|
+
});
|
|
727
|
+
likesByPost.set(liker.post_id, whoLiked);
|
|
728
|
+
}
|
|
729
|
+
const posts = [];
|
|
730
|
+
for (const sourcePost of sourcePosts) {
|
|
731
|
+
const projectedPost = {
|
|
732
|
+
img_url: orEmptyString(sourcePost.media_url),
|
|
733
|
+
caption: orEmptyString(sourcePost.caption),
|
|
734
|
+
num_of_likes: orZero(sourcePost.like_count),
|
|
735
|
+
who_liked: typeof sourcePost.id === "string"
|
|
736
|
+
? (likesByPost.get(sourcePost.id) ?? [])
|
|
737
|
+
: [],
|
|
738
|
+
};
|
|
739
|
+
if (typeof sourcePost.taken_at === "string") {
|
|
740
|
+
projectedPost.taken_at = sourcePost.taken_at;
|
|
741
|
+
}
|
|
742
|
+
posts.push(projectedPost);
|
|
743
|
+
}
|
|
744
|
+
return {
|
|
745
|
+
ok: true,
|
|
746
|
+
payload: { posts },
|
|
747
|
+
};
|
|
748
|
+
},
|
|
749
|
+
};
|
|
750
|
+
// ---------------------------------------------------------------------------
|
|
751
|
+
// instagram.following and instagram.ads
|
|
752
|
+
// declarations/meta.collection-profile.json#streams[name=following,ads]
|
|
753
|
+
// ---------------------------------------------------------------------------
|
|
754
|
+
const instagramFollowing = {
|
|
755
|
+
scope: "instagram.following",
|
|
756
|
+
pdppSource: "https://registry.pdpp.dev/connectors/meta",
|
|
757
|
+
pdppStreams: ["following"],
|
|
758
|
+
legacySchemaPath: "instagram.following.json",
|
|
759
|
+
fieldsRead: {
|
|
760
|
+
following: [
|
|
761
|
+
"id",
|
|
762
|
+
"username",
|
|
763
|
+
"full_name",
|
|
764
|
+
"is_private",
|
|
765
|
+
"is_verified",
|
|
766
|
+
"profile_pic_url",
|
|
767
|
+
],
|
|
768
|
+
},
|
|
769
|
+
primaryKey: { following: ["id"] },
|
|
770
|
+
lossy: [
|
|
771
|
+
"rows without a non-empty string username are skipped because the legacy account schema requires username",
|
|
772
|
+
],
|
|
773
|
+
provenance: [
|
|
774
|
+
{
|
|
775
|
+
kind: "oci",
|
|
776
|
+
ref: "ghcr.io/pdp-connect/connector/meta:0.4.0",
|
|
777
|
+
digest: "sha256:9805c13ab4ad45caf904b4ce834764e701e426f5c3d2f3697fbab2705d1636ea",
|
|
778
|
+
path: "declarations/meta.collection-profile.json#streams[name=following]",
|
|
779
|
+
},
|
|
780
|
+
],
|
|
781
|
+
project(records, options) {
|
|
782
|
+
const guard = requireStreams("instagram.following", options, ["following"]);
|
|
783
|
+
if (guard)
|
|
784
|
+
return guard;
|
|
785
|
+
const accounts = byStream(records, "following").flatMap((r) => {
|
|
786
|
+
if (typeof r.username !== "string" || !r.username)
|
|
787
|
+
return [];
|
|
788
|
+
const account = {
|
|
789
|
+
username: r.username,
|
|
790
|
+
full_name: typeof r.full_name === "string" ? r.full_name : "",
|
|
791
|
+
is_private: r.is_private === true,
|
|
792
|
+
is_verified: r.is_verified === true,
|
|
793
|
+
profile_pic_url: typeof r.profile_pic_url === "string" && r.profile_pic_url
|
|
794
|
+
? r.profile_pic_url
|
|
795
|
+
: null,
|
|
796
|
+
};
|
|
797
|
+
if (typeof r.id === "string")
|
|
798
|
+
account.pk = r.id;
|
|
799
|
+
return [account];
|
|
800
|
+
});
|
|
801
|
+
return { ok: true, payload: { accounts, total: accounts.length } };
|
|
802
|
+
},
|
|
803
|
+
};
|
|
804
|
+
const instagramAds = {
|
|
805
|
+
scope: "instagram.ads",
|
|
806
|
+
pdppSource: "https://registry.pdpp.dev/connectors/meta",
|
|
807
|
+
pdppStreams: ["ads"],
|
|
808
|
+
legacySchemaPath: "instagram.ads.json",
|
|
809
|
+
fieldsRead: { ads: ["kind", "name", "description"] },
|
|
810
|
+
primaryKey: { ads: ["id"] },
|
|
811
|
+
lossy: [
|
|
812
|
+
"ads rows with a null description omit the optional legacy category description",
|
|
813
|
+
"malformed ads rows without a string name or one of the declaration's three kinds are skipped",
|
|
814
|
+
],
|
|
815
|
+
provenance: [
|
|
816
|
+
{
|
|
817
|
+
kind: "oci",
|
|
818
|
+
ref: "ghcr.io/pdp-connect/connector/meta:0.4.0",
|
|
819
|
+
digest: "sha256:9805c13ab4ad45caf904b4ce834764e701e426f5c3d2f3697fbab2705d1636ea",
|
|
820
|
+
path: "declarations/meta.collection-profile.json#streams[name=ads]",
|
|
821
|
+
},
|
|
822
|
+
],
|
|
823
|
+
project(records, options) {
|
|
824
|
+
const guard = requireStreams("instagram.ads", options, ["ads"]);
|
|
825
|
+
if (guard)
|
|
826
|
+
return guard;
|
|
827
|
+
const advertisers = [];
|
|
828
|
+
const ad_topics = [];
|
|
829
|
+
const categories = [];
|
|
830
|
+
for (const row of byStream(records, "ads")) {
|
|
831
|
+
if (typeof row.name !== "string")
|
|
832
|
+
continue;
|
|
833
|
+
if (row.kind === "advertiser")
|
|
834
|
+
advertisers.push({ name: row.name });
|
|
835
|
+
if (row.kind === "ad_topic")
|
|
836
|
+
ad_topics.push({ name: row.name });
|
|
837
|
+
if (row.kind === "ad_category") {
|
|
838
|
+
categories.push({
|
|
839
|
+
name: row.name,
|
|
840
|
+
...(typeof row.description === "string"
|
|
841
|
+
? { description: row.description }
|
|
842
|
+
: {}),
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return { ok: true, payload: { advertisers, ad_topics, categories } };
|
|
847
|
+
},
|
|
848
|
+
};
|
|
849
|
+
// ---------------------------------------------------------------------------
|
|
850
|
+
// github
|
|
851
|
+
// declarations/github.collection-profile.json
|
|
852
|
+
// connector_id: https://registry.pdpp.dev/connectors/github
|
|
853
|
+
// ---------------------------------------------------------------------------
|
|
854
|
+
const githubRepositories = {
|
|
855
|
+
scope: "github.repositories",
|
|
856
|
+
pdppSource: "https://registry.pdpp.dev/connectors/github",
|
|
857
|
+
pdppStreams: ["repositories"],
|
|
858
|
+
legacySchemaPath: "github.repositories.json",
|
|
859
|
+
fieldsRead: {
|
|
860
|
+
repositories: [
|
|
861
|
+
"name",
|
|
862
|
+
"html_url",
|
|
863
|
+
"description",
|
|
864
|
+
"language",
|
|
865
|
+
"stargazers_count",
|
|
866
|
+
"forks_count",
|
|
867
|
+
"private",
|
|
868
|
+
"topics",
|
|
869
|
+
"updated_at",
|
|
870
|
+
],
|
|
871
|
+
},
|
|
872
|
+
primaryKey: { repositories: ["id"] },
|
|
873
|
+
lossy: [
|
|
874
|
+
"description, language, updatedAt: PDPP-side null projects as legacy's own '' / null default, not a fabricated value, but omission still differs from a populated legacy record",
|
|
875
|
+
],
|
|
876
|
+
provenance: [
|
|
877
|
+
{
|
|
878
|
+
kind: "oci",
|
|
879
|
+
ref: "ghcr.io/pdp-connect/connector/github:0.7.2",
|
|
880
|
+
digest: "sha256:5501bb109cdca02d2acd08aa6891613c7af0bbe0869c30eba2a8070836b0d2f7",
|
|
881
|
+
path: "declarations/github.collection-profile.json#streams[name=repositories]",
|
|
882
|
+
},
|
|
883
|
+
],
|
|
884
|
+
project(records, options) {
|
|
885
|
+
const guard = requireStreams("github.repositories", options, [
|
|
886
|
+
"repositories",
|
|
887
|
+
]);
|
|
888
|
+
if (guard) {
|
|
889
|
+
return guard;
|
|
890
|
+
}
|
|
891
|
+
const items = byStream(records, "repositories");
|
|
892
|
+
// Legacy requires name, url per item; allows description, language,
|
|
893
|
+
// stars, forks, visibility, topics, updatedAt. `url` is `html_url` on
|
|
894
|
+
// the repositories stream (a real GitHub URL field, not a template).
|
|
895
|
+
// `visibility` is the literal text GitHub's own repo-list DOM renders,
|
|
896
|
+
// capitalized "Public"/"Private" (github-playwright.js:416,
|
|
897
|
+
// `row.querySelector('span.Label')?.textContent || 'Public'`) — derived
|
|
898
|
+
// here from PDPP's boolean `private` field with the matching casing,
|
|
899
|
+
// not lowercase.
|
|
900
|
+
return {
|
|
901
|
+
ok: true,
|
|
902
|
+
payload: {
|
|
903
|
+
repositories: items.map((r) => ({
|
|
904
|
+
name: orEmptyString(r.name),
|
|
905
|
+
url: orEmptyString(r.html_url),
|
|
906
|
+
description: orEmptyString(r.description),
|
|
907
|
+
language: orEmptyString(r.language),
|
|
908
|
+
stars: orZero(r.stargazers_count),
|
|
909
|
+
forks: orZero(r.forks_count),
|
|
910
|
+
visibility: r.private === true ? "Private" : "Public",
|
|
911
|
+
topics: Array.isArray(r.topics) ? r.topics : [],
|
|
912
|
+
updatedAt: typeof r.updated_at === "string" ? r.updated_at : null,
|
|
913
|
+
})),
|
|
914
|
+
},
|
|
915
|
+
};
|
|
916
|
+
},
|
|
917
|
+
};
|
|
918
|
+
const githubStarred = {
|
|
919
|
+
scope: "github.starred",
|
|
920
|
+
pdppSource: "https://registry.pdpp.dev/connectors/github",
|
|
921
|
+
pdppStreams: ["starred"],
|
|
922
|
+
legacySchemaPath: "github.starred.json",
|
|
923
|
+
fieldsRead: {
|
|
924
|
+
starred: [
|
|
925
|
+
"full_name",
|
|
926
|
+
"html_url",
|
|
927
|
+
"description",
|
|
928
|
+
"language",
|
|
929
|
+
"stargazers_count",
|
|
930
|
+
],
|
|
931
|
+
},
|
|
932
|
+
primaryKey: { starred: ["id"] },
|
|
933
|
+
lossy: [
|
|
934
|
+
"updatedAt changes meaning: legacy is the repo's own last-updated time from the stars page (github-playwright.js:415, relative-time datetime); PDPP's starred stream only carries starred_at, the time the user starred it. Field is emitted as null (no PDPP source for repo update time), not omitted.",
|
|
935
|
+
"description, language: PDPP-side null projects as ''",
|
|
936
|
+
],
|
|
937
|
+
provenance: [
|
|
938
|
+
{
|
|
939
|
+
kind: "oci",
|
|
940
|
+
ref: "ghcr.io/pdp-connect/connector/github:0.7.2",
|
|
941
|
+
digest: "sha256:5501bb109cdca02d2acd08aa6891613c7af0bbe0869c30eba2a8070836b0d2f7",
|
|
942
|
+
path: "declarations/github.collection-profile.json#streams[name=starred]",
|
|
943
|
+
},
|
|
944
|
+
],
|
|
945
|
+
project(records, options) {
|
|
946
|
+
const guard = requireStreams("github.starred", options, ["starred"]);
|
|
947
|
+
if (guard) {
|
|
948
|
+
return guard;
|
|
949
|
+
}
|
|
950
|
+
const items = byStream(records, "starred");
|
|
951
|
+
return {
|
|
952
|
+
ok: true,
|
|
953
|
+
payload: {
|
|
954
|
+
starred: items.map((r) => ({
|
|
955
|
+
fullName: orEmptyString(r.full_name),
|
|
956
|
+
url: orEmptyString(r.html_url),
|
|
957
|
+
description: orEmptyString(r.description),
|
|
958
|
+
language: orEmptyString(r.language),
|
|
959
|
+
stars: orZero(r.stargazers_count),
|
|
960
|
+
updatedAt: null,
|
|
961
|
+
})),
|
|
962
|
+
},
|
|
963
|
+
};
|
|
964
|
+
},
|
|
965
|
+
};
|
|
966
|
+
// github.history can be projected from authored pull_requests plus authored
|
|
967
|
+
// issues. The signed issues stream also contains assigned issues, so it is
|
|
968
|
+
// filtered against the signed user.login singleton before projection.
|
|
969
|
+
const githubHistory = {
|
|
970
|
+
scope: "github.history",
|
|
971
|
+
pdppSource: "https://registry.pdpp.dev/connectors/github",
|
|
972
|
+
pdppStreams: ["user", "issues", "pull_requests"],
|
|
973
|
+
legacySchemaPath: "github.history.json",
|
|
974
|
+
fieldsRead: {
|
|
975
|
+
user: ["login"],
|
|
976
|
+
issues: [
|
|
977
|
+
"id",
|
|
978
|
+
"number",
|
|
979
|
+
"title",
|
|
980
|
+
"body",
|
|
981
|
+
"state",
|
|
982
|
+
"user_login",
|
|
983
|
+
"labels",
|
|
984
|
+
"repository_full_name",
|
|
985
|
+
"html_url",
|
|
986
|
+
"comments",
|
|
987
|
+
"reactions_total_count",
|
|
988
|
+
"created_at",
|
|
989
|
+
"updated_at",
|
|
990
|
+
"closed_at",
|
|
991
|
+
"is_pull_request",
|
|
992
|
+
],
|
|
993
|
+
pull_requests: [
|
|
994
|
+
"id",
|
|
995
|
+
"number",
|
|
996
|
+
"title",
|
|
997
|
+
"body",
|
|
998
|
+
"state",
|
|
999
|
+
"labels",
|
|
1000
|
+
"repository_full_name",
|
|
1001
|
+
"html_url",
|
|
1002
|
+
"comments",
|
|
1003
|
+
"reactions_total_count",
|
|
1004
|
+
"created_at",
|
|
1005
|
+
"updated_at",
|
|
1006
|
+
"closed_at",
|
|
1007
|
+
"merged_at",
|
|
1008
|
+
"draft",
|
|
1009
|
+
],
|
|
1010
|
+
},
|
|
1011
|
+
primaryKey: { user: ["id"], issues: ["id"], pull_requests: ["id"] },
|
|
1012
|
+
lossy: [
|
|
1013
|
+
"legacy IDs used gh-issue-/gh-pr- prefixes; projection uses the signed PDPP record ID directly",
|
|
1014
|
+
"body is truncated to the legacy connector's 500-character limit",
|
|
1015
|
+
"repoUrl is omitted because no repository URL field is declared on issue or pull request streams",
|
|
1016
|
+
"fetchedAt is set at projection time because the legacy schema requires it and PDPP history records have no fetch timestamp",
|
|
1017
|
+
"items with missing required legacy repo names or malformed declared fields are skipped",
|
|
1018
|
+
],
|
|
1019
|
+
provenance: [
|
|
1020
|
+
{
|
|
1021
|
+
kind: "oci",
|
|
1022
|
+
ref: "ghcr.io/pdp-connect/connector/github:0.7.2",
|
|
1023
|
+
digest: "sha256:5501bb109cdca02d2acd08aa6891613c7af0bbe0869c30eba2a8070836b0d2f7",
|
|
1024
|
+
path: "declarations/github.collection-profile.json#streams[name=user,issues,pull_requests]",
|
|
1025
|
+
},
|
|
1026
|
+
],
|
|
1027
|
+
project(records, options) {
|
|
1028
|
+
const streams = ["user", "issues", "pull_requests"];
|
|
1029
|
+
const guard = requireStreams("github.history", options, streams);
|
|
1030
|
+
if (guard)
|
|
1031
|
+
return guard;
|
|
1032
|
+
const login = byStream(records, "user")[0]?.login;
|
|
1033
|
+
const sourceIssues = byStream(records, "issues");
|
|
1034
|
+
if (typeof login !== "string" && sourceIssues.length > 0) {
|
|
1035
|
+
return {
|
|
1036
|
+
ok: false,
|
|
1037
|
+
error: {
|
|
1038
|
+
kind: "invalid_value",
|
|
1039
|
+
scope: "github.history",
|
|
1040
|
+
reason: "Signed user stream has no login for authored-issue filtering",
|
|
1041
|
+
},
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
const projectItem = (r, type) => {
|
|
1045
|
+
if (typeof r.id !== "string" ||
|
|
1046
|
+
typeof r.repository_full_name !== "string")
|
|
1047
|
+
return null;
|
|
1048
|
+
const item = {
|
|
1049
|
+
id: r.id,
|
|
1050
|
+
type,
|
|
1051
|
+
repo: r.repository_full_name,
|
|
1052
|
+
comments: typeof r.comments === "number" ? r.comments : 0,
|
|
1053
|
+
reactionsTotal: typeof r.reactions_total_count === "number"
|
|
1054
|
+
? r.reactions_total_count
|
|
1055
|
+
: 0,
|
|
1056
|
+
isDraft: type === "pr" &&
|
|
1057
|
+
r.draft === true &&
|
|
1058
|
+
r.state === "open" &&
|
|
1059
|
+
r.merged_at == null,
|
|
1060
|
+
};
|
|
1061
|
+
if (typeof r.number === "number")
|
|
1062
|
+
item.number = r.number;
|
|
1063
|
+
if (typeof r.title === "string" || r.title === null)
|
|
1064
|
+
item.title = r.title;
|
|
1065
|
+
if (typeof r.body === "string" || r.body === null) {
|
|
1066
|
+
item.body = typeof r.body === "string" ? r.body.slice(0, 500) : null;
|
|
1067
|
+
}
|
|
1068
|
+
if (typeof r.state === "string" || r.state === null)
|
|
1069
|
+
item.state = r.state;
|
|
1070
|
+
for (const [source, target] of [
|
|
1071
|
+
["created_at", "createdAt"],
|
|
1072
|
+
["updated_at", "updatedAt"],
|
|
1073
|
+
["closed_at", "closedAt"],
|
|
1074
|
+
["html_url", "url"],
|
|
1075
|
+
]) {
|
|
1076
|
+
if (typeof r[source] === "string" || r[source] === null)
|
|
1077
|
+
item[target] = r[source];
|
|
1078
|
+
}
|
|
1079
|
+
if (type === "pr" &&
|
|
1080
|
+
(typeof r.merged_at === "string" || r.merged_at === null)) {
|
|
1081
|
+
item.mergedAt = r.merged_at;
|
|
1082
|
+
}
|
|
1083
|
+
item.labels = Array.isArray(r.labels)
|
|
1084
|
+
? r.labels.filter((label) => typeof label === "string")
|
|
1085
|
+
: [];
|
|
1086
|
+
return item;
|
|
1087
|
+
};
|
|
1088
|
+
const pullRequests = byStream(records, "pull_requests").flatMap((r) => {
|
|
1089
|
+
const item = projectItem(r, "pr");
|
|
1090
|
+
return item ? [item] : [];
|
|
1091
|
+
});
|
|
1092
|
+
const issues = sourceIssues.flatMap((r) => {
|
|
1093
|
+
if (typeof r.is_pull_request !== "boolean" ||
|
|
1094
|
+
r.is_pull_request ||
|
|
1095
|
+
r.user_login !== login) {
|
|
1096
|
+
return [];
|
|
1097
|
+
}
|
|
1098
|
+
const item = projectItem(r, "issue");
|
|
1099
|
+
return item ? [item] : [];
|
|
1100
|
+
});
|
|
1101
|
+
const authoredIssues = sourceIssues.filter((r) => typeof login === "string" &&
|
|
1102
|
+
typeof r.is_pull_request === "boolean" &&
|
|
1103
|
+
!r.is_pull_request &&
|
|
1104
|
+
r.user_login === login);
|
|
1105
|
+
if (pullRequests.length !== byStream(records, "pull_requests").length ||
|
|
1106
|
+
issues.length !== authoredIssues.length) {
|
|
1107
|
+
return {
|
|
1108
|
+
ok: false,
|
|
1109
|
+
error: {
|
|
1110
|
+
kind: "incomplete_scope",
|
|
1111
|
+
scope: "github.history",
|
|
1112
|
+
reason: "Cannot preserve every authored history row: required id or repository_full_name is missing",
|
|
1113
|
+
},
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
return {
|
|
1117
|
+
ok: true,
|
|
1118
|
+
payload: { pullRequests, issues, fetchedAt: projectionTime(options) },
|
|
1119
|
+
};
|
|
1120
|
+
},
|
|
1121
|
+
};
|
|
1122
|
+
const githubProfile = {
|
|
1123
|
+
scope: "github.profile",
|
|
1124
|
+
pdppSource: "https://registry.pdpp.dev/connectors/github",
|
|
1125
|
+
pdppStreams: ["user", "user_stats", "pinned_repositories", "organizations"],
|
|
1126
|
+
legacySchemaPath: "github.profile.json",
|
|
1127
|
+
fieldsRead: {
|
|
1128
|
+
user: [
|
|
1129
|
+
"login",
|
|
1130
|
+
"name",
|
|
1131
|
+
"bio",
|
|
1132
|
+
"company",
|
|
1133
|
+
"location",
|
|
1134
|
+
"blog",
|
|
1135
|
+
"avatar_url",
|
|
1136
|
+
"achievements",
|
|
1137
|
+
],
|
|
1138
|
+
user_stats: ["observed_on", "followers", "following", "public_repos"],
|
|
1139
|
+
pinned_repositories: [
|
|
1140
|
+
"full_name",
|
|
1141
|
+
"html_url",
|
|
1142
|
+
"description",
|
|
1143
|
+
"languages",
|
|
1144
|
+
"stargazers_count",
|
|
1145
|
+
],
|
|
1146
|
+
organizations: ["login", "description", "avatar_url"],
|
|
1147
|
+
},
|
|
1148
|
+
primaryKey: {
|
|
1149
|
+
user: ["id"],
|
|
1150
|
+
user_stats: ["id"],
|
|
1151
|
+
pinned_repositories: ["id"],
|
|
1152
|
+
organizations: ["id"],
|
|
1153
|
+
},
|
|
1154
|
+
lossy: [
|
|
1155
|
+
"profileUrl is derived from the authenticated user's login using GitHub's canonical profile URL pattern",
|
|
1156
|
+
"organization label and URL are omitted because the organizations stream carries neither field; description is not substituted for label",
|
|
1157
|
+
"pinned repository description, language, URL, and stars are omitted when the source value is absent; languages is a list and legacy supports only one language, so the first collected language is used",
|
|
1158
|
+
"contributionsLastYear is omitted: daily contribution data may be an incremental or caller-limited window and cannot establish a complete rolling-year total",
|
|
1159
|
+
"follower, following, and repository counts use the newest observed_on stats record; no value is emitted if no stats record exists",
|
|
1160
|
+
],
|
|
1161
|
+
provenance: [
|
|
1162
|
+
{
|
|
1163
|
+
kind: "oci",
|
|
1164
|
+
ref: "ghcr.io/pdp-connect/connector/github:0.7.2",
|
|
1165
|
+
digest: "sha256:5501bb109cdca02d2acd08aa6891613c7af0bbe0869c30eba2a8070836b0d2f7",
|
|
1166
|
+
path: "declarations/github.collection-profile.json#streams[name=user,user_stats,pinned_repositories,organizations]",
|
|
1167
|
+
},
|
|
1168
|
+
],
|
|
1169
|
+
project(records, options) {
|
|
1170
|
+
const streams = [
|
|
1171
|
+
"user",
|
|
1172
|
+
"user_stats",
|
|
1173
|
+
"pinned_repositories",
|
|
1174
|
+
"organizations",
|
|
1175
|
+
];
|
|
1176
|
+
const guard = requireStreams("github.profile", options, streams);
|
|
1177
|
+
if (guard)
|
|
1178
|
+
return guard;
|
|
1179
|
+
const user = byStream(records, "user")[0];
|
|
1180
|
+
if (!user || typeof user.login !== "string") {
|
|
1181
|
+
return {
|
|
1182
|
+
ok: false,
|
|
1183
|
+
error: { kind: "empty_singleton", scope: "github.profile" },
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
const payload = {
|
|
1187
|
+
username: user.login,
|
|
1188
|
+
profileUrl: `https://github.com/${user.login}`,
|
|
1189
|
+
};
|
|
1190
|
+
for (const [source, target] of [
|
|
1191
|
+
["name", "fullName"],
|
|
1192
|
+
["bio", "bio"],
|
|
1193
|
+
["company", "company"],
|
|
1194
|
+
["location", "location"],
|
|
1195
|
+
["blog", "website"],
|
|
1196
|
+
["avatar_url", "avatarUrl"],
|
|
1197
|
+
]) {
|
|
1198
|
+
if (typeof user[source] === "string")
|
|
1199
|
+
payload[target] = user[source];
|
|
1200
|
+
}
|
|
1201
|
+
const stats = byStream(records, "user_stats")
|
|
1202
|
+
.filter((r) => typeof r.observed_on === "string")
|
|
1203
|
+
.sort((a, b) => String(b.observed_on).localeCompare(String(a.observed_on)))[0];
|
|
1204
|
+
if (stats) {
|
|
1205
|
+
for (const [source, target] of [
|
|
1206
|
+
["followers", "followers"],
|
|
1207
|
+
["following", "following"],
|
|
1208
|
+
["public_repos", "repositoryCount"],
|
|
1209
|
+
]) {
|
|
1210
|
+
if (typeof stats[source] === "number")
|
|
1211
|
+
payload[target] = stats[source];
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
if (Array.isArray(user.achievements)) {
|
|
1215
|
+
payload.achievements = user.achievements.flatMap((value) => {
|
|
1216
|
+
if (!value || typeof value !== "object")
|
|
1217
|
+
return [];
|
|
1218
|
+
const item = value;
|
|
1219
|
+
if (typeof item.name !== "string")
|
|
1220
|
+
return [];
|
|
1221
|
+
return [
|
|
1222
|
+
{
|
|
1223
|
+
name: item.name,
|
|
1224
|
+
...(typeof item.icon_url === "string" || item.icon_url === null
|
|
1225
|
+
? { iconUrl: item.icon_url }
|
|
1226
|
+
: {}),
|
|
1227
|
+
},
|
|
1228
|
+
];
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
payload.pinnedRepositories = byStream(records, "pinned_repositories").flatMap((r) => {
|
|
1232
|
+
if (typeof r.full_name !== "string")
|
|
1233
|
+
return [];
|
|
1234
|
+
const item = { fullName: r.full_name };
|
|
1235
|
+
if (typeof r.html_url === "string" || r.html_url === null)
|
|
1236
|
+
item.url = r.html_url;
|
|
1237
|
+
if (typeof r.description === "string")
|
|
1238
|
+
item.description = r.description;
|
|
1239
|
+
if (Array.isArray(r.languages) && typeof r.languages[0] === "string")
|
|
1240
|
+
item.language = r.languages[0];
|
|
1241
|
+
if (typeof r.stargazers_count === "number")
|
|
1242
|
+
item.stars = r.stargazers_count;
|
|
1243
|
+
return [item];
|
|
1244
|
+
});
|
|
1245
|
+
payload.organizations = byStream(records, "organizations").flatMap((r) => {
|
|
1246
|
+
if (typeof r.login !== "string")
|
|
1247
|
+
return [];
|
|
1248
|
+
return [
|
|
1249
|
+
{
|
|
1250
|
+
login: r.login,
|
|
1251
|
+
...(typeof r.avatar_url === "string" || r.avatar_url === null
|
|
1252
|
+
? { avatarUrl: r.avatar_url }
|
|
1253
|
+
: {}),
|
|
1254
|
+
},
|
|
1255
|
+
];
|
|
1256
|
+
});
|
|
1257
|
+
return { ok: true, payload };
|
|
1258
|
+
},
|
|
1259
|
+
};
|
|
1260
|
+
const githubEvents = {
|
|
1261
|
+
scope: "github.events",
|
|
1262
|
+
pdppSource: "https://registry.pdpp.dev/connectors/github",
|
|
1263
|
+
pdppStreams: ["events"],
|
|
1264
|
+
legacySchemaPath: "github.events.json",
|
|
1265
|
+
fieldsRead: {
|
|
1266
|
+
events: ["id", "type", "created_at", "repository_full_name", "is_public"],
|
|
1267
|
+
},
|
|
1268
|
+
primaryKey: { events: ["id"] },
|
|
1269
|
+
lossy: [
|
|
1270
|
+
"the events API retains only roughly 90 days; the connector emits each collected public event as a legacy event",
|
|
1271
|
+
"legacy action, title, body, URL, branch, commit count, repository URL, and window description are omitted because events records do not declare those values",
|
|
1272
|
+
"fetchedAt is set at projection time because the stream has no collection timestamp",
|
|
1273
|
+
"malformed records missing legacy-required fields are skipped",
|
|
1274
|
+
],
|
|
1275
|
+
provenance: [
|
|
1276
|
+
{
|
|
1277
|
+
kind: "oci",
|
|
1278
|
+
ref: "ghcr.io/pdp-connect/connector/github:0.7.2",
|
|
1279
|
+
digest: "sha256:5501bb109cdca02d2acd08aa6891613c7af0bbe0869c30eba2a8070836b0d2f7",
|
|
1280
|
+
path: "declarations/github.collection-profile.json#streams[name=events]",
|
|
1281
|
+
},
|
|
1282
|
+
],
|
|
1283
|
+
project(records, options) {
|
|
1284
|
+
const guard = requireStreams("github.events", options, ["events"]);
|
|
1285
|
+
if (guard)
|
|
1286
|
+
return guard;
|
|
1287
|
+
const events = byStream(records, "events").flatMap((r) => {
|
|
1288
|
+
if (typeof r.id !== "string" ||
|
|
1289
|
+
typeof r.type !== "string" ||
|
|
1290
|
+
typeof r.created_at !== "string" ||
|
|
1291
|
+
typeof r.repository_full_name !== "string" ||
|
|
1292
|
+
typeof r.is_public !== "boolean")
|
|
1293
|
+
return [];
|
|
1294
|
+
return [
|
|
1295
|
+
{
|
|
1296
|
+
id: r.id,
|
|
1297
|
+
type: r.type,
|
|
1298
|
+
createdAt: r.created_at,
|
|
1299
|
+
repo: r.repository_full_name,
|
|
1300
|
+
isPublic: r.is_public,
|
|
1301
|
+
},
|
|
1302
|
+
];
|
|
1303
|
+
});
|
|
1304
|
+
return {
|
|
1305
|
+
ok: true,
|
|
1306
|
+
payload: { events, fetchedAt: projectionTime(options) },
|
|
1307
|
+
};
|
|
1308
|
+
},
|
|
1309
|
+
};
|
|
1310
|
+
const githubContributions = {
|
|
1311
|
+
scope: "github.contributions",
|
|
1312
|
+
pdppSource: "https://registry.pdpp.dev/connectors/github",
|
|
1313
|
+
pdppStreams: ["contributions"],
|
|
1314
|
+
legacySchemaPath: "github.contributions.json",
|
|
1315
|
+
fieldsRead: { contributions: ["date", "contribution_count"] },
|
|
1316
|
+
primaryKey: { contributions: ["id"] },
|
|
1317
|
+
lossy: [
|
|
1318
|
+
"daily records are projected to legacy days; level is absent from the source and omitted",
|
|
1319
|
+
"totalContributionsLastYear, yearTotals, monthlyTotals, and topDay are omitted because collected contribution records may cover only an incremental or caller-limited interval",
|
|
1320
|
+
"fetchedAt is set at projection time because contribution records have no collection timestamp",
|
|
1321
|
+
],
|
|
1322
|
+
provenance: [
|
|
1323
|
+
{
|
|
1324
|
+
kind: "oci",
|
|
1325
|
+
ref: "ghcr.io/pdp-connect/connector/github:0.7.2",
|
|
1326
|
+
digest: "sha256:5501bb109cdca02d2acd08aa6891613c7af0bbe0869c30eba2a8070836b0d2f7",
|
|
1327
|
+
path: "declarations/github.collection-profile.json#streams[name=contributions]",
|
|
1328
|
+
},
|
|
1329
|
+
],
|
|
1330
|
+
project(records, options) {
|
|
1331
|
+
const guard = requireStreams("github.contributions", options, [
|
|
1332
|
+
"contributions",
|
|
1333
|
+
]);
|
|
1334
|
+
if (guard)
|
|
1335
|
+
return guard;
|
|
1336
|
+
const days = byStream(records, "contributions").flatMap((r) => {
|
|
1337
|
+
if (typeof r.date !== "string" ||
|
|
1338
|
+
typeof r.contribution_count !== "number")
|
|
1339
|
+
return [];
|
|
1340
|
+
return [{ date: r.date, count: r.contribution_count }];
|
|
1341
|
+
});
|
|
1342
|
+
return { ok: true, payload: { days, fetchedAt: projectionTime(options) } };
|
|
1343
|
+
},
|
|
1344
|
+
};
|
|
1345
|
+
// ---------------------------------------------------------------------------
|
|
1346
|
+
// openai (chatgpt)
|
|
1347
|
+
// declarations/chatgpt.collection-profile.json
|
|
1348
|
+
// connector_id: https://registry.pdpp.dev/connectors/chatgpt
|
|
1349
|
+
// ---------------------------------------------------------------------------
|
|
1350
|
+
const chatgptMemories = {
|
|
1351
|
+
scope: "chatgpt.memories",
|
|
1352
|
+
pdppSource: "https://registry.pdpp.dev/connectors/chatgpt",
|
|
1353
|
+
pdppStreams: ["memories"],
|
|
1354
|
+
legacySchemaPath: "chatgpt.memories.json",
|
|
1355
|
+
fieldsRead: { memories: ["id", "content", "created_at", "updated_at"] },
|
|
1356
|
+
primaryKey: { memories: ["id"] },
|
|
1357
|
+
lossy: [
|
|
1358
|
+
"type: legacy always emits it, defaulting to the literal string 'memory' when the upstream field is absent (chatgpt-playwright.js:645, `memory.type || 'memory'`); the memories stream has no `type` field at all, so this binding reproduces that same fallback rather than omitting the field the schema allows.",
|
|
1359
|
+
"created_at: a null value falls back to the projection time (options.now, else the wall clock), matching legacy's own `memory.created_at || new Date().toISOString()` fallback (N-M3).",
|
|
1360
|
+
],
|
|
1361
|
+
provenance: [
|
|
1362
|
+
{
|
|
1363
|
+
kind: "oci",
|
|
1364
|
+
ref: "ghcr.io/pdp-connect/connector/chatgpt:0.1.0",
|
|
1365
|
+
digest: "sha256:811daaf8f2a9a346f1ff5109c118ea404212c8e00790cc5d448e89ad9fa9c4a4",
|
|
1366
|
+
path: "declarations/chatgpt.collection-profile.json#streams[name=memories]",
|
|
1367
|
+
},
|
|
1368
|
+
],
|
|
1369
|
+
project(records, options) {
|
|
1370
|
+
const guard = requireStreams("chatgpt.memories", options, ["memories"]);
|
|
1371
|
+
if (guard) {
|
|
1372
|
+
return guard;
|
|
1373
|
+
}
|
|
1374
|
+
const items = byStream(records, "memories");
|
|
1375
|
+
// created_at is required by the legacy schema; the stream types it
|
|
1376
|
+
// nullable, so a null value falls back to now() the same way the
|
|
1377
|
+
// legacy connector does (chatgpt-playwright.js: `created_at:
|
|
1378
|
+
// memory.created_at || new Date().toISOString()`), rather than
|
|
1379
|
+
// passing through a schema-invalid null.
|
|
1380
|
+
return {
|
|
1381
|
+
ok: true,
|
|
1382
|
+
payload: {
|
|
1383
|
+
memories: items.map((m) => ({
|
|
1384
|
+
id: orEmptyString(m.id),
|
|
1385
|
+
content: orEmptyString(m.content),
|
|
1386
|
+
created_at: typeof m.created_at === "string"
|
|
1387
|
+
? m.created_at
|
|
1388
|
+
: projectionTime(options),
|
|
1389
|
+
type: "memory",
|
|
1390
|
+
...(typeof m.updated_at === "string"
|
|
1391
|
+
? { updated_at: m.updated_at }
|
|
1392
|
+
: {}),
|
|
1393
|
+
})),
|
|
1394
|
+
total: items.length,
|
|
1395
|
+
},
|
|
1396
|
+
};
|
|
1397
|
+
},
|
|
1398
|
+
};
|
|
1399
|
+
// Signed ChatGPT 0.1.1 is the source; Desktop ingest remains disabled until
|
|
1400
|
+
// catalog admission and static_secret host setup land separately.
|
|
1401
|
+
const chatgptConversations = {
|
|
1402
|
+
scope: "chatgpt.conversations",
|
|
1403
|
+
pdppSource: "https://registry.pdpp.dev/connectors/chatgpt",
|
|
1404
|
+
pdppStreams: ["conversations", "messages"],
|
|
1405
|
+
legacySchemaPath: "chatgpt.conversations.json",
|
|
1406
|
+
fieldsRead: {
|
|
1407
|
+
conversations: [
|
|
1408
|
+
"id",
|
|
1409
|
+
"title",
|
|
1410
|
+
"create_time",
|
|
1411
|
+
"update_time",
|
|
1412
|
+
"current_node",
|
|
1413
|
+
"message_count_on_current_branch",
|
|
1414
|
+
],
|
|
1415
|
+
messages: [
|
|
1416
|
+
"id",
|
|
1417
|
+
"conversation_id",
|
|
1418
|
+
"parent_id",
|
|
1419
|
+
"role",
|
|
1420
|
+
"content",
|
|
1421
|
+
"content_type",
|
|
1422
|
+
"model_slug",
|
|
1423
|
+
"create_time",
|
|
1424
|
+
"on_current_branch",
|
|
1425
|
+
],
|
|
1426
|
+
},
|
|
1427
|
+
primaryKey: { conversations: ["id"], messages: ["id"] },
|
|
1428
|
+
lossy: [
|
|
1429
|
+
"Conversation create/update times pass through as null when the source lacks them, matching legacy's listed?.create_time ?? fetched.create_time ?? null fallback.",
|
|
1430
|
+
"fetched_at is the projection time (options.now, else the wall clock), matching legacy's per-run toConversationRecord timestamp.",
|
|
1431
|
+
"A conversation without complete current-branch message evidence, a valid id, or valid timestamps is dropped and counted in a records_dropped diagnostic; nested full threads are not invented. Messages whose conversation is absent are dropped and counted the same way.",
|
|
1432
|
+
"Only user/assistant text or multimodal_text messages with nonempty string content are retained, matching the legacy connector's walkMessages filter; message_count is the retained count.",
|
|
1433
|
+
"A missing title becomes 'Untitled', matching the legacy connector's toConversationRecord fallback; model_slug is renamed to model.",
|
|
1434
|
+
],
|
|
1435
|
+
provenance: [
|
|
1436
|
+
{
|
|
1437
|
+
kind: "oci",
|
|
1438
|
+
ref: "ghcr.io/pdp-connect/connector/chatgpt:0.2.0",
|
|
1439
|
+
digest: "sha256:01777566b5163ca62bdb2bd21c316d09dd88f42c30a6d943ca101cfb635494ea",
|
|
1440
|
+
path: "declarations/chatgpt-0.2.0.collection-profile.json#streams[name=conversations,messages]",
|
|
1441
|
+
},
|
|
1442
|
+
],
|
|
1443
|
+
project(records, options) {
|
|
1444
|
+
const scope = "chatgpt.conversations";
|
|
1445
|
+
const guard = requireStreams(scope, options, ["conversations"]);
|
|
1446
|
+
if (guard)
|
|
1447
|
+
return guard;
|
|
1448
|
+
// Without a messages stream at all, a read-time caller still serves the
|
|
1449
|
+
// conversation list (titles and times) with empty threads, and says so.
|
|
1450
|
+
const messagesMissing = !options.fetchedStreams.includes("messages");
|
|
1451
|
+
if (messagesMissing && !options.allowMissingJoinStreams)
|
|
1452
|
+
return missingStream(scope, "messages");
|
|
1453
|
+
const fetchedAt = projectionTime(options);
|
|
1454
|
+
// One malformed conversation must not make the whole scope unreadable:
|
|
1455
|
+
// it is left out and counted, by reason.
|
|
1456
|
+
const dropped = new Map();
|
|
1457
|
+
const drop = (reason) => dropped.set(reason, (dropped.get(reason) ?? 0) + 1);
|
|
1458
|
+
let malformedMessages = 0;
|
|
1459
|
+
const messagesByConversation = new Map();
|
|
1460
|
+
for (const message of byStream(records, "messages")) {
|
|
1461
|
+
if (!isObjectRow(message)) {
|
|
1462
|
+
malformedMessages += 1;
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1465
|
+
if (typeof message.conversation_id !== "string")
|
|
1466
|
+
continue;
|
|
1467
|
+
const group = messagesByConversation.get(message.conversation_id) ?? [];
|
|
1468
|
+
group.push(message);
|
|
1469
|
+
messagesByConversation.set(message.conversation_id, group);
|
|
1470
|
+
}
|
|
1471
|
+
const sourceConversations = [];
|
|
1472
|
+
for (const row of byStream(records, "conversations")) {
|
|
1473
|
+
if (isObjectRow(row))
|
|
1474
|
+
sourceConversations.push(row);
|
|
1475
|
+
else
|
|
1476
|
+
drop("Conversation record is not an object");
|
|
1477
|
+
}
|
|
1478
|
+
const sourceIds = new Set(sourceConversations.map((c) => c.id));
|
|
1479
|
+
let orphanMessages = 0;
|
|
1480
|
+
for (const [id, group] of messagesByConversation) {
|
|
1481
|
+
if (!sourceIds.has(id))
|
|
1482
|
+
orphanMessages += group.length;
|
|
1483
|
+
}
|
|
1484
|
+
const conversations = [];
|
|
1485
|
+
for (const conversation of sourceConversations) {
|
|
1486
|
+
const { id, create_time, update_time, current_node, message_count_on_current_branch, } = conversation;
|
|
1487
|
+
if (!isOptionalString(create_time) || !isOptionalString(update_time)) {
|
|
1488
|
+
drop("Conversation timestamps must be strings, null, or absent");
|
|
1489
|
+
continue;
|
|
1490
|
+
}
|
|
1491
|
+
if (!isNonEmptyString(id)) {
|
|
1492
|
+
drop("Conversation lacks an id");
|
|
1493
|
+
continue;
|
|
1494
|
+
}
|
|
1495
|
+
const title = typeof conversation.title === "string" && conversation.title.length > 0
|
|
1496
|
+
? conversation.title
|
|
1497
|
+
: "Untitled";
|
|
1498
|
+
if (messagesMissing) {
|
|
1499
|
+
conversations.push({
|
|
1500
|
+
id,
|
|
1501
|
+
title,
|
|
1502
|
+
create_time: create_time ?? null,
|
|
1503
|
+
update_time: update_time ?? null,
|
|
1504
|
+
message_count: 0,
|
|
1505
|
+
messages: [],
|
|
1506
|
+
fetched_at: fetchedAt,
|
|
1507
|
+
});
|
|
1508
|
+
continue;
|
|
1509
|
+
}
|
|
1510
|
+
if (typeof message_count_on_current_branch !== "number" ||
|
|
1511
|
+
!Number.isInteger(message_count_on_current_branch) ||
|
|
1512
|
+
message_count_on_current_branch < 0) {
|
|
1513
|
+
drop("Conversation lacks a current-branch message count");
|
|
1514
|
+
continue;
|
|
1515
|
+
}
|
|
1516
|
+
const branch = (messagesByConversation.get(id) ?? []).filter((m) => m.on_current_branch === true);
|
|
1517
|
+
if (branch.length !== message_count_on_current_branch) {
|
|
1518
|
+
drop("Current branch message count does not match conversation");
|
|
1519
|
+
continue;
|
|
1520
|
+
}
|
|
1521
|
+
const byId = new Map(branch.filter((m) => typeof m.id === "string").map((m) => [m.id, m]));
|
|
1522
|
+
const ordered = [];
|
|
1523
|
+
const seen = new Set();
|
|
1524
|
+
let node = typeof current_node === "string" ? current_node : null;
|
|
1525
|
+
while (node && byId.has(node) && !seen.has(node)) {
|
|
1526
|
+
seen.add(node);
|
|
1527
|
+
const message = byId.get(node);
|
|
1528
|
+
ordered.push(message);
|
|
1529
|
+
node = typeof message.parent_id === "string" ? message.parent_id : null;
|
|
1530
|
+
}
|
|
1531
|
+
if (ordered.length !== branch.length) {
|
|
1532
|
+
drop("Current branch message chain is incomplete");
|
|
1533
|
+
continue;
|
|
1534
|
+
}
|
|
1535
|
+
ordered.reverse();
|
|
1536
|
+
const messages = ordered
|
|
1537
|
+
.filter((m) => (m.role === "user" || m.role === "assistant") &&
|
|
1538
|
+
(m.content_type === "text" ||
|
|
1539
|
+
m.content_type === "multimodal_text") &&
|
|
1540
|
+
typeof m.content === "string" &&
|
|
1541
|
+
m.content.length > 0)
|
|
1542
|
+
.map((m) => ({
|
|
1543
|
+
id: m.id,
|
|
1544
|
+
role: m.role,
|
|
1545
|
+
content: m.content,
|
|
1546
|
+
content_type: m.content_type,
|
|
1547
|
+
create_time: typeof m.create_time === "string" ? m.create_time : null,
|
|
1548
|
+
model: typeof m.model_slug === "string" ? m.model_slug : null,
|
|
1549
|
+
}));
|
|
1550
|
+
conversations.push({
|
|
1551
|
+
id,
|
|
1552
|
+
title,
|
|
1553
|
+
create_time: create_time ?? null,
|
|
1554
|
+
update_time: update_time ?? null,
|
|
1555
|
+
message_count: messages.length,
|
|
1556
|
+
messages,
|
|
1557
|
+
fetched_at: fetchedAt,
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
const diagnostics = [];
|
|
1561
|
+
if (dropped.size > 0) {
|
|
1562
|
+
diagnostics.push({
|
|
1563
|
+
kind: "records_dropped",
|
|
1564
|
+
scope,
|
|
1565
|
+
stream: "conversations",
|
|
1566
|
+
count: [...dropped.values()].reduce((sum, n) => sum + n, 0),
|
|
1567
|
+
reasons: [...dropped.keys()],
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
if (orphanMessages + malformedMessages > 0) {
|
|
1571
|
+
diagnostics.push({
|
|
1572
|
+
kind: "records_dropped",
|
|
1573
|
+
scope,
|
|
1574
|
+
stream: "messages",
|
|
1575
|
+
count: orphanMessages + malformedMessages,
|
|
1576
|
+
reasons: [
|
|
1577
|
+
...(orphanMessages > 0
|
|
1578
|
+
? ["Messages have no matching conversation"]
|
|
1579
|
+
: []),
|
|
1580
|
+
...(malformedMessages > 0 ? ["Message record is not an object"] : []),
|
|
1581
|
+
],
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
if (messagesMissing) {
|
|
1585
|
+
diagnostics.push({ kind: "stream_missing", scope, stream: "messages" });
|
|
1586
|
+
}
|
|
1587
|
+
// Legacy delivered the conversation list in ChatGPT's own order: most
|
|
1588
|
+
// recently updated first. A stable order keeps that order, not id order.
|
|
1589
|
+
if (options.orderByPrimaryKey)
|
|
1590
|
+
conversations.sort(newestUpdateFirst);
|
|
1591
|
+
return {
|
|
1592
|
+
ok: true,
|
|
1593
|
+
payload: { conversations, total: conversations.length },
|
|
1594
|
+
...(diagnostics.length > 0 ? { diagnostics } : {}),
|
|
1595
|
+
};
|
|
1596
|
+
},
|
|
1597
|
+
};
|
|
1598
|
+
/**
|
|
1599
|
+
* Newest `update_time` first; a missing or unparseable time sorts last.
|
|
1600
|
+
* Ties fall back to the id, so the order never depends on input order.
|
|
1601
|
+
*/
|
|
1602
|
+
function newestUpdateFirst(a, b) {
|
|
1603
|
+
const time = (row) => {
|
|
1604
|
+
const parsed = typeof row.update_time === "string" ? Date.parse(row.update_time) : NaN;
|
|
1605
|
+
return Number.isNaN(parsed) ? -Infinity : parsed;
|
|
1606
|
+
};
|
|
1607
|
+
const byTime = time(b) - time(a);
|
|
1608
|
+
if (byTime !== 0 && !Number.isNaN(byTime))
|
|
1609
|
+
return byTime;
|
|
1610
|
+
return String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0;
|
|
1611
|
+
}
|
|
1612
|
+
// This is the merged 0.1.3 source declaration. Admission remains a separate
|
|
1613
|
+
// decision; this local manifest hash is not the signed OCI artifact digest.
|
|
1614
|
+
const anthropicProjectionSource = "https://registry.pdpp.dev/connectors/anthropic";
|
|
1615
|
+
const anthropicProjectionProvenance = [
|
|
1616
|
+
{
|
|
1617
|
+
kind: "collection-profile",
|
|
1618
|
+
ref: "PDP-Connect/data-connectors ae44adf769 (#160)",
|
|
1619
|
+
digest: "sha256:1a156e586c81a95cb3bb1ad9c09c639990a22212074cba7da62268c326593372",
|
|
1620
|
+
path: "declarations/anthropic-0.1.3.collection-profile.json#streams[name=account_profile,conversations,messages,projects,project_documents]",
|
|
1621
|
+
},
|
|
1622
|
+
];
|
|
1623
|
+
function claudeMetadata(scope, records) {
|
|
1624
|
+
const profiles = byStream(records, "account_profile");
|
|
1625
|
+
if (profiles.length !== 1) {
|
|
1626
|
+
return {
|
|
1627
|
+
ok: false,
|
|
1628
|
+
error: {
|
|
1629
|
+
kind: "incomplete_scope",
|
|
1630
|
+
scope,
|
|
1631
|
+
reason: "A single verified account_profile record is required",
|
|
1632
|
+
},
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
const profile = profiles[0];
|
|
1636
|
+
if (typeof profile.id !== "string" ||
|
|
1637
|
+
!profile.id.trim() ||
|
|
1638
|
+
typeof profile.organization_id !== "string" ||
|
|
1639
|
+
!profile.organization_id.trim() ||
|
|
1640
|
+
profile.id !== profile.organization_id ||
|
|
1641
|
+
!(typeof profile.full_name === "string" || profile.full_name === null) ||
|
|
1642
|
+
!(typeof profile.plan === "string" || profile.plan === null) ||
|
|
1643
|
+
!["browser_menu", "users_json", "none"].includes(profile.name_source) ||
|
|
1644
|
+
!["valid", "absent", "malformed", "ambiguous", "mismatch"].includes(profile.metadata_status)) {
|
|
1645
|
+
return {
|
|
1646
|
+
ok: false,
|
|
1647
|
+
error: {
|
|
1648
|
+
kind: "invalid_value",
|
|
1649
|
+
scope,
|
|
1650
|
+
reason: "account_profile does not match its declared verified contract",
|
|
1651
|
+
},
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
return {
|
|
1655
|
+
ok: true,
|
|
1656
|
+
profile: {
|
|
1657
|
+
name: profile.full_name,
|
|
1658
|
+
plan: profile.plan,
|
|
1659
|
+
},
|
|
1660
|
+
organizationId: profile.organization_id,
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
function claudeConversationsBinding() {
|
|
1664
|
+
const scope = "claude.conversations";
|
|
1665
|
+
return {
|
|
1666
|
+
scope,
|
|
1667
|
+
pdppSource: anthropicProjectionSource,
|
|
1668
|
+
pdppStreams: ["account_profile", "conversations", "messages"],
|
|
1669
|
+
legacySchemaPath: "claude.conversations.json",
|
|
1670
|
+
fieldsRead: {
|
|
1671
|
+
account_profile: [
|
|
1672
|
+
"id",
|
|
1673
|
+
"organization_id",
|
|
1674
|
+
"full_name",
|
|
1675
|
+
"plan",
|
|
1676
|
+
"name_source",
|
|
1677
|
+
"metadata_status",
|
|
1678
|
+
],
|
|
1679
|
+
conversations: [
|
|
1680
|
+
"id",
|
|
1681
|
+
"title",
|
|
1682
|
+
"create_time",
|
|
1683
|
+
"update_time",
|
|
1684
|
+
"project_id",
|
|
1685
|
+
"message_count",
|
|
1686
|
+
"is_starred",
|
|
1687
|
+
],
|
|
1688
|
+
messages: [
|
|
1689
|
+
"id",
|
|
1690
|
+
"conversation_id",
|
|
1691
|
+
"role",
|
|
1692
|
+
"parent_id",
|
|
1693
|
+
"content",
|
|
1694
|
+
"create_time",
|
|
1695
|
+
"update_time",
|
|
1696
|
+
"attachments",
|
|
1697
|
+
],
|
|
1698
|
+
},
|
|
1699
|
+
primaryKey: {
|
|
1700
|
+
account_profile: ["id"],
|
|
1701
|
+
conversations: ["id"],
|
|
1702
|
+
messages: ["id"],
|
|
1703
|
+
},
|
|
1704
|
+
lossy: [
|
|
1705
|
+
"href is reconstructed from the retained Claude route convention; rawContent and unavailable fetchError are omitted.",
|
|
1706
|
+
],
|
|
1707
|
+
provenance: anthropicProjectionProvenance,
|
|
1708
|
+
project(records, options) {
|
|
1709
|
+
const guard = requireStreams(scope, options, [
|
|
1710
|
+
"account_profile",
|
|
1711
|
+
"conversations",
|
|
1712
|
+
"messages",
|
|
1713
|
+
]);
|
|
1714
|
+
if (guard)
|
|
1715
|
+
return guard;
|
|
1716
|
+
const metadata = claudeMetadata(scope, records);
|
|
1717
|
+
if (!metadata.ok)
|
|
1718
|
+
return metadata;
|
|
1719
|
+
const conversations = byStream(records, "conversations");
|
|
1720
|
+
const messagesByConversation = new Map();
|
|
1721
|
+
for (const message of byStream(records, "messages")) {
|
|
1722
|
+
if (typeof message.conversation_id !== "string")
|
|
1723
|
+
return {
|
|
1724
|
+
ok: false,
|
|
1725
|
+
error: {
|
|
1726
|
+
kind: "invalid_value",
|
|
1727
|
+
scope,
|
|
1728
|
+
reason: "Message has no conversation id",
|
|
1729
|
+
},
|
|
1730
|
+
};
|
|
1731
|
+
const rows = messagesByConversation.get(message.conversation_id) ?? [];
|
|
1732
|
+
rows.push(message);
|
|
1733
|
+
messagesByConversation.set(message.conversation_id, rows);
|
|
1734
|
+
}
|
|
1735
|
+
const ids = new Set(conversations.map((conversation) => conversation.id));
|
|
1736
|
+
if ([...messagesByConversation.keys()].some((id) => !ids.has(id)))
|
|
1737
|
+
return {
|
|
1738
|
+
ok: false,
|
|
1739
|
+
error: {
|
|
1740
|
+
kind: "invalid_value",
|
|
1741
|
+
scope,
|
|
1742
|
+
reason: "Messages have no matching conversation",
|
|
1743
|
+
},
|
|
1744
|
+
};
|
|
1745
|
+
const projected = [];
|
|
1746
|
+
for (const conversation of conversations) {
|
|
1747
|
+
if (typeof conversation.id !== "string" ||
|
|
1748
|
+
!conversation.id.trim() ||
|
|
1749
|
+
!(typeof conversation.title === "string" ||
|
|
1750
|
+
conversation.title === null))
|
|
1751
|
+
return {
|
|
1752
|
+
ok: false,
|
|
1753
|
+
error: {
|
|
1754
|
+
kind: "invalid_value",
|
|
1755
|
+
scope,
|
|
1756
|
+
reason: "Conversation id or title has invalid source shape",
|
|
1757
|
+
},
|
|
1758
|
+
};
|
|
1759
|
+
const sourceMessages = messagesByConversation.get(conversation.id) ?? [];
|
|
1760
|
+
if (sourceMessages.some((message) => typeof message.id !== "string" ||
|
|
1761
|
+
!(typeof message.content === "string" || message.content === null) ||
|
|
1762
|
+
!(Array.isArray(message.attachments) ||
|
|
1763
|
+
message.attachments === null)))
|
|
1764
|
+
return {
|
|
1765
|
+
ok: false,
|
|
1766
|
+
error: {
|
|
1767
|
+
kind: "invalid_value",
|
|
1768
|
+
scope,
|
|
1769
|
+
reason: "Message does not match declared source shape",
|
|
1770
|
+
},
|
|
1771
|
+
};
|
|
1772
|
+
const messages = [...sourceMessages]
|
|
1773
|
+
.sort((a, b) => (Date.parse(a.create_time || "") || 0) -
|
|
1774
|
+
(Date.parse(b.create_time || "") || 0))
|
|
1775
|
+
.map((message) => {
|
|
1776
|
+
return {
|
|
1777
|
+
id: message.id,
|
|
1778
|
+
sender: message.role ?? null,
|
|
1779
|
+
parentId: message.parent_id ?? null,
|
|
1780
|
+
createdAt: message.create_time ?? null,
|
|
1781
|
+
updatedAt: message.update_time ?? null,
|
|
1782
|
+
content: message.content ?? "",
|
|
1783
|
+
attachments: message.attachments ?? [],
|
|
1784
|
+
};
|
|
1785
|
+
});
|
|
1786
|
+
const messageCount = typeof conversation.message_count === "number"
|
|
1787
|
+
? conversation.message_count
|
|
1788
|
+
: messages.length;
|
|
1789
|
+
if (messageCount !== messages.length)
|
|
1790
|
+
return {
|
|
1791
|
+
ok: false,
|
|
1792
|
+
error: {
|
|
1793
|
+
kind: "incomplete_scope",
|
|
1794
|
+
scope,
|
|
1795
|
+
reason: "Fetched messages do not match conversation message_count",
|
|
1796
|
+
},
|
|
1797
|
+
};
|
|
1798
|
+
projected.push({
|
|
1799
|
+
id: conversation.id,
|
|
1800
|
+
title: conversation.title || "Untitled",
|
|
1801
|
+
href: `/chat/${conversation.id}`,
|
|
1802
|
+
createdAt: conversation.create_time ?? null,
|
|
1803
|
+
updatedAt: conversation.update_time ?? null,
|
|
1804
|
+
starred: conversation.is_starred ?? null,
|
|
1805
|
+
projectId: conversation.project_id ?? null,
|
|
1806
|
+
messageCount,
|
|
1807
|
+
messages,
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1810
|
+
return {
|
|
1811
|
+
ok: true,
|
|
1812
|
+
payload: {
|
|
1813
|
+
profile: metadata.profile,
|
|
1814
|
+
organizationId: metadata.organizationId,
|
|
1815
|
+
conversations: projected,
|
|
1816
|
+
total: projected.length,
|
|
1817
|
+
messageTotal: projected.reduce((total, conversation) => total + conversation.messageCount, 0),
|
|
1818
|
+
source: "official-export",
|
|
1819
|
+
},
|
|
1820
|
+
};
|
|
1821
|
+
},
|
|
1822
|
+
};
|
|
1823
|
+
}
|
|
1824
|
+
const claudeConversations = claudeConversationsBinding();
|
|
1825
|
+
const claudeProjects = {
|
|
1826
|
+
scope: "claude.projects",
|
|
1827
|
+
pdppSource: anthropicProjectionSource,
|
|
1828
|
+
pdppStreams: ["account_profile", "projects", "project_documents"],
|
|
1829
|
+
legacySchemaPath: "claude.projects.json",
|
|
1830
|
+
fieldsRead: {
|
|
1831
|
+
account_profile: [
|
|
1832
|
+
"id",
|
|
1833
|
+
"organization_id",
|
|
1834
|
+
"full_name",
|
|
1835
|
+
"plan",
|
|
1836
|
+
"name_source",
|
|
1837
|
+
"metadata_status",
|
|
1838
|
+
],
|
|
1839
|
+
projects: [
|
|
1840
|
+
"id",
|
|
1841
|
+
"name",
|
|
1842
|
+
"description",
|
|
1843
|
+
"create_time",
|
|
1844
|
+
"update_time",
|
|
1845
|
+
"is_archived",
|
|
1846
|
+
"prompt_template",
|
|
1847
|
+
"creator",
|
|
1848
|
+
"is_private",
|
|
1849
|
+
"is_starter_project",
|
|
1850
|
+
"archived_at",
|
|
1851
|
+
"raw_docs",
|
|
1852
|
+
],
|
|
1853
|
+
project_documents: [
|
|
1854
|
+
"id",
|
|
1855
|
+
"project_id",
|
|
1856
|
+
"filename",
|
|
1857
|
+
"content",
|
|
1858
|
+
"create_time",
|
|
1859
|
+
"update_time",
|
|
1860
|
+
],
|
|
1861
|
+
},
|
|
1862
|
+
primaryKey: {
|
|
1863
|
+
account_profile: ["id"],
|
|
1864
|
+
projects: ["id"],
|
|
1865
|
+
project_documents: ["id"],
|
|
1866
|
+
},
|
|
1867
|
+
lossy: [
|
|
1868
|
+
"detail carries only declared project and raw document fields. Other raw-only keys and documents dropped by source parsing cannot be recovered; href and label follow the legacy connector's route and label rules.",
|
|
1869
|
+
],
|
|
1870
|
+
provenance: anthropicProjectionProvenance,
|
|
1871
|
+
project(records, options) {
|
|
1872
|
+
const scope = "claude.projects";
|
|
1873
|
+
const guard = requireStreams(scope, options, [
|
|
1874
|
+
"account_profile",
|
|
1875
|
+
"projects",
|
|
1876
|
+
"project_documents",
|
|
1877
|
+
]);
|
|
1878
|
+
if (guard)
|
|
1879
|
+
return guard;
|
|
1880
|
+
const metadata = claudeMetadata(scope, records);
|
|
1881
|
+
if (!metadata.ok)
|
|
1882
|
+
return metadata;
|
|
1883
|
+
const documents = byStream(records, "project_documents");
|
|
1884
|
+
const projects = byStream(records, "projects");
|
|
1885
|
+
const projectIds = new Set(projects.map((project) => project.id));
|
|
1886
|
+
if (documents.some((document) => typeof document.project_id !== "string" ||
|
|
1887
|
+
!projectIds.has(document.project_id)))
|
|
1888
|
+
return {
|
|
1889
|
+
ok: false,
|
|
1890
|
+
error: {
|
|
1891
|
+
kind: "invalid_value",
|
|
1892
|
+
scope,
|
|
1893
|
+
reason: "Project document has no matching project",
|
|
1894
|
+
},
|
|
1895
|
+
};
|
|
1896
|
+
const projected = [];
|
|
1897
|
+
for (const project of projects) {
|
|
1898
|
+
if (typeof project.id !== "string" ||
|
|
1899
|
+
!project.id.trim() ||
|
|
1900
|
+
typeof project.name !== "string")
|
|
1901
|
+
return {
|
|
1902
|
+
ok: false,
|
|
1903
|
+
error: {
|
|
1904
|
+
kind: "invalid_value",
|
|
1905
|
+
scope,
|
|
1906
|
+
reason: "Project id and name are required by the legacy payload",
|
|
1907
|
+
},
|
|
1908
|
+
};
|
|
1909
|
+
const docs = documents
|
|
1910
|
+
.filter((document) => document.project_id === project.id)
|
|
1911
|
+
.map((document) => ({
|
|
1912
|
+
uuid: document.id,
|
|
1913
|
+
filename: document.filename ?? null,
|
|
1914
|
+
content: document.content ?? null,
|
|
1915
|
+
created_at: document.create_time ?? null,
|
|
1916
|
+
updated_at: document.update_time ?? null,
|
|
1917
|
+
}));
|
|
1918
|
+
const detail = {
|
|
1919
|
+
uuid: project.id,
|
|
1920
|
+
name: project.name,
|
|
1921
|
+
docs,
|
|
1922
|
+
};
|
|
1923
|
+
for (const [sourceKey, legacyKey] of [
|
|
1924
|
+
["description", "description"],
|
|
1925
|
+
["create_time", "created_at"],
|
|
1926
|
+
["update_time", "updated_at"],
|
|
1927
|
+
["prompt_template", "prompt_template"],
|
|
1928
|
+
]) {
|
|
1929
|
+
if (project[sourceKey] !== undefined)
|
|
1930
|
+
detail[legacyKey] = project[sourceKey];
|
|
1931
|
+
}
|
|
1932
|
+
for (const key of ["is_private", "is_starter_project"]) {
|
|
1933
|
+
const value = project[key];
|
|
1934
|
+
if (value !== undefined) {
|
|
1935
|
+
if (value !== null && typeof value !== "boolean")
|
|
1936
|
+
return {
|
|
1937
|
+
ok: false,
|
|
1938
|
+
error: {
|
|
1939
|
+
kind: "invalid_value",
|
|
1940
|
+
scope,
|
|
1941
|
+
reason: `${key} has invalid source shape`,
|
|
1942
|
+
},
|
|
1943
|
+
};
|
|
1944
|
+
detail[key] = value;
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
if (project.archived_at !== undefined) {
|
|
1948
|
+
if (project.archived_at !== null &&
|
|
1949
|
+
typeof project.archived_at !== "string")
|
|
1950
|
+
return {
|
|
1951
|
+
ok: false,
|
|
1952
|
+
error: {
|
|
1953
|
+
kind: "invalid_value",
|
|
1954
|
+
scope,
|
|
1955
|
+
reason: "archived_at has invalid source shape",
|
|
1956
|
+
},
|
|
1957
|
+
};
|
|
1958
|
+
detail.archived_at = project.archived_at;
|
|
1959
|
+
}
|
|
1960
|
+
if (project.creator !== undefined) {
|
|
1961
|
+
const creator = project.creator;
|
|
1962
|
+
if (creator !== null &&
|
|
1963
|
+
(typeof creator !== "object" || Array.isArray(creator)))
|
|
1964
|
+
return {
|
|
1965
|
+
ok: false,
|
|
1966
|
+
error: {
|
|
1967
|
+
kind: "invalid_value",
|
|
1968
|
+
scope,
|
|
1969
|
+
reason: "creator has invalid source shape",
|
|
1970
|
+
},
|
|
1971
|
+
};
|
|
1972
|
+
if (creator === null)
|
|
1973
|
+
detail.creator = null;
|
|
1974
|
+
else {
|
|
1975
|
+
const value = creator;
|
|
1976
|
+
if (["uuid", "full_name"].some((key) => value[key] !== undefined && typeof value[key] !== "string"))
|
|
1977
|
+
return {
|
|
1978
|
+
ok: false,
|
|
1979
|
+
error: {
|
|
1980
|
+
kind: "invalid_value",
|
|
1981
|
+
scope,
|
|
1982
|
+
reason: "creator fields have invalid source shape",
|
|
1983
|
+
},
|
|
1984
|
+
};
|
|
1985
|
+
detail.creator = Object.fromEntries(["uuid", "full_name"]
|
|
1986
|
+
.filter((key) => value[key] !== undefined)
|
|
1987
|
+
.map((key) => [key, value[key]]));
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
if (project.raw_docs !== undefined) {
|
|
1991
|
+
if (!Array.isArray(project.raw_docs))
|
|
1992
|
+
return {
|
|
1993
|
+
ok: false,
|
|
1994
|
+
error: {
|
|
1995
|
+
kind: "invalid_value",
|
|
1996
|
+
scope,
|
|
1997
|
+
reason: "raw_docs has invalid source shape",
|
|
1998
|
+
},
|
|
1999
|
+
};
|
|
2000
|
+
const rawKeys = [
|
|
2001
|
+
"uuid",
|
|
2002
|
+
"filename",
|
|
2003
|
+
"content",
|
|
2004
|
+
"created_at",
|
|
2005
|
+
"updated_at",
|
|
2006
|
+
];
|
|
2007
|
+
const rawDocs = [];
|
|
2008
|
+
for (const raw of project.raw_docs) {
|
|
2009
|
+
if (!raw ||
|
|
2010
|
+
typeof raw !== "object" ||
|
|
2011
|
+
Array.isArray(raw) ||
|
|
2012
|
+
rawKeys.some((key) => raw[key] !== undefined &&
|
|
2013
|
+
typeof raw[key] !== "string"))
|
|
2014
|
+
return {
|
|
2015
|
+
ok: false,
|
|
2016
|
+
error: {
|
|
2017
|
+
kind: "invalid_value",
|
|
2018
|
+
scope,
|
|
2019
|
+
reason: "raw document has invalid source shape",
|
|
2020
|
+
},
|
|
2021
|
+
};
|
|
2022
|
+
const value = raw;
|
|
2023
|
+
rawDocs.push(Object.fromEntries(rawKeys
|
|
2024
|
+
.filter((key) => value[key] !== undefined)
|
|
2025
|
+
.map((key) => [key, value[key]])));
|
|
2026
|
+
}
|
|
2027
|
+
detail.raw_docs = rawDocs;
|
|
2028
|
+
}
|
|
2029
|
+
projected.push({
|
|
2030
|
+
id: project.id,
|
|
2031
|
+
title: project.name,
|
|
2032
|
+
href: `/project/${project.id}`,
|
|
2033
|
+
label: `Project, ${project.name}`,
|
|
2034
|
+
createdAt: project.create_time ?? null,
|
|
2035
|
+
updatedAt: project.update_time ?? null,
|
|
2036
|
+
// Legacy emits `Boolean(p?.archived_at)` (claude-export-playwright.js
|
|
2037
|
+
// normalizeProject), so the value is always a boolean, never null.
|
|
2038
|
+
archived: project.is_archived === true || Boolean(project.archived_at),
|
|
2039
|
+
detail,
|
|
2040
|
+
});
|
|
2041
|
+
}
|
|
2042
|
+
return {
|
|
2043
|
+
ok: true,
|
|
2044
|
+
payload: {
|
|
2045
|
+
profile: metadata.profile,
|
|
2046
|
+
organizationId: metadata.organizationId,
|
|
2047
|
+
projects: projected,
|
|
2048
|
+
total: projected.length,
|
|
2049
|
+
source: "official-export",
|
|
2050
|
+
},
|
|
2051
|
+
};
|
|
2052
|
+
},
|
|
2053
|
+
};
|
|
2054
|
+
// ---------------------------------------------------------------------------
|
|
2055
|
+
// linkedin
|
|
2056
|
+
// declarations/linkedin.collection-profile.json
|
|
2057
|
+
// connector_id: https://registry.pdpp.dev/connectors/linkedin
|
|
2058
|
+
// ---------------------------------------------------------------------------
|
|
2059
|
+
const linkedinProfile = {
|
|
2060
|
+
scope: "linkedin.profile",
|
|
2061
|
+
pdppSource: "https://registry.pdpp.dev/connectors/linkedin",
|
|
2062
|
+
pdppStreams: ["profile"],
|
|
2063
|
+
legacySchemaPath: "linkedin.profile.json",
|
|
2064
|
+
fieldsRead: {
|
|
2065
|
+
profile: [
|
|
2066
|
+
"public_url",
|
|
2067
|
+
"full_name",
|
|
2068
|
+
"headline",
|
|
2069
|
+
"location",
|
|
2070
|
+
"connection_count",
|
|
2071
|
+
"profile_picture_url",
|
|
2072
|
+
"summary",
|
|
2073
|
+
],
|
|
2074
|
+
},
|
|
2075
|
+
primaryKey: { profile: ["id"] },
|
|
2076
|
+
lossy: [
|
|
2077
|
+
"public_url, full_name, headline, location, profile_picture_url, summary: null/missing values become '' to satisfy the required legacy string fields",
|
|
2078
|
+
"connection_count: absent or non-finite/non-number values become '0'; the legacy connector could replace its initial '0' with a fetched connection count, which this projection does not derive",
|
|
2079
|
+
],
|
|
2080
|
+
provenance: [
|
|
2081
|
+
{
|
|
2082
|
+
kind: "oci",
|
|
2083
|
+
ref: "ghcr.io/pdp-connect/connector/linkedin:0.3.1",
|
|
2084
|
+
digest: "sha256:7544a6c015bf4b730afdd97a7521608d10d1c077950af5e9f3adc9405609fd68",
|
|
2085
|
+
path: "declarations/linkedin.collection-profile.json#streams[name=profile]",
|
|
2086
|
+
},
|
|
2087
|
+
],
|
|
2088
|
+
project(records, options) {
|
|
2089
|
+
const guard = requireStreams("linkedin.profile", options, ["profile"]);
|
|
2090
|
+
if (guard) {
|
|
2091
|
+
return guard;
|
|
2092
|
+
}
|
|
2093
|
+
const [profile] = byStream(records, "profile");
|
|
2094
|
+
if (!profile) {
|
|
2095
|
+
return {
|
|
2096
|
+
ok: false,
|
|
2097
|
+
error: { kind: "empty_singleton", scope: "linkedin.profile" },
|
|
2098
|
+
};
|
|
2099
|
+
}
|
|
2100
|
+
return {
|
|
2101
|
+
ok: true,
|
|
2102
|
+
payload: {
|
|
2103
|
+
profileUrl: orEmptyString(profile.public_url),
|
|
2104
|
+
fullName: orEmptyString(profile.full_name),
|
|
2105
|
+
headline: orEmptyString(profile.headline),
|
|
2106
|
+
location: orEmptyString(profile.location),
|
|
2107
|
+
connections: typeof profile.connection_count === "number" &&
|
|
2108
|
+
Number.isFinite(profile.connection_count)
|
|
2109
|
+
? String(profile.connection_count)
|
|
2110
|
+
: "0",
|
|
2111
|
+
profilePictureUrl: orEmptyString(profile.profile_picture_url),
|
|
2112
|
+
// The legacy connector reads this same Voyager summary as `about`.
|
|
2113
|
+
about: orEmptyString(profile.summary),
|
|
2114
|
+
},
|
|
2115
|
+
};
|
|
2116
|
+
},
|
|
2117
|
+
};
|
|
2118
|
+
const linkedinConnections = {
|
|
2119
|
+
scope: "linkedin.connections",
|
|
2120
|
+
pdppSource: "https://registry.pdpp.dev/connectors/linkedin",
|
|
2121
|
+
pdppStreams: ["connections"],
|
|
2122
|
+
legacySchemaPath: "linkedin.connections.json",
|
|
2123
|
+
fieldsRead: {
|
|
2124
|
+
connections: ["id", "full_name", "headline", "profile_url", "connected_at"],
|
|
2125
|
+
},
|
|
2126
|
+
primaryKey: { connections: ["id"] },
|
|
2127
|
+
lossy: [
|
|
2128
|
+
"Null full_name, headline, and profile_url become empty strings, matching the legacy connector's own fallback; connected_at is reduced to the legacy YYYY-MM-DD date text, with missing/invalid values becoming the legacy empty-string fallback.",
|
|
2129
|
+
],
|
|
2130
|
+
provenance: [
|
|
2131
|
+
{
|
|
2132
|
+
kind: "oci",
|
|
2133
|
+
ref: "ghcr.io/pdp-connect/connector/linkedin:0.3.1",
|
|
2134
|
+
digest: "sha256:7544a6c015bf4b730afdd97a7521608d10d1c077950af5e9f3adc9405609fd68",
|
|
2135
|
+
path: "declarations/linkedin.collection-profile.json#streams[name=connections]",
|
|
2136
|
+
},
|
|
2137
|
+
],
|
|
2138
|
+
project(records, options) {
|
|
2139
|
+
const guard = requireStreams("linkedin.connections", options, [
|
|
2140
|
+
"connections",
|
|
2141
|
+
]);
|
|
2142
|
+
if (guard)
|
|
2143
|
+
return guard;
|
|
2144
|
+
return {
|
|
2145
|
+
ok: true,
|
|
2146
|
+
payload: {
|
|
2147
|
+
connections: byStream(records, "connections").map((r) => ({
|
|
2148
|
+
fullName: orEmptyString(r.full_name),
|
|
2149
|
+
headline: orEmptyString(r.headline),
|
|
2150
|
+
profileUrl: orEmptyString(r.profile_url),
|
|
2151
|
+
dateConnected: typeof r.connected_at === "string" &&
|
|
2152
|
+
!Number.isNaN(new Date(r.connected_at).getTime())
|
|
2153
|
+
? new Date(r.connected_at).toISOString().slice(0, 10)
|
|
2154
|
+
: "",
|
|
2155
|
+
})),
|
|
2156
|
+
},
|
|
2157
|
+
};
|
|
2158
|
+
},
|
|
2159
|
+
};
|
|
2160
|
+
const linkedinExperience = {
|
|
2161
|
+
scope: "linkedin.experience",
|
|
2162
|
+
pdppSource: "https://registry.pdpp.dev/connectors/linkedin",
|
|
2163
|
+
pdppStreams: ["experience"],
|
|
2164
|
+
legacySchemaPath: "linkedin.experience.json",
|
|
2165
|
+
fieldsRead: {
|
|
2166
|
+
experience: [
|
|
2167
|
+
"title",
|
|
2168
|
+
"company",
|
|
2169
|
+
"start_date",
|
|
2170
|
+
"end_date",
|
|
2171
|
+
"location",
|
|
2172
|
+
"description",
|
|
2173
|
+
],
|
|
2174
|
+
},
|
|
2175
|
+
primaryKey: { experience: ["id"] },
|
|
2176
|
+
lossy: [
|
|
2177
|
+
"location, description: PDPP-side null projects as '' (matches legacy's own `pos.locationName || ''` / `pos.description || ''`)",
|
|
2178
|
+
],
|
|
2179
|
+
provenance: [
|
|
2180
|
+
{
|
|
2181
|
+
kind: "oci",
|
|
2182
|
+
ref: "ghcr.io/pdp-connect/connector/linkedin:0.3.1",
|
|
2183
|
+
digest: "sha256:7544a6c015bf4b730afdd97a7521608d10d1c077950af5e9f3adc9405609fd68",
|
|
2184
|
+
path: "declarations/linkedin.collection-profile.json#streams[name=experience]",
|
|
2185
|
+
},
|
|
2186
|
+
],
|
|
2187
|
+
project(records, options) {
|
|
2188
|
+
const guard = requireStreams("linkedin.experience", options, [
|
|
2189
|
+
"experience",
|
|
2190
|
+
]);
|
|
2191
|
+
if (guard) {
|
|
2192
|
+
return guard;
|
|
2193
|
+
}
|
|
2194
|
+
const items = byStream(records, "experience");
|
|
2195
|
+
// Legacy `dates` is one free-text field, "M/YYYY - M/YYYY" or
|
|
2196
|
+
// "M/YYYY - Present" (linkedin-playwright.js:129-145,
|
|
2197
|
+
// extractTimePeriod). PDPP declares separate start_date/end_date as
|
|
2198
|
+
// date-time strings; toLegacyTimePeriod reproduces the exact legacy
|
|
2199
|
+
// format from those two real fields, not a fabricated one.
|
|
2200
|
+
return {
|
|
2201
|
+
ok: true,
|
|
2202
|
+
payload: {
|
|
2203
|
+
experiences: items.map((e) => ({
|
|
2204
|
+
jobTitle: orEmptyString(e.title),
|
|
2205
|
+
companyName: orEmptyString(e.company),
|
|
2206
|
+
dates: toLegacyTimePeriod(e.start_date, e.end_date),
|
|
2207
|
+
location: orEmptyString(e.location),
|
|
2208
|
+
description: orEmptyString(e.description),
|
|
2209
|
+
})),
|
|
2210
|
+
},
|
|
2211
|
+
};
|
|
2212
|
+
},
|
|
2213
|
+
};
|
|
2214
|
+
const linkedinEducation = {
|
|
2215
|
+
scope: "linkedin.education",
|
|
2216
|
+
pdppSource: "https://registry.pdpp.dev/connectors/linkedin",
|
|
2217
|
+
pdppStreams: ["education"],
|
|
2218
|
+
legacySchemaPath: "linkedin.education.json",
|
|
2219
|
+
fieldsRead: {
|
|
2220
|
+
education: [
|
|
2221
|
+
"school",
|
|
2222
|
+
"degree",
|
|
2223
|
+
"field_of_study",
|
|
2224
|
+
"start_date",
|
|
2225
|
+
"end_date",
|
|
2226
|
+
"grade",
|
|
2227
|
+
"logo_url",
|
|
2228
|
+
],
|
|
2229
|
+
},
|
|
2230
|
+
primaryKey: { education: ["id"] },
|
|
2231
|
+
lossy: [
|
|
2232
|
+
"school, degree, grade, logo_url: null/missing values become '' to satisfy the required legacy string fields",
|
|
2233
|
+
],
|
|
2234
|
+
provenance: [
|
|
2235
|
+
{
|
|
2236
|
+
kind: "oci",
|
|
2237
|
+
ref: "ghcr.io/pdp-connect/connector/linkedin:0.3.1",
|
|
2238
|
+
digest: "sha256:7544a6c015bf4b730afdd97a7521608d10d1c077950af5e9f3adc9405609fd68",
|
|
2239
|
+
path: "declarations/linkedin.collection-profile.json#streams[name=education]",
|
|
2240
|
+
},
|
|
2241
|
+
],
|
|
2242
|
+
project(records, options) {
|
|
2243
|
+
const guard = requireStreams("linkedin.education", options, ["education"]);
|
|
2244
|
+
if (guard) {
|
|
2245
|
+
return guard;
|
|
2246
|
+
}
|
|
2247
|
+
const items = byStream(records, "education");
|
|
2248
|
+
return {
|
|
2249
|
+
ok: true,
|
|
2250
|
+
payload: {
|
|
2251
|
+
education: items.map((e) => ({
|
|
2252
|
+
schoolName: orEmptyString(e.school),
|
|
2253
|
+
degree: [orEmptyString(e.degree), orEmptyString(e.field_of_study)]
|
|
2254
|
+
.filter(Boolean)
|
|
2255
|
+
.join(", "),
|
|
2256
|
+
years: toLegacyTimePeriod(e.start_date, e.end_date),
|
|
2257
|
+
grade: orEmptyString(e.grade),
|
|
2258
|
+
logoUrl: orEmptyString(e.logo_url),
|
|
2259
|
+
})),
|
|
2260
|
+
},
|
|
2261
|
+
};
|
|
2262
|
+
},
|
|
2263
|
+
};
|
|
2264
|
+
const linkedinLanguages = {
|
|
2265
|
+
scope: "linkedin.languages",
|
|
2266
|
+
pdppSource: "https://registry.pdpp.dev/connectors/linkedin",
|
|
2267
|
+
pdppStreams: ["languages"],
|
|
2268
|
+
legacySchemaPath: "linkedin.languages.json",
|
|
2269
|
+
fieldsRead: { languages: ["name", "proficiency"] },
|
|
2270
|
+
primaryKey: { languages: ["id"] },
|
|
2271
|
+
lossy: [
|
|
2272
|
+
"proficiency: null/missing values become '' to satisfy the required legacy string field",
|
|
2273
|
+
],
|
|
2274
|
+
provenance: [
|
|
2275
|
+
{
|
|
2276
|
+
kind: "oci",
|
|
2277
|
+
ref: "ghcr.io/pdp-connect/connector/linkedin:0.3.1",
|
|
2278
|
+
digest: "sha256:7544a6c015bf4b730afdd97a7521608d10d1c077950af5e9f3adc9405609fd68",
|
|
2279
|
+
path: "declarations/linkedin.collection-profile.json#streams[name=languages]",
|
|
2280
|
+
},
|
|
2281
|
+
],
|
|
2282
|
+
project(records, options) {
|
|
2283
|
+
const guard = requireStreams("linkedin.languages", options, ["languages"]);
|
|
2284
|
+
if (guard) {
|
|
2285
|
+
return guard;
|
|
2286
|
+
}
|
|
2287
|
+
const items = byStream(records, "languages");
|
|
2288
|
+
return {
|
|
2289
|
+
ok: true,
|
|
2290
|
+
payload: {
|
|
2291
|
+
languages: items.map((l) => ({
|
|
2292
|
+
name: orEmptyString(l.name),
|
|
2293
|
+
proficiency: orEmptyString(l.proficiency),
|
|
2294
|
+
})),
|
|
2295
|
+
},
|
|
2296
|
+
};
|
|
2297
|
+
},
|
|
2298
|
+
};
|
|
2299
|
+
const linkedinSkills = {
|
|
2300
|
+
scope: "linkedin.skills",
|
|
2301
|
+
pdppSource: "https://registry.pdpp.dev/connectors/linkedin",
|
|
2302
|
+
pdppStreams: ["skills"],
|
|
2303
|
+
legacySchemaPath: "linkedin.skills.json",
|
|
2304
|
+
fieldsRead: { skills: ["name", "endorsement_count"] },
|
|
2305
|
+
primaryKey: { skills: ["id"] },
|
|
2306
|
+
lossy: [],
|
|
2307
|
+
provenance: [
|
|
2308
|
+
{
|
|
2309
|
+
kind: "oci",
|
|
2310
|
+
ref: "ghcr.io/pdp-connect/connector/linkedin:0.3.1",
|
|
2311
|
+
digest: "sha256:7544a6c015bf4b730afdd97a7521608d10d1c077950af5e9f3adc9405609fd68",
|
|
2312
|
+
path: "declarations/linkedin.collection-profile.json#streams[name=skills]",
|
|
2313
|
+
},
|
|
2314
|
+
],
|
|
2315
|
+
project(records, options) {
|
|
2316
|
+
const guard = requireStreams("linkedin.skills", options, ["skills"]);
|
|
2317
|
+
if (guard) {
|
|
2318
|
+
return guard;
|
|
2319
|
+
}
|
|
2320
|
+
const items = byStream(records, "skills");
|
|
2321
|
+
// Legacy `endorsements` is a string; PDPP `endorsement_count` is a
|
|
2322
|
+
// number. String-converted with legacy's own 0 fallback
|
|
2323
|
+
// (linkedin-playwright.js:551, `String(sk.endorsementCount || ... || 0)`).
|
|
2324
|
+
return {
|
|
2325
|
+
ok: true,
|
|
2326
|
+
payload: {
|
|
2327
|
+
skills: items.map((s) => ({
|
|
2328
|
+
name: orEmptyString(s.name),
|
|
2329
|
+
endorsements: String(orZero(s.endorsement_count)),
|
|
2330
|
+
})),
|
|
2331
|
+
},
|
|
2332
|
+
};
|
|
2333
|
+
},
|
|
2334
|
+
};
|
|
2335
|
+
// ---------------------------------------------------------------------------
|
|
2336
|
+
// spotify
|
|
2337
|
+
// declarations/spotify.collection-profile.json (0.1.4)
|
|
2338
|
+
// connector_id: https://registry.pdpp.dev/connectors/spotify
|
|
2339
|
+
// ---------------------------------------------------------------------------
|
|
2340
|
+
const spotifyProfile = {
|
|
2341
|
+
scope: "spotify.profile",
|
|
2342
|
+
pdppSource: "https://registry.pdpp.dev/connectors/spotify",
|
|
2343
|
+
pdppStreams: ["profile"],
|
|
2344
|
+
legacySchemaPath: "spotify.profile.json",
|
|
2345
|
+
fieldsRead: {
|
|
2346
|
+
profile: ["id", "display_name", "uri", "followers", "following", "images"],
|
|
2347
|
+
},
|
|
2348
|
+
primaryKey: { profile: ["id"] },
|
|
2349
|
+
lossy: [
|
|
2350
|
+
"display_name: PDPP permits null, but the legacy schema requires a string; null projects as ''.",
|
|
2351
|
+
"images: legacy stores string URLs. String entries are retained, and structured PDPP image entries project their string url field.",
|
|
2352
|
+
],
|
|
2353
|
+
provenance: [
|
|
2354
|
+
{
|
|
2355
|
+
kind: "oci",
|
|
2356
|
+
ref: "ghcr.io/pdp-connect/connector/spotify:0.1.4",
|
|
2357
|
+
digest: "sha256:6989f4eceb2142456bebb7b3d442e0a81ce571ef762824aa6dca882afabcba08",
|
|
2358
|
+
path: "declarations/spotify.collection-profile.json#streams[name=profile]",
|
|
2359
|
+
},
|
|
2360
|
+
],
|
|
2361
|
+
project(records, options) {
|
|
2362
|
+
const guard = requireStreams("spotify.profile", options, ["profile"]);
|
|
2363
|
+
if (guard) {
|
|
2364
|
+
return guard;
|
|
2365
|
+
}
|
|
2366
|
+
const [profile] = byStream(records, "profile");
|
|
2367
|
+
if (!profile) {
|
|
2368
|
+
return {
|
|
2369
|
+
ok: false,
|
|
2370
|
+
error: { kind: "empty_singleton", scope: "spotify.profile" },
|
|
2371
|
+
};
|
|
2372
|
+
}
|
|
2373
|
+
return {
|
|
2374
|
+
ok: true,
|
|
2375
|
+
payload: {
|
|
2376
|
+
id: orEmptyString(profile.id),
|
|
2377
|
+
display_name: orEmptyString(profile.display_name),
|
|
2378
|
+
uri: orEmptyString(profile.uri),
|
|
2379
|
+
followers: orZero(profile.followers),
|
|
2380
|
+
following: orZero(profile.following),
|
|
2381
|
+
images: spotifyImageUrls(profile.images),
|
|
2382
|
+
},
|
|
2383
|
+
};
|
|
2384
|
+
},
|
|
2385
|
+
};
|
|
2386
|
+
const spotifySavedTracks = {
|
|
2387
|
+
scope: "spotify.savedTracks",
|
|
2388
|
+
pdppSource: "https://registry.pdpp.dev/connectors/spotify",
|
|
2389
|
+
pdppStreams: ["saved_tracks"],
|
|
2390
|
+
legacySchemaPath: "spotify.savedTracks.json",
|
|
2391
|
+
fieldsRead: {
|
|
2392
|
+
saved_tracks: [
|
|
2393
|
+
"id",
|
|
2394
|
+
"name",
|
|
2395
|
+
"artist_names",
|
|
2396
|
+
"album_name",
|
|
2397
|
+
"duration_ms",
|
|
2398
|
+
"added_at",
|
|
2399
|
+
"uri",
|
|
2400
|
+
"explicit",
|
|
2401
|
+
"album_artist_names",
|
|
2402
|
+
],
|
|
2403
|
+
},
|
|
2404
|
+
primaryKey: { saved_tracks: ["id"] },
|
|
2405
|
+
lossy: [
|
|
2406
|
+
"Rows without the required legacy name and artist list are skipped; nullable album and optional fields retain the connector's empty-string, zero, and false sentinels.",
|
|
2407
|
+
"isrc, popularity, and id have no corresponding legacy fields and are omitted.",
|
|
2408
|
+
],
|
|
2409
|
+
provenance: [
|
|
2410
|
+
{
|
|
2411
|
+
kind: "oci",
|
|
2412
|
+
ref: "ghcr.io/pdp-connect/connector/spotify:0.1.4",
|
|
2413
|
+
digest: "sha256:6989f4eceb2142456bebb7b3d442e0a81ce571ef762824aa6dca882afabcba08",
|
|
2414
|
+
path: "declarations/spotify.collection-profile.json#streams[name=saved_tracks]",
|
|
2415
|
+
},
|
|
2416
|
+
],
|
|
2417
|
+
project(records, options) {
|
|
2418
|
+
const guard = requireStreams("spotify.savedTracks", options, [
|
|
2419
|
+
"saved_tracks",
|
|
2420
|
+
]);
|
|
2421
|
+
if (guard)
|
|
2422
|
+
return guard;
|
|
2423
|
+
const tracks = byStream(records, "saved_tracks").flatMap((r) => {
|
|
2424
|
+
if (typeof r.name !== "string" ||
|
|
2425
|
+
!Array.isArray(r.artist_names) ||
|
|
2426
|
+
!r.artist_names.every((name) => typeof name === "string"))
|
|
2427
|
+
return [];
|
|
2428
|
+
return [
|
|
2429
|
+
{
|
|
2430
|
+
added_at: orEmptyString(r.added_at),
|
|
2431
|
+
name: r.name,
|
|
2432
|
+
artists: r.artist_names.map((name) => ({ name })),
|
|
2433
|
+
album: {
|
|
2434
|
+
name: orEmptyString(r.album_name),
|
|
2435
|
+
artists: Array.isArray(r.album_artist_names)
|
|
2436
|
+
? r.album_artist_names
|
|
2437
|
+
.filter((name) => typeof name === "string")
|
|
2438
|
+
.map((name) => ({ name }))
|
|
2439
|
+
: [],
|
|
2440
|
+
},
|
|
2441
|
+
duration_ms: orZero(r.duration_ms),
|
|
2442
|
+
uri: orEmptyString(r.uri),
|
|
2443
|
+
explicit: typeof r.explicit === "boolean" ? r.explicit : false,
|
|
2444
|
+
},
|
|
2445
|
+
];
|
|
2446
|
+
});
|
|
2447
|
+
return { ok: true, payload: { savedTracks: tracks, total: tracks.length } };
|
|
2448
|
+
},
|
|
2449
|
+
};
|
|
2450
|
+
const spotifyPlaylists = {
|
|
2451
|
+
scope: "spotify.playlists",
|
|
2452
|
+
pdppSource: "https://registry.pdpp.dev/connectors/spotify",
|
|
2453
|
+
pdppStreams: ["playlists", "playlist_items"],
|
|
2454
|
+
legacySchemaPath: "spotify.playlists.json",
|
|
2455
|
+
fieldsRead: {
|
|
2456
|
+
playlists: [
|
|
2457
|
+
"id",
|
|
2458
|
+
"name",
|
|
2459
|
+
"description",
|
|
2460
|
+
"owner_name",
|
|
2461
|
+
"uri",
|
|
2462
|
+
"followers",
|
|
2463
|
+
"images",
|
|
2464
|
+
"track_count",
|
|
2465
|
+
],
|
|
2466
|
+
playlist_items: [
|
|
2467
|
+
"playlist_id",
|
|
2468
|
+
"position",
|
|
2469
|
+
"added_at",
|
|
2470
|
+
"added_by",
|
|
2471
|
+
"name",
|
|
2472
|
+
"artist_names",
|
|
2473
|
+
"album_name",
|
|
2474
|
+
"duration_ms",
|
|
2475
|
+
"track_id",
|
|
2476
|
+
],
|
|
2477
|
+
},
|
|
2478
|
+
primaryKey: {
|
|
2479
|
+
playlists: ["id"],
|
|
2480
|
+
playlist_items: ["id"],
|
|
2481
|
+
},
|
|
2482
|
+
lossy: [
|
|
2483
|
+
"tracks[].uri: raw playlist item URIs are retained when available; older pinned records fall back to a canonical Spotify track ID, and a null ID uses the retained collector's empty-string sentinel.",
|
|
2484
|
+
"tracks_total counts joined fetched tracks, as in the retained collector; catalog track_count is not a fetched-row count.",
|
|
2485
|
+
"images: legacy stores string URLs. String entries are retained, and structured PDPP image entries project their string url field.",
|
|
2486
|
+
],
|
|
2487
|
+
provenance: [
|
|
2488
|
+
{
|
|
2489
|
+
kind: "oci",
|
|
2490
|
+
ref: "ghcr.io/pdp-connect/connector/spotify:0.1.4",
|
|
2491
|
+
digest: "sha256:6989f4eceb2142456bebb7b3d442e0a81ce571ef762824aa6dca882afabcba08",
|
|
2492
|
+
path: "declarations/spotify.collection-profile.json#streams[name=playlists,playlist_items]",
|
|
2493
|
+
},
|
|
2494
|
+
],
|
|
2495
|
+
project(records, options) {
|
|
2496
|
+
const guard = requireStreams("spotify.playlists", options, [
|
|
2497
|
+
"playlists",
|
|
2498
|
+
"playlist_items",
|
|
2499
|
+
]);
|
|
2500
|
+
if (guard) {
|
|
2501
|
+
return guard;
|
|
2502
|
+
}
|
|
2503
|
+
const itemsByPlaylist = new Map();
|
|
2504
|
+
for (const item of byStream(records, "playlist_items")) {
|
|
2505
|
+
const playlistId = item.playlist_id;
|
|
2506
|
+
if (typeof playlistId !== "string")
|
|
2507
|
+
continue;
|
|
2508
|
+
const group = itemsByPlaylist.get(playlistId);
|
|
2509
|
+
if (group) {
|
|
2510
|
+
group.push(item);
|
|
2511
|
+
}
|
|
2512
|
+
else {
|
|
2513
|
+
itemsByPlaylist.set(playlistId, [item]);
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
const sourcePlaylists = byStream(records, "playlists");
|
|
2517
|
+
const playlistIds = new Set(sourcePlaylists.map((playlist) => playlist.id));
|
|
2518
|
+
const unrepresentablePlaylist = [...itemsByPlaylist].some(([id, tracks]) => !playlistIds.has(id) ||
|
|
2519
|
+
tracks.some((track) => typeof track.uri !== "string" &&
|
|
2520
|
+
track.track_id != null &&
|
|
2521
|
+
(typeof track.track_id !== "string" ||
|
|
2522
|
+
!/^(?:spotify:track:)?[A-Za-z0-9]{22}$/.test(track.track_id))));
|
|
2523
|
+
if (unrepresentablePlaylist) {
|
|
2524
|
+
return {
|
|
2525
|
+
ok: false,
|
|
2526
|
+
error: {
|
|
2527
|
+
kind: "incomplete_scope",
|
|
2528
|
+
scope: "spotify.playlists",
|
|
2529
|
+
reason: "Cannot join every playlist item or derive its Spotify track URI",
|
|
2530
|
+
},
|
|
2531
|
+
};
|
|
2532
|
+
}
|
|
2533
|
+
const playlists = sourcePlaylists.map((playlist) => {
|
|
2534
|
+
const id = typeof playlist.id === "string" ? playlist.id : "";
|
|
2535
|
+
const trackItems = [...(itemsByPlaylist.get(id) ?? [])].sort((a, b) => orZero(a.position) - orZero(b.position));
|
|
2536
|
+
return {
|
|
2537
|
+
name: orEmptyString(playlist.name),
|
|
2538
|
+
description: orEmptyString(playlist.description),
|
|
2539
|
+
owner: orEmptyString(playlist.owner_name),
|
|
2540
|
+
uri: orEmptyString(playlist.uri),
|
|
2541
|
+
followers: orZero(playlist.followers),
|
|
2542
|
+
images: spotifyImageUrls(playlist.images),
|
|
2543
|
+
tracks_total: trackItems.length,
|
|
2544
|
+
tracks: trackItems.map((track) => ({
|
|
2545
|
+
added_at: orEmptyString(track.added_at),
|
|
2546
|
+
added_by: orEmptyString(track.added_by),
|
|
2547
|
+
name: orEmptyString(track.name),
|
|
2548
|
+
artists: Array.isArray(track.artist_names)
|
|
2549
|
+
? track.artist_names
|
|
2550
|
+
.filter((artist) => typeof artist === "string")
|
|
2551
|
+
.map((name) => ({ name }))
|
|
2552
|
+
: [],
|
|
2553
|
+
album: orEmptyString(track.album_name),
|
|
2554
|
+
duration_ms: orZero(track.duration_ms),
|
|
2555
|
+
uri: typeof track.uri === "string"
|
|
2556
|
+
? track.uri
|
|
2557
|
+
: typeof track.track_id === "string"
|
|
2558
|
+
? track.track_id.startsWith("spotify:track:")
|
|
2559
|
+
? track.track_id
|
|
2560
|
+
: `spotify:track:${track.track_id}`
|
|
2561
|
+
: "",
|
|
2562
|
+
})),
|
|
2563
|
+
};
|
|
2564
|
+
});
|
|
2565
|
+
return {
|
|
2566
|
+
ok: true,
|
|
2567
|
+
payload: { playlists, total: playlists.length },
|
|
2568
|
+
};
|
|
2569
|
+
},
|
|
2570
|
+
};
|
|
2571
|
+
function spotifyImageUrls(value) {
|
|
2572
|
+
if (!Array.isArray(value))
|
|
2573
|
+
return [];
|
|
2574
|
+
return value.flatMap((image) => {
|
|
2575
|
+
if (typeof image === "string")
|
|
2576
|
+
return [image];
|
|
2577
|
+
if (typeof image !== "object" || image === null)
|
|
2578
|
+
return [];
|
|
2579
|
+
const url = image.url;
|
|
2580
|
+
return typeof url === "string" ? [url] : [];
|
|
2581
|
+
});
|
|
2582
|
+
}
|
|
2583
|
+
// ---------------------------------------------------------------------------
|
|
2584
|
+
// shopify (shop.orders)
|
|
2585
|
+
// declarations/shopify.collection-profile.json (0.2.0)
|
|
2586
|
+
// connector_id: https://registry.pdpp.dev/connectors/shopify
|
|
2587
|
+
// ---------------------------------------------------------------------------
|
|
2588
|
+
const shopOrders = {
|
|
2589
|
+
scope: "shop.orders",
|
|
2590
|
+
pdppSource: "https://registry.pdpp.dev/connectors/shopify",
|
|
2591
|
+
pdppStreams: ["orders"],
|
|
2592
|
+
legacySchemaPath: "shop.orders.json",
|
|
2593
|
+
fieldsRead: {
|
|
2594
|
+
orders: [
|
|
2595
|
+
"id",
|
|
2596
|
+
"order_date",
|
|
2597
|
+
"merchant_name",
|
|
2598
|
+
"total_cents",
|
|
2599
|
+
"currency",
|
|
2600
|
+
"status",
|
|
2601
|
+
"item_count",
|
|
2602
|
+
"line_item_titles",
|
|
2603
|
+
"detail_url",
|
|
2604
|
+
],
|
|
2605
|
+
},
|
|
2606
|
+
primaryKey: { orders: ["id"] },
|
|
2607
|
+
lossy: [
|
|
2608
|
+
"orderNumber: PDPP provides order_number, but the legacy connector derives orderNumber from id (shop-playwright.js:174); keep the existing value to preserve the scope contract",
|
|
2609
|
+
"status: legacy uses displayStatus free text scraped from the storefront; PDPP's normalized `status` value is not verified to match that text 1:1",
|
|
2610
|
+
"placedAt (F5): passed through only when the PDPP source value parses as a valid ISO 8601 date-time; otherwise omitted. legacy's own no-match fallback is '' (shop-playwright.js:175, `orderObj.createdAt || ''`), which the pinned schema does not accept (placedAt is `format: date-time`). A null-shaped legacy fallback would itself fail the pinned schema.",
|
|
2611
|
+
"total, itemCount (N-S1): legacy's own no-match fallbacks are null (:154, top-level total) and `totalItemCount ?? (itemTitles.length || null)` (:180) — none of which the pinned per-item schema accepts (both are `type: number`). A null-shaped legacy fallback would itself fail the pinned schema, and only `id` is required per item, so a missing PDPP value for these two fields is OMITTED from the payload entirely (not defaulted to 0) rather than invented — that omission is itself the disclosed lossy behavior.",
|
|
2612
|
+
],
|
|
2613
|
+
provenance: [
|
|
2614
|
+
{
|
|
2615
|
+
kind: "oci",
|
|
2616
|
+
ref: "ghcr.io/pdp-connect/connector/shopify:0.2.0",
|
|
2617
|
+
digest: "sha256:167cff474a653ad7bce036c973a78adcfc3a3e8e73925a6e24c74961f979ac0e",
|
|
2618
|
+
path: "declarations/shopify.collection-profile.json#streams[name=orders]",
|
|
2619
|
+
},
|
|
2620
|
+
],
|
|
2621
|
+
project(records, options) {
|
|
2622
|
+
const guard = requireStreams("shop.orders", options, ["orders"]);
|
|
2623
|
+
if (guard) {
|
|
2624
|
+
return guard;
|
|
2625
|
+
}
|
|
2626
|
+
const items = byStream(records, "orders");
|
|
2627
|
+
// orderNumber: legacy's own value is String(orderObj.id)
|
|
2628
|
+
// (shop-playwright.js:174) on its primary extraction path — a real,
|
|
2629
|
+
// derivable transform of the same id PDPP carries, not an invented
|
|
2630
|
+
// field. placedAt/total/itemCount: omitted (not defaulted) when the PDPP
|
|
2631
|
+
// source value is absent — see the `lossy` entry above (N-S1). Only
|
|
2632
|
+
// `id` is required per item by the pinned schema, so omitting these is
|
|
2633
|
+
// schema-valid.
|
|
2634
|
+
return {
|
|
2635
|
+
ok: true,
|
|
2636
|
+
payload: {
|
|
2637
|
+
orders: items.map((o) => ({
|
|
2638
|
+
id: orEmptyString(o.id),
|
|
2639
|
+
orderNumber: orEmptyString(o.id),
|
|
2640
|
+
...(typeof o.order_date === "string" &&
|
|
2641
|
+
isValidIso8601DateTime(o.order_date)
|
|
2642
|
+
? { placedAt: o.order_date }
|
|
2643
|
+
: {}),
|
|
2644
|
+
merchantName: orEmptyString(o.merchant_name),
|
|
2645
|
+
...(typeof o.total_cents === "number"
|
|
2646
|
+
? { total: o.total_cents / 100 }
|
|
2647
|
+
: {}),
|
|
2648
|
+
currency: orEmptyString(o.currency),
|
|
2649
|
+
status: orEmptyString(o.status),
|
|
2650
|
+
...(typeof o.item_count === "number"
|
|
2651
|
+
? { itemCount: o.item_count }
|
|
2652
|
+
: {}),
|
|
2653
|
+
...(Array.isArray(o.line_item_titles) &&
|
|
2654
|
+
o.line_item_titles.every((title) => typeof title === "string")
|
|
2655
|
+
? { lineItemTitles: o.line_item_titles }
|
|
2656
|
+
: {}),
|
|
2657
|
+
...(typeof o.detail_url === "string"
|
|
2658
|
+
? { detailUrl: o.detail_url }
|
|
2659
|
+
: {}),
|
|
2660
|
+
})),
|
|
2661
|
+
total: items.length,
|
|
2662
|
+
},
|
|
2663
|
+
};
|
|
2664
|
+
},
|
|
2665
|
+
};
|
|
2666
|
+
// ---------------------------------------------------------------------------
|
|
2667
|
+
// Oura (activity and readiness)
|
|
2668
|
+
// declarations/oura.collection-profile.json (0.1.1, current OCI pin)
|
|
2669
|
+
// connector_id: https://registry.pdpp.dev/connectors/oura
|
|
2670
|
+
// ---------------------------------------------------------------------------
|
|
2671
|
+
const ouraActivity = {
|
|
2672
|
+
scope: "oura.activity",
|
|
2673
|
+
pdppSource: "https://registry.pdpp.dev/connectors/oura",
|
|
2674
|
+
pdppStreams: ["activity"],
|
|
2675
|
+
legacySchemaPath: "oura.activity.json",
|
|
2676
|
+
fieldsRead: {
|
|
2677
|
+
activity: [
|
|
2678
|
+
"id",
|
|
2679
|
+
"day",
|
|
2680
|
+
"score",
|
|
2681
|
+
"active_calories",
|
|
2682
|
+
"total_calories",
|
|
2683
|
+
"steps",
|
|
2684
|
+
"equivalent_walking_distance",
|
|
2685
|
+
"high_activity_time",
|
|
2686
|
+
"medium_activity_time",
|
|
2687
|
+
"low_activity_time",
|
|
2688
|
+
"sedentary_time",
|
|
2689
|
+
"resting_time",
|
|
2690
|
+
"inactivity_alerts",
|
|
2691
|
+
"contributors",
|
|
2692
|
+
],
|
|
2693
|
+
},
|
|
2694
|
+
primaryKey: { activity: ["id"] },
|
|
2695
|
+
lossy: [
|
|
2696
|
+
"timestamp: no source field; omitted (legacy schema makes it optional)",
|
|
2697
|
+
"target_calories: has no legacy field and is not projected",
|
|
2698
|
+
],
|
|
2699
|
+
provenance: [
|
|
2700
|
+
{
|
|
2701
|
+
kind: "oci",
|
|
2702
|
+
ref: "ghcr.io/pdp-connect/connector/oura:0.1.1",
|
|
2703
|
+
digest: "sha256:7e39080f5ca65b83f44911cb120f9b8fc5c696b874e6493b26c00f07172efcae",
|
|
2704
|
+
path: "declarations/oura.collection-profile.json#streams[name=activity]",
|
|
2705
|
+
},
|
|
2706
|
+
],
|
|
2707
|
+
project(records, options) {
|
|
2708
|
+
const guard = requireStreams("oura.activity", options, ["activity"]);
|
|
2709
|
+
if (guard)
|
|
2710
|
+
return guard;
|
|
2711
|
+
return {
|
|
2712
|
+
ok: true,
|
|
2713
|
+
payload: {
|
|
2714
|
+
days: byStream(records, "activity").map((r) => ({
|
|
2715
|
+
id: r.id,
|
|
2716
|
+
day: r.day,
|
|
2717
|
+
...ifDefined(r, "score", "score"),
|
|
2718
|
+
...ifDefined(r, "active_calories", "activeCalories"),
|
|
2719
|
+
...ifDefined(r, "total_calories", "totalCalories"),
|
|
2720
|
+
...ifDefined(r, "steps", "steps"),
|
|
2721
|
+
...ifDefined(r, "equivalent_walking_distance", "equivalentWalkingDistance"),
|
|
2722
|
+
...ifDefined(r, "high_activity_time", "highActivityTime"),
|
|
2723
|
+
...ifDefined(r, "medium_activity_time", "mediumActivityTime"),
|
|
2724
|
+
...ifDefined(r, "low_activity_time", "lowActivityTime"),
|
|
2725
|
+
...ifDefined(r, "sedentary_time", "sedentaryTime"),
|
|
2726
|
+
...ifDefined(r, "resting_time", "restingTime"),
|
|
2727
|
+
...ifDefined(r, "inactivity_alerts", "inactivityAlerts"),
|
|
2728
|
+
...(r.contributors && typeof r.contributors === "object"
|
|
2729
|
+
? { contributors: r.contributors }
|
|
2730
|
+
: {}),
|
|
2731
|
+
})),
|
|
2732
|
+
},
|
|
2733
|
+
};
|
|
2734
|
+
},
|
|
2735
|
+
};
|
|
2736
|
+
const ouraReadiness = {
|
|
2737
|
+
scope: "oura.readiness",
|
|
2738
|
+
pdppSource: "https://registry.pdpp.dev/connectors/oura",
|
|
2739
|
+
pdppStreams: ["readiness"],
|
|
2740
|
+
legacySchemaPath: "oura.readiness.json",
|
|
2741
|
+
fieldsRead: {
|
|
2742
|
+
readiness: [
|
|
2743
|
+
"id",
|
|
2744
|
+
"day",
|
|
2745
|
+
"score",
|
|
2746
|
+
"temperature_deviation",
|
|
2747
|
+
"temperature_trend_deviation",
|
|
2748
|
+
"contributors",
|
|
2749
|
+
],
|
|
2750
|
+
},
|
|
2751
|
+
primaryKey: { readiness: ["id"] },
|
|
2752
|
+
lossy: [
|
|
2753
|
+
"timestamp: no source field; omitted (legacy schema makes it optional)",
|
|
2754
|
+
],
|
|
2755
|
+
provenance: [
|
|
2756
|
+
{
|
|
2757
|
+
kind: "oci",
|
|
2758
|
+
ref: "ghcr.io/pdp-connect/connector/oura:0.1.1",
|
|
2759
|
+
digest: "sha256:7e39080f5ca65b83f44911cb120f9b8fc5c696b874e6493b26c00f07172efcae",
|
|
2760
|
+
path: "declarations/oura.collection-profile.json#streams[name=readiness]",
|
|
2761
|
+
},
|
|
2762
|
+
],
|
|
2763
|
+
project(records, options) {
|
|
2764
|
+
const guard = requireStreams("oura.readiness", options, ["readiness"]);
|
|
2765
|
+
if (guard)
|
|
2766
|
+
return guard;
|
|
2767
|
+
return {
|
|
2768
|
+
ok: true,
|
|
2769
|
+
payload: {
|
|
2770
|
+
days: byStream(records, "readiness").map((r) => ({
|
|
2771
|
+
id: r.id,
|
|
2772
|
+
day: r.day,
|
|
2773
|
+
...ifDefined(r, "score", "score"),
|
|
2774
|
+
...ifDefined(r, "temperature_deviation", "temperatureDeviation"),
|
|
2775
|
+
...ifDefined(r, "temperature_trend_deviation", "temperatureTrendDeviation"),
|
|
2776
|
+
...(r.contributors && typeof r.contributors === "object"
|
|
2777
|
+
? { contributors: r.contributors }
|
|
2778
|
+
: {}),
|
|
2779
|
+
})),
|
|
2780
|
+
},
|
|
2781
|
+
};
|
|
2782
|
+
},
|
|
2783
|
+
};
|
|
2784
|
+
const ouraSleep = {
|
|
2785
|
+
scope: "oura.sleep",
|
|
2786
|
+
pdppSource: "https://registry.pdpp.dev/connectors/oura",
|
|
2787
|
+
pdppStreams: ["sleep"],
|
|
2788
|
+
legacySchemaPath: "oura.sleep.json",
|
|
2789
|
+
fieldsRead: {
|
|
2790
|
+
sleep: [
|
|
2791
|
+
"id",
|
|
2792
|
+
"day",
|
|
2793
|
+
"sleep_score",
|
|
2794
|
+
"contributors",
|
|
2795
|
+
"type",
|
|
2796
|
+
"bedtime_start",
|
|
2797
|
+
"bedtime_end",
|
|
2798
|
+
"total_sleep_duration",
|
|
2799
|
+
"time_in_bed",
|
|
2800
|
+
"deep_sleep_duration",
|
|
2801
|
+
"light_sleep_duration",
|
|
2802
|
+
"rem_sleep_duration",
|
|
2803
|
+
"efficiency",
|
|
2804
|
+
"latency",
|
|
2805
|
+
"average_heart_rate",
|
|
2806
|
+
"average_hrv",
|
|
2807
|
+
"lowest_heart_rate",
|
|
2808
|
+
"average_breath",
|
|
2809
|
+
"restless_periods",
|
|
2810
|
+
],
|
|
2811
|
+
},
|
|
2812
|
+
primaryKey: { sleep: ["id"] },
|
|
2813
|
+
lossy: [
|
|
2814
|
+
"awakeTime: no declared PDPP field and is omitted",
|
|
2815
|
+
"dailyScores.timestamp: no source field and is omitted",
|
|
2816
|
+
"sleepPeriods.averageHrv: PDPP allows a non-integer number but legacy schema allows only integer or null; non-integer values are omitted",
|
|
2817
|
+
"sleepPeriods.type: unrecognized or null values are omitted because the legacy enum accepts only long_sleep, short_sleep or rest",
|
|
2818
|
+
],
|
|
2819
|
+
provenance: [
|
|
2820
|
+
{
|
|
2821
|
+
kind: "oci",
|
|
2822
|
+
ref: "ghcr.io/pdp-connect/connector/oura:0.1.1",
|
|
2823
|
+
digest: "sha256:7e39080f5ca65b83f44911cb120f9b8fc5c696b874e6493b26c00f07172efcae",
|
|
2824
|
+
path: "declarations/oura.collection-profile.json#streams[name=sleep]",
|
|
2825
|
+
},
|
|
2826
|
+
],
|
|
2827
|
+
project(records, options) {
|
|
2828
|
+
const guard = requireStreams("oura.sleep", options, ["sleep"]);
|
|
2829
|
+
if (guard)
|
|
2830
|
+
return guard;
|
|
2831
|
+
const rows = byStream(records, "sleep");
|
|
2832
|
+
const periods = rows.map((r) => ({
|
|
2833
|
+
id: r.id,
|
|
2834
|
+
day: r.day,
|
|
2835
|
+
...(r.type === "long_sleep" ||
|
|
2836
|
+
r.type === "short_sleep" ||
|
|
2837
|
+
r.type === "rest"
|
|
2838
|
+
? { type: r.type }
|
|
2839
|
+
: {}),
|
|
2840
|
+
...ifDefined(r, "bedtime_start", "bedtimeStart"),
|
|
2841
|
+
...ifDefined(r, "bedtime_end", "bedtimeEnd"),
|
|
2842
|
+
...ifDefined(r, "total_sleep_duration", "totalSleepDuration"),
|
|
2843
|
+
...ifDefined(r, "time_in_bed", "timeInBed"),
|
|
2844
|
+
...ifDefined(r, "deep_sleep_duration", "deepSleepDuration"),
|
|
2845
|
+
...ifDefined(r, "light_sleep_duration", "lightSleepDuration"),
|
|
2846
|
+
...ifDefined(r, "rem_sleep_duration", "remSleepDuration"),
|
|
2847
|
+
...ifDefined(r, "efficiency", "efficiency"),
|
|
2848
|
+
...ifDefined(r, "latency", "latency"),
|
|
2849
|
+
...ifDefined(r, "average_heart_rate", "averageHeartRate"),
|
|
2850
|
+
...(r.average_hrv === null
|
|
2851
|
+
? { averageHrv: null }
|
|
2852
|
+
: typeof r.average_hrv === "number" && Number.isInteger(r.average_hrv)
|
|
2853
|
+
? { averageHrv: r.average_hrv }
|
|
2854
|
+
: {}),
|
|
2855
|
+
...ifDefined(r, "lowest_heart_rate", "lowestHeartRate"),
|
|
2856
|
+
...ifDefined(r, "average_breath", "averageBreath"),
|
|
2857
|
+
...ifDefined(r, "restless_periods", "restlessPeriods"),
|
|
2858
|
+
}));
|
|
2859
|
+
return {
|
|
2860
|
+
ok: true,
|
|
2861
|
+
payload: {
|
|
2862
|
+
dailyScores: rows.map((r) => ({
|
|
2863
|
+
id: r.id,
|
|
2864
|
+
day: r.day,
|
|
2865
|
+
...ifDefined(r, "sleep_score", "score"),
|
|
2866
|
+
...(r.contributors && typeof r.contributors === "object"
|
|
2867
|
+
? { contributors: r.contributors }
|
|
2868
|
+
: {}),
|
|
2869
|
+
})),
|
|
2870
|
+
sleepPeriods: periods,
|
|
2871
|
+
},
|
|
2872
|
+
};
|
|
2873
|
+
},
|
|
2874
|
+
};
|
|
2875
|
+
// ---------------------------------------------------------------------------
|
|
2876
|
+
// amazon
|
|
2877
|
+
// declarations/amazon.collection-profile.json, streams "orders" +
|
|
2878
|
+
// "order_items" (joined on order_items.order_id === orders.id).
|
|
2879
|
+
// connector_id: https://registry.pdpp.dev/connectors/amazon
|
|
2880
|
+
// ---------------------------------------------------------------------------
|
|
2881
|
+
const amazonOrders = {
|
|
2882
|
+
scope: "amazon.orders",
|
|
2883
|
+
pdppSource: "https://registry.pdpp.dev/connectors/amazon",
|
|
2884
|
+
pdppStreams: ["orders", "order_items"],
|
|
2885
|
+
legacySchemaPath: "amazon.orders.json",
|
|
2886
|
+
fieldsRead: {
|
|
2887
|
+
orders: ["id", "order_date", "order_total", "delivery_status"],
|
|
2888
|
+
order_items: ["order_id", "name", "url", "unit_price"],
|
|
2889
|
+
},
|
|
2890
|
+
primaryKey: { orders: ["id"], order_items: ["id"] },
|
|
2891
|
+
lossy: [
|
|
2892
|
+
"items[].quantity: no legacy field (legacy schema does not carry quantity at all; PDPP-side quantity is simply not projected)",
|
|
2893
|
+
"items[].price (N-S2): legacy's own connector ALWAYS emits the literal constant '' for this field (amazon-playwright.js:222, `price: ''` — not a fallback for a missing value, the connector never scrapes a real price here at all). This projection instead emits PDPP's real `unit_price` when present, so an app reading this field gets a populated value where legacy always gave it an empty string — a value change, not a gap-fill, and the comment at the call site below previously implied the two matched when they do not.",
|
|
2894
|
+
],
|
|
2895
|
+
provenance: [
|
|
2896
|
+
{
|
|
2897
|
+
kind: "oci",
|
|
2898
|
+
ref: "ghcr.io/pdp-connect/connector/amazon:0.1.0",
|
|
2899
|
+
digest: "sha256:dfddeffbc11c53fa2577c71f45428b87ccb25a69175218621398342a22b415d4",
|
|
2900
|
+
path: "declarations/amazon.collection-profile.json#streams[name=orders,order_items]",
|
|
2901
|
+
},
|
|
2902
|
+
],
|
|
2903
|
+
project(records, options) {
|
|
2904
|
+
const guard = requireStreams("amazon.orders", options, [
|
|
2905
|
+
"orders",
|
|
2906
|
+
"order_items",
|
|
2907
|
+
]);
|
|
2908
|
+
if (guard) {
|
|
2909
|
+
return guard;
|
|
2910
|
+
}
|
|
2911
|
+
const orders = byStream(records, "orders");
|
|
2912
|
+
const items = byStream(records, "order_items");
|
|
2913
|
+
// orderDate: legacy scrapes "MonthName D, YYYY" out of card text via
|
|
2914
|
+
// regex (amazon-playwright.js:184-187); toLegacyLongDate reproduces
|
|
2915
|
+
// that exact text shape from PDPP's ISO order_date, not an ISO
|
|
2916
|
+
// passthrough. Item price: legacy ALWAYS emits the literal constant ''
|
|
2917
|
+
// here (amazon-playwright.js:222, `price: ''`) — it never scrapes a
|
|
2918
|
+
// real price for line items. This binding instead emits PDPP's real
|
|
2919
|
+
// `unit_price` when present, a disclosed value change, see `lossy`
|
|
2920
|
+
// (N-S2), not a match with legacy's own behavior.
|
|
2921
|
+
return {
|
|
2922
|
+
ok: true,
|
|
2923
|
+
payload: {
|
|
2924
|
+
orders: orders.map((o) => ({
|
|
2925
|
+
orderId: orEmptyString(o.id),
|
|
2926
|
+
orderDate: toLegacyLongDate(o.order_date),
|
|
2927
|
+
orderTotal: orEmptyString(o.order_total),
|
|
2928
|
+
deliveryStatus: orEmptyString(o.delivery_status),
|
|
2929
|
+
items: items
|
|
2930
|
+
.filter((i) => i.order_id === o.id)
|
|
2931
|
+
.map((i) => ({
|
|
2932
|
+
name: orEmptyString(i.name),
|
|
2933
|
+
url: orEmptyString(i.url),
|
|
2934
|
+
price: orEmptyString(i.unit_price),
|
|
2935
|
+
})),
|
|
2936
|
+
})),
|
|
2937
|
+
total: orders.length,
|
|
2938
|
+
},
|
|
2939
|
+
};
|
|
2940
|
+
},
|
|
2941
|
+
};
|
|
2942
|
+
// ---------------------------------------------------------------------------
|
|
2943
|
+
// H-E-B 0.5.3 is pinned to the published, signature-verified OCI artifact.
|
|
2944
|
+
// Product identity remains H-E-B's provider product_id.
|
|
2945
|
+
const hebSource = "https://registry.pdpp.dev/connectors/heb";
|
|
2946
|
+
const hebPublishedEvidence = {
|
|
2947
|
+
kind: "oci",
|
|
2948
|
+
ref: "ghcr.io/pdp-connect/connector/heb:0.5.3",
|
|
2949
|
+
digest: "sha256:9de1c9203453b3897305ca99dfe93755f0e5528b3e7e79d0a9e6c8313445c02a",
|
|
2950
|
+
path: "declarations/heb.collection-profile.json#streams[name=profile]",
|
|
2951
|
+
};
|
|
2952
|
+
const hebProfile = {
|
|
2953
|
+
scope: "heb.profile",
|
|
2954
|
+
pdppSource: hebSource,
|
|
2955
|
+
pdppStreams: ["profile"],
|
|
2956
|
+
legacySchemaPath: "heb.profile.json",
|
|
2957
|
+
fieldsRead: { profile: ["name", "email", "phone", "delivery_addresses"] },
|
|
2958
|
+
primaryKey: { profile: ["id"] },
|
|
2959
|
+
lossy: ["fetched_at is not part of the legacy profile payload."],
|
|
2960
|
+
provenance: [hebPublishedEvidence],
|
|
2961
|
+
project(records, options) {
|
|
2962
|
+
const guard = requireStreams("heb.profile", options, ["profile"]);
|
|
2963
|
+
if (guard)
|
|
2964
|
+
return guard;
|
|
2965
|
+
const profiles = byStream(records, "profile");
|
|
2966
|
+
if (profiles.length === 0)
|
|
2967
|
+
return {
|
|
2968
|
+
ok: false,
|
|
2969
|
+
error: { kind: "empty_singleton", scope: "heb.profile" },
|
|
2970
|
+
};
|
|
2971
|
+
if (profiles.length !== 1)
|
|
2972
|
+
return {
|
|
2973
|
+
ok: false,
|
|
2974
|
+
error: {
|
|
2975
|
+
kind: "incomplete_scope",
|
|
2976
|
+
scope: "heb.profile",
|
|
2977
|
+
reason: "profile stream did not contain exactly one record",
|
|
2978
|
+
},
|
|
2979
|
+
};
|
|
2980
|
+
const [p] = profiles;
|
|
2981
|
+
if ((p.name != null && typeof p.name !== "string") ||
|
|
2982
|
+
(p.email != null && typeof p.email !== "string") ||
|
|
2983
|
+
(p.phone != null && typeof p.phone !== "string")) {
|
|
2984
|
+
return {
|
|
2985
|
+
ok: false,
|
|
2986
|
+
error: {
|
|
2987
|
+
kind: "incomplete_scope",
|
|
2988
|
+
scope: "heb.profile",
|
|
2989
|
+
reason: "profile contains a malformed scalar value",
|
|
2990
|
+
},
|
|
2991
|
+
};
|
|
2992
|
+
}
|
|
2993
|
+
if (!Array.isArray(p.delivery_addresses)) {
|
|
2994
|
+
return {
|
|
2995
|
+
ok: false,
|
|
2996
|
+
error: {
|
|
2997
|
+
kind: "incomplete_scope",
|
|
2998
|
+
scope: "heb.profile",
|
|
2999
|
+
reason: "profile.delivery_addresses is unavailable",
|
|
3000
|
+
},
|
|
3001
|
+
};
|
|
3002
|
+
}
|
|
3003
|
+
if (Array.isArray(p.delivery_addresses) &&
|
|
3004
|
+
p.delivery_addresses.some((entry) => entry === null ||
|
|
3005
|
+
typeof entry !== "object" ||
|
|
3006
|
+
typeof entry.address !== "string" ||
|
|
3007
|
+
typeof entry.is_primary !== "boolean" ||
|
|
3008
|
+
!(typeof entry.label === "string" || entry.label === null))) {
|
|
3009
|
+
return {
|
|
3010
|
+
ok: false,
|
|
3011
|
+
error: {
|
|
3012
|
+
kind: "incomplete_scope",
|
|
3013
|
+
scope: "heb.profile",
|
|
3014
|
+
reason: "profile.delivery_addresses contains an unrepresentable address",
|
|
3015
|
+
},
|
|
3016
|
+
};
|
|
3017
|
+
}
|
|
3018
|
+
const deliveryAddresses = p.delivery_addresses.map((entry) => {
|
|
3019
|
+
const a = entry;
|
|
3020
|
+
return {
|
|
3021
|
+
address: a.address,
|
|
3022
|
+
isPrimary: a.is_primary,
|
|
3023
|
+
label: a.label,
|
|
3024
|
+
};
|
|
3025
|
+
});
|
|
3026
|
+
return {
|
|
3027
|
+
ok: true,
|
|
3028
|
+
payload: {
|
|
3029
|
+
name: typeof p.name === "string" ? p.name : null,
|
|
3030
|
+
email: typeof p.email === "string" ? p.email : null,
|
|
3031
|
+
phone: p.phone === undefined || p.phone === null ? null : p.phone,
|
|
3032
|
+
deliveryAddresses,
|
|
3033
|
+
},
|
|
3034
|
+
};
|
|
3035
|
+
},
|
|
3036
|
+
};
|
|
3037
|
+
// H-E-B emits one nutrition outcome for each distinct source order-item
|
|
3038
|
+
// product. Keep the source item stream in this projection so missing outcomes
|
|
3039
|
+
// cannot become a successful but incomplete legacy payload.
|
|
3040
|
+
const hebNutritionFields = [
|
|
3041
|
+
"product_id",
|
|
3042
|
+
"name",
|
|
3043
|
+
"source",
|
|
3044
|
+
"confidence",
|
|
3045
|
+
"calories",
|
|
3046
|
+
"protein_g",
|
|
3047
|
+
"carbs_g",
|
|
3048
|
+
"fat_g",
|
|
3049
|
+
"sodium_mg",
|
|
3050
|
+
"fiber_g",
|
|
3051
|
+
"sugar_g",
|
|
3052
|
+
"saturated_fat_g",
|
|
3053
|
+
"trans_fat_g",
|
|
3054
|
+
"cholesterol_mg",
|
|
3055
|
+
"added_sugar_g",
|
|
3056
|
+
"calcium_mg",
|
|
3057
|
+
"iron_mg",
|
|
3058
|
+
"potassium_mg",
|
|
3059
|
+
"vitamin_d_mcg",
|
|
3060
|
+
"serving_size",
|
|
3061
|
+
"servings_per_container",
|
|
3062
|
+
"upc",
|
|
3063
|
+
"ingredients",
|
|
3064
|
+
"allergens",
|
|
3065
|
+
"category",
|
|
3066
|
+
"highlights",
|
|
3067
|
+
"product_url",
|
|
3068
|
+
];
|
|
3069
|
+
const hebNutrition = {
|
|
3070
|
+
scope: "heb.nutrition",
|
|
3071
|
+
pdppSource: hebSource,
|
|
3072
|
+
pdppStreams: ["nutrition", "orders", "order_items"],
|
|
3073
|
+
legacySchemaPath: "heb.nutrition.json",
|
|
3074
|
+
fieldsRead: {
|
|
3075
|
+
nutrition: hebNutritionFields,
|
|
3076
|
+
orders: [],
|
|
3077
|
+
order_items: ["product_id", "name", "product_url"],
|
|
3078
|
+
},
|
|
3079
|
+
primaryKey: { nutrition: ["id"], orders: ["id"], order_items: ["id"] },
|
|
3080
|
+
lossy: [
|
|
3081
|
+
"The legacy map key is the declared H-E-B product_id; no global UPC identity is inferred.",
|
|
3082
|
+
"fetched_at is omitted.",
|
|
3083
|
+
],
|
|
3084
|
+
provenance: [
|
|
3085
|
+
{
|
|
3086
|
+
...hebPublishedEvidence,
|
|
3087
|
+
path: "declarations/heb.collection-profile.json#streams[name=orders,order_items,nutrition]",
|
|
3088
|
+
},
|
|
3089
|
+
],
|
|
3090
|
+
project(records, options) {
|
|
3091
|
+
const guard = requireStreams("heb.nutrition", options, [
|
|
3092
|
+
"nutrition",
|
|
3093
|
+
"orders",
|
|
3094
|
+
"order_items",
|
|
3095
|
+
]);
|
|
3096
|
+
if (guard)
|
|
3097
|
+
return guard;
|
|
3098
|
+
const orderedProducts = new Map();
|
|
3099
|
+
for (const row of byStream(records, "order_items")) {
|
|
3100
|
+
// The source only creates nutrition targets for order lines with a
|
|
3101
|
+
// product_id, and deduplicates those targets by that id.
|
|
3102
|
+
if (!isNonEmptyString(row.product_id))
|
|
3103
|
+
continue;
|
|
3104
|
+
if (!isNonEmptyString(row.name) ||
|
|
3105
|
+
(row.product_url !== null && !isNonEmptyString(row.product_url))) {
|
|
3106
|
+
return {
|
|
3107
|
+
ok: false,
|
|
3108
|
+
error: {
|
|
3109
|
+
kind: "incomplete_scope",
|
|
3110
|
+
scope: "heb.nutrition",
|
|
3111
|
+
reason: "order item lacks its source product name or URL",
|
|
3112
|
+
},
|
|
3113
|
+
};
|
|
3114
|
+
}
|
|
3115
|
+
// The legacy browser connector deduplicates by product id and retains
|
|
3116
|
+
// the first observed order line as the product's name and URL.
|
|
3117
|
+
if (!orderedProducts.has(row.product_id)) {
|
|
3118
|
+
orderedProducts.set(row.product_id, {
|
|
3119
|
+
name: row.name,
|
|
3120
|
+
productUrl: row.product_url,
|
|
3121
|
+
});
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
const items = Object.create(null);
|
|
3125
|
+
let found = 0, foundUSDA = 0, blocked = 0;
|
|
3126
|
+
for (const n of byStream(records, "nutrition")) {
|
|
3127
|
+
if (!isNonEmptyString(n.product_id) ||
|
|
3128
|
+
!orderedProducts.has(n.product_id) ||
|
|
3129
|
+
n.product_url !== orderedProducts.get(n.product_id)?.productUrl ||
|
|
3130
|
+
typeof n.source !== "string" ||
|
|
3131
|
+
![
|
|
3132
|
+
"heb_product_page",
|
|
3133
|
+
"usda_fdc",
|
|
3134
|
+
"not_found",
|
|
3135
|
+
"error",
|
|
3136
|
+
"blocked",
|
|
3137
|
+
].includes(n.source) ||
|
|
3138
|
+
(n.product_url === null && n.source !== "not_found")) {
|
|
3139
|
+
return {
|
|
3140
|
+
ok: false,
|
|
3141
|
+
error: {
|
|
3142
|
+
kind: "incomplete_scope",
|
|
3143
|
+
scope: "heb.nutrition",
|
|
3144
|
+
reason: "nutrition row lacks a product id, matching product URL, or valid observed outcome",
|
|
3145
|
+
},
|
|
3146
|
+
};
|
|
3147
|
+
}
|
|
3148
|
+
if (Object.hasOwn(items, n.product_id)) {
|
|
3149
|
+
return {
|
|
3150
|
+
ok: false,
|
|
3151
|
+
error: {
|
|
3152
|
+
kind: "incomplete_scope",
|
|
3153
|
+
scope: "heb.nutrition",
|
|
3154
|
+
reason: "duplicate nutrition product id",
|
|
3155
|
+
},
|
|
3156
|
+
};
|
|
3157
|
+
}
|
|
3158
|
+
const item = {
|
|
3159
|
+
name: orderedProducts.get(n.product_id).name,
|
|
3160
|
+
productUrl: n.product_url,
|
|
3161
|
+
source: n.source,
|
|
3162
|
+
};
|
|
3163
|
+
if (["high", "medium", "low"].includes(String(n.confidence))) {
|
|
3164
|
+
item.confidence = n.confidence;
|
|
3165
|
+
}
|
|
3166
|
+
for (const field of hebNutritionFields.slice(4, 19)) {
|
|
3167
|
+
if (n[field] === null || typeof n[field] === "number") {
|
|
3168
|
+
item[field] = n[field];
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
if (n.serving_size === null || typeof n.serving_size === "string")
|
|
3172
|
+
item.servingSize = n.serving_size;
|
|
3173
|
+
if (n.servings_per_container === null ||
|
|
3174
|
+
typeof n.servings_per_container === "string")
|
|
3175
|
+
item.servingsPerContainer = n.servings_per_container;
|
|
3176
|
+
for (const field of [
|
|
3177
|
+
"upc",
|
|
3178
|
+
"ingredients",
|
|
3179
|
+
"allergens",
|
|
3180
|
+
"category",
|
|
3181
|
+
"highlights",
|
|
3182
|
+
]) {
|
|
3183
|
+
if (n[field] !== undefined)
|
|
3184
|
+
item[field] = n[field];
|
|
3185
|
+
}
|
|
3186
|
+
// Preserve the legacy connector's productImageUrl() mapping. These URLs
|
|
3187
|
+
// are deterministic from the observed H-E-B product id; they are not
|
|
3188
|
+
// scraped or inferred from nutrition content.
|
|
3189
|
+
const paddedProductId = n.product_id.padStart(9, "0");
|
|
3190
|
+
item.images = {
|
|
3191
|
+
thumbnail: `https://images.heb.com/is/image/HEBGrocery/prd-small/${paddedProductId}.jpg`,
|
|
3192
|
+
full: `https://images.heb.com/is/image/HEBGrocery/${n.product_id}-1`,
|
|
3193
|
+
};
|
|
3194
|
+
items[n.product_id] = item;
|
|
3195
|
+
if (n.source === "heb_product_page")
|
|
3196
|
+
found++;
|
|
3197
|
+
if (n.source === "usda_fdc")
|
|
3198
|
+
foundUSDA++;
|
|
3199
|
+
if (n.source === "blocked")
|
|
3200
|
+
blocked++;
|
|
3201
|
+
}
|
|
3202
|
+
const total = Object.keys(items).length;
|
|
3203
|
+
if (total !== orderedProducts.size) {
|
|
3204
|
+
return {
|
|
3205
|
+
ok: false,
|
|
3206
|
+
error: {
|
|
3207
|
+
kind: "incomplete_scope",
|
|
3208
|
+
scope: "heb.nutrition",
|
|
3209
|
+
reason: "nutrition outcomes do not cover every ordered product",
|
|
3210
|
+
},
|
|
3211
|
+
};
|
|
3212
|
+
}
|
|
3213
|
+
return {
|
|
3214
|
+
ok: true,
|
|
3215
|
+
payload: {
|
|
3216
|
+
items,
|
|
3217
|
+
coverage: {
|
|
3218
|
+
total,
|
|
3219
|
+
found,
|
|
3220
|
+
foundUSDA,
|
|
3221
|
+
blocked,
|
|
3222
|
+
percentCovered: total
|
|
3223
|
+
? Math.round(((found + foundUSDA) / total) * 100)
|
|
3224
|
+
: 0,
|
|
3225
|
+
},
|
|
3226
|
+
},
|
|
3227
|
+
};
|
|
3228
|
+
},
|
|
3229
|
+
};
|
|
3230
|
+
// Retained legacy H-E-B order projection, unchanged.
|
|
3231
|
+
// ---------------------------------------------------------------------------
|
|
3232
|
+
const hebOrders = {
|
|
3233
|
+
scope: "heb.orders",
|
|
3234
|
+
pdppSource: "https://registry.pdpp.dev/connectors/heb",
|
|
3235
|
+
pdppStreams: ["orders", "order_items"],
|
|
3236
|
+
legacySchemaPath: "heb.orders.json",
|
|
3237
|
+
fieldsRead: {
|
|
3238
|
+
orders: ["id", "order_date", "total_cents", "item_count", "status"],
|
|
3239
|
+
order_items: [
|
|
3240
|
+
"order_id",
|
|
3241
|
+
"name",
|
|
3242
|
+
"product_id",
|
|
3243
|
+
"product_url",
|
|
3244
|
+
"image_url",
|
|
3245
|
+
"quantity",
|
|
3246
|
+
"line_total_cents",
|
|
3247
|
+
],
|
|
3248
|
+
},
|
|
3249
|
+
primaryKey: { orders: ["id"], order_items: ["id"] },
|
|
3250
|
+
lossy: [
|
|
3251
|
+
"orderUrl and address: the signed profile has no equivalent fields; fulfillment_location is not treated as an address.",
|
|
3252
|
+
"status_code, store_name, timeslot_start/end, unfulfilled_count, fulfillment_method, and fetched_at are not represented by the legacy schema.",
|
|
3253
|
+
"total and item price: derived from declared integer cents when present; omitted when absent. No currency or decimal text is guessed.",
|
|
3254
|
+
"the retained collector emitted only product-linked named lines; a source line without either field cannot be safely counted as complete and fails this scope.",
|
|
3255
|
+
"quantity: a declared numeric quantity is converted to its decimal string form to match the legacy connector output type.",
|
|
3256
|
+
],
|
|
3257
|
+
provenance: [
|
|
3258
|
+
{
|
|
3259
|
+
...hebPublishedEvidence,
|
|
3260
|
+
path: "declarations/heb.collection-profile.json#streams[name=orders,order_items]",
|
|
3261
|
+
},
|
|
3262
|
+
],
|
|
3263
|
+
project(records, options) {
|
|
3264
|
+
const guard = requireStreams("heb.orders", options, [
|
|
3265
|
+
"orders",
|
|
3266
|
+
"order_items",
|
|
3267
|
+
]);
|
|
3268
|
+
if (guard)
|
|
3269
|
+
return guard;
|
|
3270
|
+
const sourceItems = byStream(records, "order_items");
|
|
3271
|
+
const sourceOrderIds = new Set(byStream(records, "orders").map((order) => order.id));
|
|
3272
|
+
let skippedItems = 0;
|
|
3273
|
+
let skippedOrders = 0;
|
|
3274
|
+
let orderItemsShortOfDeclaredCount = false;
|
|
3275
|
+
const missingFields = new Set();
|
|
3276
|
+
if (sourceItems.some((item) => !sourceOrderIds.has(item.order_id))) {
|
|
3277
|
+
return {
|
|
3278
|
+
ok: false,
|
|
3279
|
+
error: {
|
|
3280
|
+
kind: "incomplete_scope",
|
|
3281
|
+
scope: "heb.orders",
|
|
3282
|
+
reason: "order_items.order_id has no matching order",
|
|
3283
|
+
},
|
|
3284
|
+
};
|
|
3285
|
+
}
|
|
3286
|
+
const orders = byStream(records, "orders").flatMap((order) => {
|
|
3287
|
+
if (!isNonEmptyString(order.id)) {
|
|
3288
|
+
skippedOrders += 1;
|
|
3289
|
+
missingFields.add("orders.id");
|
|
3290
|
+
return [];
|
|
3291
|
+
}
|
|
3292
|
+
const items = sourceItems
|
|
3293
|
+
.filter((item) => item.order_id === order.id)
|
|
3294
|
+
.flatMap((item) => {
|
|
3295
|
+
if (!isNonEmptyString(item.name) ||
|
|
3296
|
+
!isNonEmptyString(item.product_id)) {
|
|
3297
|
+
skippedItems += 1;
|
|
3298
|
+
if (!isNonEmptyString(item.name))
|
|
3299
|
+
missingFields.add("order_items.name");
|
|
3300
|
+
if (!isNonEmptyString(item.product_id)) {
|
|
3301
|
+
missingFields.add("order_items.product_id");
|
|
3302
|
+
}
|
|
3303
|
+
return [];
|
|
3304
|
+
}
|
|
3305
|
+
const quantity = typeof item.quantity === "number" && Number.isFinite(item.quantity)
|
|
3306
|
+
? String(item.quantity)
|
|
3307
|
+
: null;
|
|
3308
|
+
const price = typeof item.line_total_cents === "number"
|
|
3309
|
+
? item.line_total_cents / 100
|
|
3310
|
+
: undefined;
|
|
3311
|
+
return [
|
|
3312
|
+
{
|
|
3313
|
+
name: item.name,
|
|
3314
|
+
productId: item.product_id,
|
|
3315
|
+
productUrl: typeof item.product_url === "string" ? item.product_url : null,
|
|
3316
|
+
imageUrl: typeof item.image_url === "string" ? item.image_url : null,
|
|
3317
|
+
quantity,
|
|
3318
|
+
...(price === undefined ? {} : { price }),
|
|
3319
|
+
},
|
|
3320
|
+
];
|
|
3321
|
+
});
|
|
3322
|
+
if (typeof order.item_count === "number" &&
|
|
3323
|
+
Number.isInteger(order.item_count) &&
|
|
3324
|
+
order.item_count > items.length) {
|
|
3325
|
+
orderItemsShortOfDeclaredCount = true;
|
|
3326
|
+
}
|
|
3327
|
+
return [
|
|
3328
|
+
{
|
|
3329
|
+
orderId: order.id,
|
|
3330
|
+
items,
|
|
3331
|
+
orderDate: toLegacyLongDate(order.order_date),
|
|
3332
|
+
...(typeof order.total_cents === "number"
|
|
3333
|
+
? { total: order.total_cents / 100 }
|
|
3334
|
+
: {}),
|
|
3335
|
+
...(typeof order.item_count === "number"
|
|
3336
|
+
? { itemCount: order.item_count }
|
|
3337
|
+
: {}),
|
|
3338
|
+
...(typeof order.status === "string" || order.status === null
|
|
3339
|
+
? { status: order.status }
|
|
3340
|
+
: {}),
|
|
3341
|
+
},
|
|
3342
|
+
];
|
|
3343
|
+
});
|
|
3344
|
+
const diagnostics = skippedItems + skippedOrders > 0
|
|
3345
|
+
? [
|
|
3346
|
+
{
|
|
3347
|
+
kind: "records_skipped",
|
|
3348
|
+
scope: "heb.orders",
|
|
3349
|
+
count: skippedItems + skippedOrders,
|
|
3350
|
+
missingFields: [...missingFields],
|
|
3351
|
+
},
|
|
3352
|
+
]
|
|
3353
|
+
: undefined;
|
|
3354
|
+
if (diagnostics) {
|
|
3355
|
+
return {
|
|
3356
|
+
ok: false,
|
|
3357
|
+
error: {
|
|
3358
|
+
kind: "incomplete_scope",
|
|
3359
|
+
scope: "heb.orders",
|
|
3360
|
+
reason: `Cannot preserve every valid order line: ${[...missingFields].join(", ")}`,
|
|
3361
|
+
},
|
|
3362
|
+
};
|
|
3363
|
+
}
|
|
3364
|
+
if (orderItemsShortOfDeclaredCount) {
|
|
3365
|
+
return {
|
|
3366
|
+
ok: false,
|
|
3367
|
+
error: {
|
|
3368
|
+
kind: "incomplete_scope",
|
|
3369
|
+
scope: "heb.orders",
|
|
3370
|
+
reason: "order_items are short of the count H-E-B reported for an order",
|
|
3371
|
+
},
|
|
3372
|
+
};
|
|
3373
|
+
}
|
|
3374
|
+
return {
|
|
3375
|
+
ok: true,
|
|
3376
|
+
payload: {
|
|
3377
|
+
orders,
|
|
3378
|
+
totalOrders: orders.length,
|
|
3379
|
+
totalItems: orders.reduce((total, order) => total + order.items.length, 0),
|
|
3380
|
+
},
|
|
3381
|
+
...(diagnostics ? { diagnostics } : {}),
|
|
3382
|
+
};
|
|
3383
|
+
},
|
|
3384
|
+
};
|
|
3385
|
+
// Whole Foods 0.3.0 is the published, signature-verified OCI artifact.
|
|
3386
|
+
const wholefoodsSource = "https://registry.pdpp.dev/connectors/wholefoods";
|
|
3387
|
+
const wholefoodsEvidence = {
|
|
3388
|
+
kind: "oci",
|
|
3389
|
+
ref: "ghcr.io/pdp-connect/connector/wholefoods:0.3.0",
|
|
3390
|
+
digest: "sha256:873e287a153673bb9b96cae87dfe64e36730e18b30ea2e5c1731bdcff8d4432c",
|
|
3391
|
+
path: "declarations/wholefoods.collection-profile.json",
|
|
3392
|
+
};
|
|
3393
|
+
const wholefoodsProfile = {
|
|
3394
|
+
scope: "wholefoods.profile",
|
|
3395
|
+
pdppSource: wholefoodsSource,
|
|
3396
|
+
pdppStreams: ["profile"],
|
|
3397
|
+
legacySchemaPath: "wholefoods.profile.json",
|
|
3398
|
+
fieldsRead: { profile: ["name", "email"] },
|
|
3399
|
+
primaryKey: { profile: ["id"] },
|
|
3400
|
+
lossy: [
|
|
3401
|
+
"The legacy schema has no account id field; the Amazon customer id is not emitted.",
|
|
3402
|
+
],
|
|
3403
|
+
provenance: [
|
|
3404
|
+
{
|
|
3405
|
+
...wholefoodsEvidence,
|
|
3406
|
+
path: "declarations/wholefoods.collection-profile.json#streams[name=profile]",
|
|
3407
|
+
},
|
|
3408
|
+
],
|
|
3409
|
+
project(records, options) {
|
|
3410
|
+
const guard = requireStreams("wholefoods.profile", options, ["profile"]);
|
|
3411
|
+
if (guard)
|
|
3412
|
+
return guard;
|
|
3413
|
+
const profiles = byStream(records, "profile");
|
|
3414
|
+
if (profiles.length === 0)
|
|
3415
|
+
return {
|
|
3416
|
+
ok: false,
|
|
3417
|
+
error: { kind: "empty_singleton", scope: "wholefoods.profile" },
|
|
3418
|
+
};
|
|
3419
|
+
if (profiles.length !== 1)
|
|
3420
|
+
return {
|
|
3421
|
+
ok: false,
|
|
3422
|
+
error: {
|
|
3423
|
+
kind: "incomplete_scope",
|
|
3424
|
+
scope: "wholefoods.profile",
|
|
3425
|
+
reason: "profile stream did not contain exactly one record",
|
|
3426
|
+
},
|
|
3427
|
+
};
|
|
3428
|
+
const [p] = profiles;
|
|
3429
|
+
if ((p.name != null && typeof p.name !== "string") ||
|
|
3430
|
+
(p.email != null && typeof p.email !== "string")) {
|
|
3431
|
+
return {
|
|
3432
|
+
ok: false,
|
|
3433
|
+
error: {
|
|
3434
|
+
kind: "incomplete_scope",
|
|
3435
|
+
scope: "wholefoods.profile",
|
|
3436
|
+
reason: "profile contains a malformed scalar value",
|
|
3437
|
+
},
|
|
3438
|
+
};
|
|
3439
|
+
}
|
|
3440
|
+
return {
|
|
3441
|
+
ok: true,
|
|
3442
|
+
payload: {
|
|
3443
|
+
name: typeof p.name === "string" ? p.name : null,
|
|
3444
|
+
email: typeof p.email === "string" ? p.email : null,
|
|
3445
|
+
},
|
|
3446
|
+
};
|
|
3447
|
+
},
|
|
3448
|
+
};
|
|
3449
|
+
const wholefoodsOrders = {
|
|
3450
|
+
scope: "wholefoods.orders",
|
|
3451
|
+
pdppSource: wholefoodsSource,
|
|
3452
|
+
pdppStreams: ["orders", "order_items"],
|
|
3453
|
+
legacySchemaPath: "wholefoods.orders.json",
|
|
3454
|
+
fieldsRead: {
|
|
3455
|
+
orders: [
|
|
3456
|
+
"id",
|
|
3457
|
+
"order_date",
|
|
3458
|
+
"order_url",
|
|
3459
|
+
"status",
|
|
3460
|
+
"total_cents",
|
|
3461
|
+
"item_count",
|
|
3462
|
+
],
|
|
3463
|
+
order_items: [
|
|
3464
|
+
"order_id",
|
|
3465
|
+
"name",
|
|
3466
|
+
"product_id",
|
|
3467
|
+
"product_url",
|
|
3468
|
+
"image_url",
|
|
3469
|
+
"quantity",
|
|
3470
|
+
"unit_price_cents",
|
|
3471
|
+
],
|
|
3472
|
+
},
|
|
3473
|
+
primaryKey: { orders: ["id"], order_items: ["id"] },
|
|
3474
|
+
lossy: [
|
|
3475
|
+
"The retained collector emitted only product-linked named lines; a source line without either field fails this scope as incomplete.",
|
|
3476
|
+
"Product identity is the source Amazon ASIN in product_id.",
|
|
3477
|
+
],
|
|
3478
|
+
provenance: [
|
|
3479
|
+
{
|
|
3480
|
+
...wholefoodsEvidence,
|
|
3481
|
+
path: "declarations/wholefoods.collection-profile.json#streams[name=orders,order_items]",
|
|
3482
|
+
},
|
|
3483
|
+
],
|
|
3484
|
+
project(records, options) {
|
|
3485
|
+
const guard = requireStreams("wholefoods.orders", options, [
|
|
3486
|
+
"orders",
|
|
3487
|
+
"order_items",
|
|
3488
|
+
]);
|
|
3489
|
+
if (guard)
|
|
3490
|
+
return guard;
|
|
3491
|
+
const sourceItems = byStream(records, "order_items");
|
|
3492
|
+
const sourceOrders = byStream(records, "orders");
|
|
3493
|
+
const sourceOrderIds = new Set(sourceOrders.map((order) => order.id));
|
|
3494
|
+
if (sourceOrderIds.size !== sourceOrders.length ||
|
|
3495
|
+
sourceOrders.some((order) => !isNonEmptyString(order.id))) {
|
|
3496
|
+
return {
|
|
3497
|
+
ok: false,
|
|
3498
|
+
error: {
|
|
3499
|
+
kind: "incomplete_scope",
|
|
3500
|
+
scope: "wholefoods.orders",
|
|
3501
|
+
reason: "orders contain a missing or duplicate id",
|
|
3502
|
+
},
|
|
3503
|
+
};
|
|
3504
|
+
}
|
|
3505
|
+
const unrepresentableItem = sourceItems.some((item) => !sourceOrderIds.has(item.order_id) ||
|
|
3506
|
+
!isNonEmptyString(item.name) ||
|
|
3507
|
+
!isNonEmptyString(item.product_id));
|
|
3508
|
+
if (unrepresentableItem) {
|
|
3509
|
+
return {
|
|
3510
|
+
ok: false,
|
|
3511
|
+
error: {
|
|
3512
|
+
kind: "incomplete_scope",
|
|
3513
|
+
scope: "wholefoods.orders",
|
|
3514
|
+
reason: "Cannot preserve every valid order line: order_items.name or order_items.product_id is missing",
|
|
3515
|
+
},
|
|
3516
|
+
};
|
|
3517
|
+
}
|
|
3518
|
+
if (sourceOrders.some((order) => typeof order.item_count !== "number" ||
|
|
3519
|
+
!Number.isSafeInteger(order.item_count) ||
|
|
3520
|
+
order.item_count < 0 ||
|
|
3521
|
+
sourceItems.filter((item) => item.order_id === order.id).length !==
|
|
3522
|
+
order.item_count)) {
|
|
3523
|
+
return {
|
|
3524
|
+
ok: false,
|
|
3525
|
+
error: {
|
|
3526
|
+
kind: "incomplete_scope",
|
|
3527
|
+
scope: "wholefoods.orders",
|
|
3528
|
+
reason: "order item count is missing or does not match the observed lines",
|
|
3529
|
+
},
|
|
3530
|
+
};
|
|
3531
|
+
}
|
|
3532
|
+
const orders = byStream(records, "orders").flatMap((o) => {
|
|
3533
|
+
if (!isNonEmptyString(o.id))
|
|
3534
|
+
return [];
|
|
3535
|
+
const items = sourceItems
|
|
3536
|
+
.filter((i) => i.order_id === o.id)
|
|
3537
|
+
.flatMap((i) => {
|
|
3538
|
+
if (!isNonEmptyString(i.name) || !isNonEmptyString(i.product_id))
|
|
3539
|
+
return [];
|
|
3540
|
+
return [
|
|
3541
|
+
{
|
|
3542
|
+
name: i.name,
|
|
3543
|
+
productId: i.product_id,
|
|
3544
|
+
...(typeof i.product_url === "string"
|
|
3545
|
+
? { productUrl: i.product_url }
|
|
3546
|
+
: {}),
|
|
3547
|
+
...(typeof i.image_url === "string"
|
|
3548
|
+
? { imageUrl: i.image_url }
|
|
3549
|
+
: {}),
|
|
3550
|
+
quantity: typeof i.quantity === "number" ? String(i.quantity) : null,
|
|
3551
|
+
price: typeof i.unit_price_cents === "number"
|
|
3552
|
+
? i.unit_price_cents / 100
|
|
3553
|
+
: null,
|
|
3554
|
+
},
|
|
3555
|
+
];
|
|
3556
|
+
});
|
|
3557
|
+
return [
|
|
3558
|
+
{
|
|
3559
|
+
orderId: o.id,
|
|
3560
|
+
items,
|
|
3561
|
+
...(typeof o.order_url === "string" ? { orderUrl: o.order_url } : {}),
|
|
3562
|
+
...(typeof o.order_date === "string" || o.order_date === null
|
|
3563
|
+
? { orderDate: o.order_date }
|
|
3564
|
+
: {}),
|
|
3565
|
+
// The legacy Whole Foods collector labeled every observed order Completed;
|
|
3566
|
+
// source 0.3.0 correctly reports status as unknown (null).
|
|
3567
|
+
...(o.status === null
|
|
3568
|
+
? { status: "Completed" }
|
|
3569
|
+
: typeof o.status === "string"
|
|
3570
|
+
? { status: o.status }
|
|
3571
|
+
: {}),
|
|
3572
|
+
...(typeof o.total_cents === "number"
|
|
3573
|
+
? { total: o.total_cents / 100 }
|
|
3574
|
+
: {}),
|
|
3575
|
+
...(typeof o.item_count === "number"
|
|
3576
|
+
? { itemCount: o.item_count }
|
|
3577
|
+
: {}),
|
|
3578
|
+
},
|
|
3579
|
+
];
|
|
3580
|
+
});
|
|
3581
|
+
return {
|
|
3582
|
+
ok: true,
|
|
3583
|
+
payload: {
|
|
3584
|
+
orders,
|
|
3585
|
+
totalOrders: orders.length,
|
|
3586
|
+
totalItems: orders.reduce((sum, o) => sum + o.items.length, 0),
|
|
3587
|
+
},
|
|
3588
|
+
};
|
|
3589
|
+
},
|
|
3590
|
+
};
|
|
3591
|
+
const wholefoodsNutritionFields = [
|
|
3592
|
+
"product_id",
|
|
3593
|
+
"name",
|
|
3594
|
+
"source",
|
|
3595
|
+
"confidence",
|
|
3596
|
+
"calories",
|
|
3597
|
+
"protein_g",
|
|
3598
|
+
"carbs_g",
|
|
3599
|
+
"fat_g",
|
|
3600
|
+
"sodium_mg",
|
|
3601
|
+
"fiber_g",
|
|
3602
|
+
"sugar_g",
|
|
3603
|
+
"serving_size",
|
|
3604
|
+
"servings_per_container",
|
|
3605
|
+
];
|
|
3606
|
+
const wholefoodsNutrition = {
|
|
3607
|
+
scope: "wholefoods.nutrition",
|
|
3608
|
+
pdppSource: wholefoodsSource,
|
|
3609
|
+
pdppStreams: ["nutrition", "order_items"],
|
|
3610
|
+
legacySchemaPath: "wholefoods.nutrition.json",
|
|
3611
|
+
fieldsRead: {
|
|
3612
|
+
nutrition: wholefoodsNutritionFields,
|
|
3613
|
+
order_items: ["product_id", "name", "product_url", "image_url"],
|
|
3614
|
+
},
|
|
3615
|
+
primaryKey: { nutrition: ["product_id"], order_items: ["id"] },
|
|
3616
|
+
lossy: [
|
|
3617
|
+
"ingredients and allergens are not collected by the current nutrition stream.",
|
|
3618
|
+
"servingsPerContainer is converted from the declared number to the legacy string field.",
|
|
3619
|
+
],
|
|
3620
|
+
provenance: [
|
|
3621
|
+
{
|
|
3622
|
+
...wholefoodsEvidence,
|
|
3623
|
+
path: "declarations/wholefoods.collection-profile.json#streams[name=nutrition]",
|
|
3624
|
+
},
|
|
3625
|
+
],
|
|
3626
|
+
project(records, options) {
|
|
3627
|
+
const guard = requireStreams("wholefoods.nutrition", options, [
|
|
3628
|
+
"nutrition",
|
|
3629
|
+
"order_items",
|
|
3630
|
+
]);
|
|
3631
|
+
if (guard)
|
|
3632
|
+
return guard;
|
|
3633
|
+
const items = Object.create(null);
|
|
3634
|
+
const orderedProducts = new Map();
|
|
3635
|
+
for (const row of byStream(records, "order_items")) {
|
|
3636
|
+
if (!isNonEmptyString(row.product_id) || typeof row.name !== "string")
|
|
3637
|
+
return {
|
|
3638
|
+
ok: false,
|
|
3639
|
+
error: {
|
|
3640
|
+
kind: "incomplete_scope",
|
|
3641
|
+
scope: "wholefoods.nutrition",
|
|
3642
|
+
reason: "order item lacks a source ASIN or name",
|
|
3643
|
+
},
|
|
3644
|
+
};
|
|
3645
|
+
orderedProducts.set(row.product_id, {
|
|
3646
|
+
name: row.name,
|
|
3647
|
+
...(typeof row.product_url === "string"
|
|
3648
|
+
? { productUrl: row.product_url }
|
|
3649
|
+
: {}),
|
|
3650
|
+
...(typeof row.image_url === "string"
|
|
3651
|
+
? { imageUrl: row.image_url }
|
|
3652
|
+
: {}),
|
|
3653
|
+
});
|
|
3654
|
+
}
|
|
3655
|
+
let found = 0, foundUSDA = 0, blocked = 0;
|
|
3656
|
+
const seenNutrition = new Set();
|
|
3657
|
+
for (const n of byStream(records, "nutrition")) {
|
|
3658
|
+
if (!isNonEmptyString(n.product_id) ||
|
|
3659
|
+
typeof n.source !== "string" ||
|
|
3660
|
+
![
|
|
3661
|
+
"wholefoods_product_page",
|
|
3662
|
+
"usda_fdc",
|
|
3663
|
+
"not_found",
|
|
3664
|
+
"error",
|
|
3665
|
+
"blocked",
|
|
3666
|
+
].includes(n.source) ||
|
|
3667
|
+
!orderedProducts.has(n.product_id) ||
|
|
3668
|
+
seenNutrition.has(n.product_id))
|
|
3669
|
+
return {
|
|
3670
|
+
ok: false,
|
|
3671
|
+
error: {
|
|
3672
|
+
kind: "incomplete_scope",
|
|
3673
|
+
scope: "wholefoods.nutrition",
|
|
3674
|
+
reason: "nutrition row has an invalid, duplicate, or unordered product outcome",
|
|
3675
|
+
},
|
|
3676
|
+
};
|
|
3677
|
+
seenNutrition.add(n.product_id);
|
|
3678
|
+
const ordered = orderedProducts.get(n.product_id);
|
|
3679
|
+
const name = typeof n.name === "string" ? n.name : ordered?.name;
|
|
3680
|
+
if (typeof name !== "string")
|
|
3681
|
+
return {
|
|
3682
|
+
ok: false,
|
|
3683
|
+
error: {
|
|
3684
|
+
kind: "incomplete_scope",
|
|
3685
|
+
scope: "wholefoods.nutrition",
|
|
3686
|
+
reason: "nutrition product name is unavailable",
|
|
3687
|
+
},
|
|
3688
|
+
};
|
|
3689
|
+
const item = { name, source: n.source };
|
|
3690
|
+
if (ordered?.productUrl !== undefined)
|
|
3691
|
+
item.productUrl = ordered.productUrl;
|
|
3692
|
+
if (ordered?.imageUrl !== undefined)
|
|
3693
|
+
item.images = ordered.imageUrl;
|
|
3694
|
+
if (["high", "medium", "low"].includes(String(n.confidence))) {
|
|
3695
|
+
item.confidence = n.confidence;
|
|
3696
|
+
}
|
|
3697
|
+
for (const field of [
|
|
3698
|
+
"calories",
|
|
3699
|
+
"protein_g",
|
|
3700
|
+
"carbs_g",
|
|
3701
|
+
"fat_g",
|
|
3702
|
+
"sodium_mg",
|
|
3703
|
+
"fiber_g",
|
|
3704
|
+
"sugar_g",
|
|
3705
|
+
]) {
|
|
3706
|
+
if (n[field] === null || typeof n[field] === "number") {
|
|
3707
|
+
item[field] = n[field];
|
|
3708
|
+
}
|
|
3709
|
+
}
|
|
3710
|
+
if (n.serving_size === null || typeof n.serving_size === "string")
|
|
3711
|
+
item.servingSize = n.serving_size;
|
|
3712
|
+
if (typeof n.servings_per_container === "number")
|
|
3713
|
+
item.servingsPerContainer = String(n.servings_per_container);
|
|
3714
|
+
else if (n.servings_per_container === null)
|
|
3715
|
+
item.servingsPerContainer = null;
|
|
3716
|
+
items[n.product_id] = item;
|
|
3717
|
+
if (n.source === "wholefoods_product_page")
|
|
3718
|
+
found++;
|
|
3719
|
+
if (n.source === "usda_fdc")
|
|
3720
|
+
foundUSDA++;
|
|
3721
|
+
if (n.source === "blocked")
|
|
3722
|
+
blocked++;
|
|
3723
|
+
}
|
|
3724
|
+
const total = orderedProducts.size;
|
|
3725
|
+
if (seenNutrition.size !== total)
|
|
3726
|
+
return {
|
|
3727
|
+
ok: false,
|
|
3728
|
+
error: {
|
|
3729
|
+
kind: "incomplete_scope",
|
|
3730
|
+
scope: "wholefoods.nutrition",
|
|
3731
|
+
reason: "nutrition outcomes do not cover every ordered product",
|
|
3732
|
+
},
|
|
3733
|
+
};
|
|
3734
|
+
return {
|
|
3735
|
+
ok: true,
|
|
3736
|
+
payload: {
|
|
3737
|
+
items,
|
|
3738
|
+
coverage: {
|
|
3739
|
+
total,
|
|
3740
|
+
found,
|
|
3741
|
+
foundUSDA,
|
|
3742
|
+
blocked,
|
|
3743
|
+
percentCovered: total
|
|
3744
|
+
? Math.round(((found + foundUSDA) / total) * 100)
|
|
3745
|
+
: 0,
|
|
3746
|
+
},
|
|
3747
|
+
},
|
|
3748
|
+
};
|
|
3749
|
+
},
|
|
3750
|
+
};
|
|
3751
|
+
// amazon.profile is a documented GAP: no `profile` stream exists on the
|
|
3752
|
+
// amazon collection-profile.json at all (verified: exactly 2 streams,
|
|
3753
|
+
// orders and order_items) — legacy's `name`/`isPrime` have no PDPP source.
|
|
3754
|
+
// iCloud Notes is bound to the signed 0.1.0 collection profile. Desktop
|
|
3755
|
+
// admission and ingest projection are separate gates.
|
|
3756
|
+
const icloudNoteFolders = {
|
|
3757
|
+
scope: "icloud_notes.folders",
|
|
3758
|
+
pdppSource: "https://registry.pdpp.dev/connectors/icloud-notes",
|
|
3759
|
+
pdppStreams: ["folders"],
|
|
3760
|
+
legacySchemaPath: "icloud_notes.folders.json",
|
|
3761
|
+
fieldsRead: { folders: ["id", "name"] },
|
|
3762
|
+
primaryKey: { folders: ["id"] },
|
|
3763
|
+
lossy: [],
|
|
3764
|
+
provenance: [
|
|
3765
|
+
{
|
|
3766
|
+
kind: "oci",
|
|
3767
|
+
ref: "ghcr.io/pdp-connect/connector/icloud-notes:0.1.0",
|
|
3768
|
+
digest: "sha256:fe5497bb79b0d567c30790cc71b1e591b955fe474725679b996441bd70c09944",
|
|
3769
|
+
path: "declarations/icloud-notes.collection-profile.json#streams[name=folders]",
|
|
3770
|
+
},
|
|
3771
|
+
],
|
|
3772
|
+
project(records, options) {
|
|
3773
|
+
const guard = requireStreams("icloud_notes.folders", options, ["folders"]);
|
|
3774
|
+
if (guard)
|
|
3775
|
+
return guard;
|
|
3776
|
+
const folders = byStream(records, "folders");
|
|
3777
|
+
return {
|
|
3778
|
+
ok: true,
|
|
3779
|
+
payload: {
|
|
3780
|
+
folders: folders.map((folder) => ({
|
|
3781
|
+
recordName: orEmptyString(folder.id),
|
|
3782
|
+
title: orEmptyString(folder.name),
|
|
3783
|
+
})),
|
|
3784
|
+
total: folders.length,
|
|
3785
|
+
},
|
|
3786
|
+
};
|
|
3787
|
+
},
|
|
3788
|
+
};
|
|
3789
|
+
const icloudNotes = {
|
|
3790
|
+
scope: "icloud_notes.notes",
|
|
3791
|
+
pdppSource: "https://registry.pdpp.dev/connectors/icloud-notes",
|
|
3792
|
+
pdppStreams: ["notes", "folders"],
|
|
3793
|
+
legacySchemaPath: "icloud_notes.notes.json",
|
|
3794
|
+
fieldsRead: {
|
|
3795
|
+
notes: [
|
|
3796
|
+
"id",
|
|
3797
|
+
"title",
|
|
3798
|
+
"snippet",
|
|
3799
|
+
"folder_id",
|
|
3800
|
+
"is_pinned",
|
|
3801
|
+
"created_at",
|
|
3802
|
+
"modified_at",
|
|
3803
|
+
"has_attachments",
|
|
3804
|
+
"text_content",
|
|
3805
|
+
],
|
|
3806
|
+
folders: ["id", "name"],
|
|
3807
|
+
},
|
|
3808
|
+
primaryKey: { notes: ["id"], folders: ["id"] },
|
|
3809
|
+
lossy: [
|
|
3810
|
+
"userName is always null because the declaration has no account/profile stream for the legacy fullName source.",
|
|
3811
|
+
"folder uses the legacy display-name shape; unresolved folder references fall back to folder_id.",
|
|
3812
|
+
],
|
|
3813
|
+
provenance: [
|
|
3814
|
+
{
|
|
3815
|
+
kind: "oci",
|
|
3816
|
+
ref: "ghcr.io/pdp-connect/connector/icloud-notes:0.1.0",
|
|
3817
|
+
digest: "sha256:fe5497bb79b0d567c30790cc71b1e591b955fe474725679b996441bd70c09944",
|
|
3818
|
+
path: "declarations/icloud-notes.collection-profile.json#streams[name=notes,folders]",
|
|
3819
|
+
},
|
|
3820
|
+
],
|
|
3821
|
+
project(records, options) {
|
|
3822
|
+
const guard = requireStreams("icloud_notes.notes", options, [
|
|
3823
|
+
"notes",
|
|
3824
|
+
"folders",
|
|
3825
|
+
]);
|
|
3826
|
+
if (guard)
|
|
3827
|
+
return guard;
|
|
3828
|
+
const folders = new Map();
|
|
3829
|
+
for (const folder of byStream(records, "folders")) {
|
|
3830
|
+
if (typeof folder.id !== "string" ||
|
|
3831
|
+
folder.id.length === 0 ||
|
|
3832
|
+
typeof folder.name !== "string" ||
|
|
3833
|
+
folder.name.length === 0) {
|
|
3834
|
+
return {
|
|
3835
|
+
ok: false,
|
|
3836
|
+
error: {
|
|
3837
|
+
kind: "invalid_value",
|
|
3838
|
+
scope: "icloud_notes.notes",
|
|
3839
|
+
reason: "iCloud Notes folder is missing a declared required field",
|
|
3840
|
+
},
|
|
3841
|
+
};
|
|
3842
|
+
}
|
|
3843
|
+
folders.set(folder.id, folder.name);
|
|
3844
|
+
}
|
|
3845
|
+
const notes = byStream(records, "notes").map((note) => {
|
|
3846
|
+
if (typeof note.id !== "string" || note.id.length === 0)
|
|
3847
|
+
return undefined;
|
|
3848
|
+
const optionalString = (field) => {
|
|
3849
|
+
const value = note[field];
|
|
3850
|
+
return value == null || typeof value === "string"
|
|
3851
|
+
? (value ?? null)
|
|
3852
|
+
: undefined;
|
|
3853
|
+
};
|
|
3854
|
+
const title = optionalString("title");
|
|
3855
|
+
const snippet = optionalString("snippet");
|
|
3856
|
+
const folderId = optionalString("folder_id");
|
|
3857
|
+
const createdDate = optionalString("created_at");
|
|
3858
|
+
const modifiedDate = optionalString("modified_at");
|
|
3859
|
+
const textContent = optionalString("text_content");
|
|
3860
|
+
if (title === undefined ||
|
|
3861
|
+
snippet === undefined ||
|
|
3862
|
+
folderId === undefined ||
|
|
3863
|
+
createdDate === undefined ||
|
|
3864
|
+
modifiedDate === undefined ||
|
|
3865
|
+
textContent === undefined ||
|
|
3866
|
+
typeof note.is_pinned !== "boolean" ||
|
|
3867
|
+
typeof note.has_attachments !== "boolean")
|
|
3868
|
+
return undefined;
|
|
3869
|
+
return {
|
|
3870
|
+
recordName: note.id,
|
|
3871
|
+
title,
|
|
3872
|
+
snippet,
|
|
3873
|
+
folder: folderId === null ? null : (folders.get(folderId) ?? folderId),
|
|
3874
|
+
isPinned: note.is_pinned,
|
|
3875
|
+
createdDate,
|
|
3876
|
+
modifiedDate,
|
|
3877
|
+
hasAttachments: note.has_attachments,
|
|
3878
|
+
textContent,
|
|
3879
|
+
};
|
|
3880
|
+
});
|
|
3881
|
+
if (notes.some((note) => note === undefined)) {
|
|
3882
|
+
return {
|
|
3883
|
+
ok: false,
|
|
3884
|
+
error: {
|
|
3885
|
+
kind: "invalid_value",
|
|
3886
|
+
scope: "icloud_notes.notes",
|
|
3887
|
+
reason: "iCloud Notes contains a record that does not match its declared schema",
|
|
3888
|
+
},
|
|
3889
|
+
};
|
|
3890
|
+
}
|
|
3891
|
+
return {
|
|
3892
|
+
ok: true,
|
|
3893
|
+
payload: { notes, total: notes.length, userName: null },
|
|
3894
|
+
};
|
|
3895
|
+
},
|
|
3896
|
+
};
|
|
3897
|
+
export const LEGACY_SCOPE_BINDINGS = new Map(Object.entries({
|
|
3898
|
+
"instagram.profile": instagramProfile,
|
|
3899
|
+
"instagram.posts": instagramPosts,
|
|
3900
|
+
"instagram.following": instagramFollowing,
|
|
3901
|
+
"instagram.ads": instagramAds,
|
|
3902
|
+
"github.repositories": githubRepositories,
|
|
3903
|
+
"github.starred": githubStarred,
|
|
3904
|
+
"github.history": githubHistory,
|
|
3905
|
+
"github.profile": githubProfile,
|
|
3906
|
+
"github.events": githubEvents,
|
|
3907
|
+
"github.contributions": githubContributions,
|
|
3908
|
+
"chatgpt.memories": chatgptMemories,
|
|
3909
|
+
"chatgpt.conversations": chatgptConversations,
|
|
3910
|
+
"linkedin.profile": linkedinProfile,
|
|
3911
|
+
"linkedin.connections": linkedinConnections,
|
|
3912
|
+
"linkedin.experience": linkedinExperience,
|
|
3913
|
+
"linkedin.education": linkedinEducation,
|
|
3914
|
+
"linkedin.skills": linkedinSkills,
|
|
3915
|
+
"linkedin.languages": linkedinLanguages,
|
|
3916
|
+
"spotify.profile": spotifyProfile,
|
|
3917
|
+
"spotify.savedTracks": spotifySavedTracks,
|
|
3918
|
+
"spotify.playlists": spotifyPlaylists,
|
|
3919
|
+
"shop.orders": shopOrders,
|
|
3920
|
+
"amazon.orders": amazonOrders,
|
|
3921
|
+
"heb.orders": hebOrders,
|
|
3922
|
+
"heb.profile": hebProfile,
|
|
3923
|
+
"heb.nutrition": hebNutrition,
|
|
3924
|
+
"wholefoods.profile": wholefoodsProfile,
|
|
3925
|
+
"wholefoods.orders": wholefoodsOrders,
|
|
3926
|
+
"wholefoods.nutrition": wholefoodsNutrition,
|
|
3927
|
+
"oura.activity": ouraActivity,
|
|
3928
|
+
"oura.readiness": ouraReadiness,
|
|
3929
|
+
"oura.sleep": ouraSleep,
|
|
3930
|
+
"icloud_notes.notes": icloudNotes,
|
|
3931
|
+
"icloud_notes.folders": icloudNoteFolders,
|
|
3932
|
+
"youtube.profile": youtubeProfile,
|
|
3933
|
+
"youtube.subscriptions": youtubeSubscriptions,
|
|
3934
|
+
"youtube.playlists": youtubePlaylists,
|
|
3935
|
+
"youtube.playlistItems": youtubePlaylistItems,
|
|
3936
|
+
"youtube.likes": youtubeLikes,
|
|
3937
|
+
"youtube.watchLater": youtubeWatchLater,
|
|
3938
|
+
"youtube.history": youtubeHistory,
|
|
3939
|
+
"claude.conversations": claudeConversations,
|
|
3940
|
+
"claude.projects": claudeProjects,
|
|
3941
|
+
}));
|
|
3942
|
+
/** Known legacy contracts checked against signed declarations but not
|
|
3943
|
+
* projectable without fabricating fields or changing the retained contract. */
|
|
3944
|
+
export const LEGACY_SCOPE_GAPS = new Map([]);
|
|
3945
|
+
//# sourceMappingURL=bindings.js.map
|