@symbiosis-lab/moss-plugin-matters 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +88 -0
- package/README.md +18 -0
- package/assets/icon.svg +1 -0
- package/assets/manifest.json +36 -0
- package/codegen.ts +26 -0
- package/e2e/moss-cli.test.ts +338 -0
- package/features/api/fetch-articles.feature +39 -0
- package/features/auth/wallet-auth.feature +27 -0
- package/features/download/retry-logic.feature +36 -0
- package/features/download/self-correcting.feature +83 -0
- package/features/download/worker-pool.feature +29 -0
- package/features/social/fetch-social-data.feature +40 -0
- package/features/steps/api.steps.ts +180 -0
- package/features/steps/download.steps.ts +423 -0
- package/features/steps/incremental-sync.steps.ts +105 -0
- package/features/steps/self-correcting.steps.ts +575 -0
- package/features/steps/social.steps.ts +257 -0
- package/features/steps/syndication.steps.ts +264 -0
- package/features/steps/wallet-auth.steps.ts +185 -0
- package/features/sync/article-sync.feature +49 -0
- package/features/sync/homepage-grid.feature +43 -0
- package/features/sync/incremental-sync.feature +28 -0
- package/features/syndication/create-draft.feature +35 -0
- package/package.json +58 -0
- package/src/__generated__/schema.graphql +4289 -0
- package/src/__generated__/types.ts +5355 -0
- package/src/__tests__/api.test.ts +678 -0
- package/src/__tests__/auth-route.test.ts +38 -0
- package/src/__tests__/auth-routing.test.ts +462 -0
- package/src/__tests__/auto-detect.test.ts +412 -0
- package/src/__tests__/binding-guard.test.ts +256 -0
- package/src/__tests__/config.test.ts +212 -0
- package/src/__tests__/converter.test.ts +289 -0
- package/src/__tests__/credential.test.ts +332 -0
- package/src/__tests__/domain.test.ts +341 -0
- package/src/__tests__/downloader.test.ts +679 -0
- package/src/__tests__/folder-detection.test.ts +289 -0
- package/src/__tests__/force-fresh-login.test.ts +236 -0
- package/src/__tests__/main.test.ts +2437 -0
- package/src/__tests__/progress.test.ts +93 -0
- package/src/__tests__/session.test.ts +375 -0
- package/src/__tests__/social-integration.test.ts +386 -0
- package/src/__tests__/social-sync-logic.test.ts +107 -0
- package/src/__tests__/social.test.ts +788 -0
- package/src/__tests__/sync.test.ts +1273 -0
- package/src/__tests__/syndication-toast-law.test.ts +649 -0
- package/src/__tests__/syndication.test.ts +125 -0
- package/src/__tests__/test-profile-escape.test.ts +209 -0
- package/src/__tests__/url-detect.test.ts +79 -0
- package/src/__tests__/utils.test.ts +226 -0
- package/src/api.ts +1366 -0
- package/src/auth-route.ts +38 -0
- package/src/config.ts +80 -0
- package/src/converter.ts +305 -0
- package/src/credential.ts +329 -0
- package/src/domain.ts +183 -0
- package/src/downloader.ts +761 -0
- package/src/main.ts +2092 -0
- package/src/progress.ts +89 -0
- package/src/queries/user.graphql +85 -0
- package/src/queries/viewer.graphql +104 -0
- package/src/social.ts +413 -0
- package/src/sync.ts +818 -0
- package/src/types.ts +477 -0
- package/src/url-detect.ts +49 -0
- package/src/utils.ts +305 -0
- package/test-fixtures/syndication-test-site/input/index.md +8 -0
- package/test-fixtures/syndication-test-site/input/posts/rich-test-article.md +90 -0
- package/test-helpers/TEST_ACCOUNT.md +151 -0
- package/test-helpers/api-client.ts +252 -0
- package/test-helpers/fixtures/articles.ts +147 -0
- package/test-helpers/wallet-auth.ts +305 -0
- package/test-setup/e2e.ts +93 -0
- package/tsconfig.json +23 -0
- package/vitest.config.ts +39 -0
package/src/api.ts
ADDED
|
@@ -0,0 +1,1366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphQL API client and authentication for Matters.town
|
|
3
|
+
*
|
|
4
|
+
* Supports two query modes:
|
|
5
|
+
* - "viewer" (production): Uses authenticated viewer queries
|
|
6
|
+
* - "user" (testing): Uses public user queries (no auth required)
|
|
7
|
+
*
|
|
8
|
+
* Configure via environment variables or apiConfig:
|
|
9
|
+
* - MATTERS_API_ENDPOINT: GraphQL endpoint URL
|
|
10
|
+
* - MATTERS_QUERY_MODE: "viewer" or "user"
|
|
11
|
+
* - MATTERS_TEST_USER: Username for user queries in tests
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
MattersArticle,
|
|
16
|
+
MattersDraft,
|
|
17
|
+
MattersCollection,
|
|
18
|
+
MattersUserProfile,
|
|
19
|
+
MattersComment,
|
|
20
|
+
MattersDonation,
|
|
21
|
+
MattersAppreciation,
|
|
22
|
+
MattersDraftWithArticle,
|
|
23
|
+
ViewerArticlesResponse,
|
|
24
|
+
ViewerDraftsResponse,
|
|
25
|
+
ViewerCollectionsResponse,
|
|
26
|
+
ViewerProfileResponse,
|
|
27
|
+
ArticleCommentsResponse,
|
|
28
|
+
ArticleDonationsResponse,
|
|
29
|
+
ArticleAppreciationsResponse,
|
|
30
|
+
ViewerArticleCommentCountsResponse,
|
|
31
|
+
UserArticleCommentCountsResponse,
|
|
32
|
+
PutDraftInput,
|
|
33
|
+
PutDraftResponse,
|
|
34
|
+
PutCollectionInput,
|
|
35
|
+
PutCollectionResponse,
|
|
36
|
+
} from "./types";
|
|
37
|
+
import type {
|
|
38
|
+
UserArticlesQuery,
|
|
39
|
+
UserCollectionsQuery,
|
|
40
|
+
UserProfileQuery,
|
|
41
|
+
} from "./__generated__/types";
|
|
42
|
+
import { httpPost, httpPostMultipart } from "@symbiosis-lab/moss-api";
|
|
43
|
+
import { authHeaderToken, markSessionInvalidated } from "./credential";
|
|
44
|
+
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// Configuration
|
|
47
|
+
// ============================================================================
|
|
48
|
+
|
|
49
|
+
/** Get environment variable safely (works in both Node and browser) */
|
|
50
|
+
function getEnv(key: string): string | undefined {
|
|
51
|
+
if (typeof process !== "undefined" && process.env) {
|
|
52
|
+
return process.env[key];
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** API configuration - can be modified for testing */
|
|
58
|
+
export const apiConfig = {
|
|
59
|
+
/** GraphQL endpoint URL */
|
|
60
|
+
endpoint: getEnv("MATTERS_API_ENDPOINT") || "https://server.matters.town/graphql",
|
|
61
|
+
/** Query mode: "viewer" (requires auth) or "user" (public, for testing) */
|
|
62
|
+
queryMode: (getEnv("MATTERS_QUERY_MODE") || "viewer") as "viewer" | "user",
|
|
63
|
+
/** Username for user queries in test mode */
|
|
64
|
+
testUserName: getEnv("MATTERS_TEST_USER") || "Matty",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** @deprecated Use apiConfig.endpoint instead */
|
|
68
|
+
export const GRAPHQL_ENDPOINT = "https://server.matters.town/graphql";
|
|
69
|
+
|
|
70
|
+
// ============================================================================
|
|
71
|
+
// GraphQL Queries
|
|
72
|
+
// ============================================================================
|
|
73
|
+
|
|
74
|
+
export const ARTICLES_QUERY = `
|
|
75
|
+
query MePublishedArticles($after: String) {
|
|
76
|
+
viewer {
|
|
77
|
+
id
|
|
78
|
+
userName
|
|
79
|
+
articles(input: { first: 50, after: $after, filter: { state: active } }) {
|
|
80
|
+
totalCount
|
|
81
|
+
pageInfo {
|
|
82
|
+
endCursor
|
|
83
|
+
hasNextPage
|
|
84
|
+
}
|
|
85
|
+
edges {
|
|
86
|
+
node {
|
|
87
|
+
id
|
|
88
|
+
title
|
|
89
|
+
slug
|
|
90
|
+
shortHash
|
|
91
|
+
content
|
|
92
|
+
summary
|
|
93
|
+
createdAt
|
|
94
|
+
revisedAt
|
|
95
|
+
tags {
|
|
96
|
+
id
|
|
97
|
+
content
|
|
98
|
+
}
|
|
99
|
+
cover
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
`;
|
|
106
|
+
|
|
107
|
+
export const DRAFTS_QUERY = `
|
|
108
|
+
query MeDrafts($after: String) {
|
|
109
|
+
viewer {
|
|
110
|
+
id
|
|
111
|
+
drafts(input: { first: 50, after: $after }) {
|
|
112
|
+
pageInfo {
|
|
113
|
+
endCursor
|
|
114
|
+
hasNextPage
|
|
115
|
+
}
|
|
116
|
+
edges {
|
|
117
|
+
node {
|
|
118
|
+
id
|
|
119
|
+
title
|
|
120
|
+
content
|
|
121
|
+
summary
|
|
122
|
+
createdAt
|
|
123
|
+
updatedAt
|
|
124
|
+
tags
|
|
125
|
+
cover
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
`;
|
|
132
|
+
|
|
133
|
+
export const COLLECTIONS_QUERY = `
|
|
134
|
+
query MeCollections($after: String) {
|
|
135
|
+
viewer {
|
|
136
|
+
id
|
|
137
|
+
collections(input: { first: 50, after: $after }) {
|
|
138
|
+
totalCount
|
|
139
|
+
pageInfo {
|
|
140
|
+
endCursor
|
|
141
|
+
hasNextPage
|
|
142
|
+
}
|
|
143
|
+
edges {
|
|
144
|
+
node {
|
|
145
|
+
id
|
|
146
|
+
title
|
|
147
|
+
description
|
|
148
|
+
cover
|
|
149
|
+
articles(input: { first: 100 }) {
|
|
150
|
+
edges {
|
|
151
|
+
node {
|
|
152
|
+
id
|
|
153
|
+
shortHash
|
|
154
|
+
title
|
|
155
|
+
slug
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
`;
|
|
165
|
+
|
|
166
|
+
export const PROFILE_QUERY = `
|
|
167
|
+
query MeProfile {
|
|
168
|
+
viewer {
|
|
169
|
+
id
|
|
170
|
+
userName
|
|
171
|
+
displayName
|
|
172
|
+
info {
|
|
173
|
+
description
|
|
174
|
+
profileCover
|
|
175
|
+
}
|
|
176
|
+
avatar
|
|
177
|
+
settings {
|
|
178
|
+
language
|
|
179
|
+
}
|
|
180
|
+
pinnedWorks {
|
|
181
|
+
id
|
|
182
|
+
pinned
|
|
183
|
+
title
|
|
184
|
+
cover
|
|
185
|
+
__typename
|
|
186
|
+
... on Article {
|
|
187
|
+
slug
|
|
188
|
+
shortHash
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
`;
|
|
194
|
+
|
|
195
|
+
// ============================================================================
|
|
196
|
+
// User Queries (for testing - no authentication required)
|
|
197
|
+
// ============================================================================
|
|
198
|
+
|
|
199
|
+
export const USER_ARTICLES_QUERY = `
|
|
200
|
+
query UserArticles($userName: String!, $after: String) {
|
|
201
|
+
user(input: { userName: $userName }) {
|
|
202
|
+
id
|
|
203
|
+
userName
|
|
204
|
+
articles(input: { first: 50, after: $after, filter: { state: active } }) {
|
|
205
|
+
totalCount
|
|
206
|
+
pageInfo {
|
|
207
|
+
endCursor
|
|
208
|
+
hasNextPage
|
|
209
|
+
}
|
|
210
|
+
edges {
|
|
211
|
+
node {
|
|
212
|
+
id
|
|
213
|
+
title
|
|
214
|
+
slug
|
|
215
|
+
shortHash
|
|
216
|
+
content
|
|
217
|
+
summary
|
|
218
|
+
language
|
|
219
|
+
createdAt
|
|
220
|
+
revisedAt
|
|
221
|
+
tags {
|
|
222
|
+
id
|
|
223
|
+
content
|
|
224
|
+
}
|
|
225
|
+
cover
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
`;
|
|
232
|
+
|
|
233
|
+
export const USER_COLLECTIONS_QUERY = `
|
|
234
|
+
query UserCollections($userName: String!, $after: String) {
|
|
235
|
+
user(input: { userName: $userName }) {
|
|
236
|
+
id
|
|
237
|
+
collections(input: { first: 50, after: $after }) {
|
|
238
|
+
totalCount
|
|
239
|
+
pageInfo {
|
|
240
|
+
endCursor
|
|
241
|
+
hasNextPage
|
|
242
|
+
}
|
|
243
|
+
edges {
|
|
244
|
+
node {
|
|
245
|
+
id
|
|
246
|
+
title
|
|
247
|
+
description
|
|
248
|
+
cover
|
|
249
|
+
articles(input: { first: 100 }) {
|
|
250
|
+
edges {
|
|
251
|
+
node {
|
|
252
|
+
id
|
|
253
|
+
shortHash
|
|
254
|
+
title
|
|
255
|
+
slug
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
`;
|
|
265
|
+
|
|
266
|
+
export const USER_PROFILE_QUERY = `
|
|
267
|
+
query UserProfile($userName: String!) {
|
|
268
|
+
user(input: { userName: $userName }) {
|
|
269
|
+
id
|
|
270
|
+
userName
|
|
271
|
+
displayName
|
|
272
|
+
info {
|
|
273
|
+
description
|
|
274
|
+
profileCover
|
|
275
|
+
}
|
|
276
|
+
avatar
|
|
277
|
+
pinnedWorks {
|
|
278
|
+
id
|
|
279
|
+
pinned
|
|
280
|
+
title
|
|
281
|
+
cover
|
|
282
|
+
__typename
|
|
283
|
+
... on Article {
|
|
284
|
+
slug
|
|
285
|
+
shortHash
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
`;
|
|
291
|
+
|
|
292
|
+
// ============================================================================
|
|
293
|
+
// Lightweight comment-count discovery queries
|
|
294
|
+
// ============================================================================
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Fetch only `{shortHash, commentCount}` for every published article. Used to
|
|
298
|
+
* decide which articles need a full comments fetch this sync.
|
|
299
|
+
*/
|
|
300
|
+
export const VIEWER_ARTICLE_COMMENT_COUNTS_QUERY = `
|
|
301
|
+
query ViewerArticleCommentCounts($after: String) {
|
|
302
|
+
viewer {
|
|
303
|
+
id
|
|
304
|
+
articles(input: { first: 50, after: $after, filter: { state: active } }) {
|
|
305
|
+
pageInfo {
|
|
306
|
+
endCursor
|
|
307
|
+
hasNextPage
|
|
308
|
+
}
|
|
309
|
+
edges {
|
|
310
|
+
node {
|
|
311
|
+
shortHash
|
|
312
|
+
commentCount
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
`;
|
|
319
|
+
|
|
320
|
+
export const USER_ARTICLE_COMMENT_COUNTS_QUERY = `
|
|
321
|
+
query UserArticleCommentCounts($userName: String!, $after: String) {
|
|
322
|
+
user(input: { userName: $userName }) {
|
|
323
|
+
id
|
|
324
|
+
articles(input: { first: 50, after: $after, filter: { state: active } }) {
|
|
325
|
+
pageInfo {
|
|
326
|
+
endCursor
|
|
327
|
+
hasNextPage
|
|
328
|
+
}
|
|
329
|
+
edges {
|
|
330
|
+
node {
|
|
331
|
+
shortHash
|
|
332
|
+
commentCount
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
`;
|
|
339
|
+
|
|
340
|
+
// ============================================================================
|
|
341
|
+
// Social Data Queries (Comments, Donations, Appreciations)
|
|
342
|
+
// ============================================================================
|
|
343
|
+
|
|
344
|
+
export const ARTICLE_COMMENTS_QUERY = `
|
|
345
|
+
query ArticleComments($shortHash: String!, $after: String) {
|
|
346
|
+
article(input: { shortHash: $shortHash }) {
|
|
347
|
+
id
|
|
348
|
+
shortHash
|
|
349
|
+
comments(input: { first: 50, after: $after, sort: newest }) {
|
|
350
|
+
totalCount
|
|
351
|
+
pageInfo {
|
|
352
|
+
endCursor
|
|
353
|
+
hasNextPage
|
|
354
|
+
}
|
|
355
|
+
edges {
|
|
356
|
+
node {
|
|
357
|
+
id
|
|
358
|
+
content
|
|
359
|
+
createdAt
|
|
360
|
+
state
|
|
361
|
+
upvotes
|
|
362
|
+
author {
|
|
363
|
+
id
|
|
364
|
+
userName
|
|
365
|
+
displayName
|
|
366
|
+
avatar
|
|
367
|
+
}
|
|
368
|
+
replyTo {
|
|
369
|
+
id
|
|
370
|
+
author {
|
|
371
|
+
userName
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
`;
|
|
380
|
+
|
|
381
|
+
export const ARTICLE_DONATIONS_QUERY = `
|
|
382
|
+
query ArticleDonations($shortHash: String!, $after: String) {
|
|
383
|
+
article(input: { shortHash: $shortHash }) {
|
|
384
|
+
id
|
|
385
|
+
shortHash
|
|
386
|
+
donations(input: { first: 50, after: $after }) {
|
|
387
|
+
totalCount
|
|
388
|
+
pageInfo {
|
|
389
|
+
endCursor
|
|
390
|
+
hasNextPage
|
|
391
|
+
}
|
|
392
|
+
edges {
|
|
393
|
+
node {
|
|
394
|
+
id
|
|
395
|
+
sender {
|
|
396
|
+
id
|
|
397
|
+
userName
|
|
398
|
+
displayName
|
|
399
|
+
avatar
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
`;
|
|
407
|
+
|
|
408
|
+
export const ARTICLE_APPRECIATIONS_QUERY = `
|
|
409
|
+
query ArticleAppreciations($shortHash: String!, $after: String) {
|
|
410
|
+
article(input: { shortHash: $shortHash }) {
|
|
411
|
+
id
|
|
412
|
+
shortHash
|
|
413
|
+
appreciationsReceived(input: { first: 50, after: $after }) {
|
|
414
|
+
totalCount
|
|
415
|
+
pageInfo {
|
|
416
|
+
endCursor
|
|
417
|
+
hasNextPage
|
|
418
|
+
}
|
|
419
|
+
edges {
|
|
420
|
+
node {
|
|
421
|
+
amount
|
|
422
|
+
createdAt
|
|
423
|
+
sender {
|
|
424
|
+
id
|
|
425
|
+
userName
|
|
426
|
+
displayName
|
|
427
|
+
avatar
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
`;
|
|
435
|
+
|
|
436
|
+
// ============================================================================
|
|
437
|
+
// Syndication Mutations (Draft/Collection Creation)
|
|
438
|
+
// ============================================================================
|
|
439
|
+
|
|
440
|
+
export const PUT_DRAFT_MUTATION = `
|
|
441
|
+
mutation PutDraft($input: PutDraftInput!) {
|
|
442
|
+
putDraft(input: $input) {
|
|
443
|
+
id
|
|
444
|
+
title
|
|
445
|
+
content
|
|
446
|
+
summary
|
|
447
|
+
createdAt
|
|
448
|
+
updatedAt
|
|
449
|
+
tags
|
|
450
|
+
cover
|
|
451
|
+
publishState
|
|
452
|
+
article {
|
|
453
|
+
id
|
|
454
|
+
shortHash
|
|
455
|
+
slug
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
`;
|
|
460
|
+
|
|
461
|
+
export const GET_DRAFT_QUERY = `
|
|
462
|
+
query GetDraft($id: ID!) {
|
|
463
|
+
node(input: { id: $id }) {
|
|
464
|
+
... on Draft {
|
|
465
|
+
id
|
|
466
|
+
title
|
|
467
|
+
publishState
|
|
468
|
+
article {
|
|
469
|
+
id
|
|
470
|
+
shortHash
|
|
471
|
+
slug
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
`;
|
|
477
|
+
|
|
478
|
+
export const PUT_COLLECTION_MUTATION = `
|
|
479
|
+
mutation PutCollection($input: PutCollectionInput!) {
|
|
480
|
+
putCollection(input: $input) {
|
|
481
|
+
id
|
|
482
|
+
title
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
`;
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
/** GraphQL extensions.code values that mean "the session is dead". */
|
|
489
|
+
const AUTH_ERROR_CODES = new Set(["TOKEN_INVALID", "UNAUTHENTICATED"]);
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* The Matters server rejected our credential. Matters signals this with
|
|
493
|
+
* HTTP 500 (not 401) + extensions.code, so callers must catch this type
|
|
494
|
+
* rather than match on status.
|
|
495
|
+
*/
|
|
496
|
+
export class MattersAuthError extends Error {
|
|
497
|
+
readonly code: string;
|
|
498
|
+
constructor(code: string, message: string) {
|
|
499
|
+
super(message);
|
|
500
|
+
this.name = "MattersAuthError";
|
|
501
|
+
this.code = code;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
interface GraphqlErrorShape {
|
|
506
|
+
message?: string;
|
|
507
|
+
extensions?: { code?: string };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function findAuthErrorCode(parsed: unknown): string | null {
|
|
511
|
+
const errors = (parsed as { errors?: GraphqlErrorShape[] } | null)?.errors;
|
|
512
|
+
if (!Array.isArray(errors)) return null;
|
|
513
|
+
for (const e of errors) {
|
|
514
|
+
const code = e?.extensions?.code;
|
|
515
|
+
if (code && AUTH_ERROR_CODES.has(code)) return code;
|
|
516
|
+
}
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** One-line, length-capped body excerpt for error messages and logs. */
|
|
521
|
+
function bodySnippet(text: string, max = 120): string {
|
|
522
|
+
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
523
|
+
return oneLine.length <= max ? oneLine : oneLine.slice(0, max) + "…";
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Shared response handling. `authenticated` gates auth-code detection: only
|
|
528
|
+
* the token-bearing path may interpret TOKEN_INVALID/UNAUTHENTICATED as
|
|
529
|
+
* evidence about OUR session and stamp it; the public path sends no token,
|
|
530
|
+
* so the same body there is just a failed request.
|
|
531
|
+
*/
|
|
532
|
+
async function handleGraphqlResponse<T>(
|
|
533
|
+
response: { ok: boolean; status: number; text(): string },
|
|
534
|
+
authenticated: boolean
|
|
535
|
+
): Promise<T> {
|
|
536
|
+
const text = response.text();
|
|
537
|
+
let parsed: unknown = null;
|
|
538
|
+
try {
|
|
539
|
+
parsed = JSON.parse(text);
|
|
540
|
+
} catch {
|
|
541
|
+
// non-JSON body (e.g. an HTML error page); fall through to status check
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (authenticated) {
|
|
545
|
+
const authCode = findAuthErrorCode(parsed);
|
|
546
|
+
if (authCode) {
|
|
547
|
+
await markSessionInvalidated();
|
|
548
|
+
throw new MattersAuthError(authCode, `Matters rejected the session (${authCode})`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
if (!response.ok) {
|
|
552
|
+
throw new Error(`GraphQL request failed (${response.status}): ${bodySnippet(text)}`);
|
|
553
|
+
}
|
|
554
|
+
const result = parsed as { errors?: GraphqlErrorShape[]; data: T } | null;
|
|
555
|
+
if (!result) {
|
|
556
|
+
throw new Error(`GraphQL request failed (${response.status}): non-JSON response: ${bodySnippet(text)}`);
|
|
557
|
+
}
|
|
558
|
+
if (result.errors && result.errors.length > 0) {
|
|
559
|
+
throw new Error(result.errors[0]?.message || "GraphQL error");
|
|
560
|
+
}
|
|
561
|
+
return result.data;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ============================================================================
|
|
565
|
+
// GraphQL Client
|
|
566
|
+
// ============================================================================
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Make authenticated GraphQL request to Matters API
|
|
570
|
+
*/
|
|
571
|
+
export async function graphqlQuery<T>(
|
|
572
|
+
query: string,
|
|
573
|
+
variables?: Record<string, unknown>
|
|
574
|
+
): Promise<T> {
|
|
575
|
+
const token = await authHeaderToken();
|
|
576
|
+
if (!token) {
|
|
577
|
+
throw new Error("No access token available. Please login first.");
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const response = await httpPost(
|
|
581
|
+
apiConfig.endpoint,
|
|
582
|
+
{ query, variables },
|
|
583
|
+
{
|
|
584
|
+
headers: {
|
|
585
|
+
"x-access-token": token,
|
|
586
|
+
},
|
|
587
|
+
timeoutMs: 30000,
|
|
588
|
+
}
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
return handleGraphqlResponse<T>(response, true);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Make public (unauthenticated) GraphQL request to Matters API
|
|
596
|
+
* Used for testing with the `user` field instead of `viewer`
|
|
597
|
+
*/
|
|
598
|
+
export async function graphqlQueryPublic<T>(
|
|
599
|
+
query: string,
|
|
600
|
+
variables?: Record<string, unknown>
|
|
601
|
+
): Promise<T> {
|
|
602
|
+
console.log(`[matters] graphqlQueryPublic: fetching with vars:`, JSON.stringify(variables));
|
|
603
|
+
|
|
604
|
+
const response = await httpPost(
|
|
605
|
+
apiConfig.endpoint,
|
|
606
|
+
{ query, variables },
|
|
607
|
+
{
|
|
608
|
+
headers: {
|
|
609
|
+
"User-Agent": "MattersPlugin/1.0",
|
|
610
|
+
Accept: "application/json",
|
|
611
|
+
},
|
|
612
|
+
timeoutMs: 30000,
|
|
613
|
+
}
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
console.log(`[matters] graphqlQueryPublic: response status ${response.status}`);
|
|
617
|
+
|
|
618
|
+
return handleGraphqlResponse<T>(response, false);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ============================================================================
|
|
622
|
+
// Data Fetching Functions
|
|
623
|
+
// ============================================================================
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Fetch all published articles with pagination
|
|
627
|
+
* Uses viewer query (authenticated) or user query (public) based on apiConfig.queryMode
|
|
628
|
+
*/
|
|
629
|
+
export async function fetchAllArticles(): Promise<{
|
|
630
|
+
articles: MattersArticle[];
|
|
631
|
+
userName: string;
|
|
632
|
+
}> {
|
|
633
|
+
if (apiConfig.queryMode === "user") {
|
|
634
|
+
return fetchUserArticles(apiConfig.testUserName);
|
|
635
|
+
}
|
|
636
|
+
return fetchViewerArticles();
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Fetch articles using authenticated viewer query
|
|
641
|
+
*/
|
|
642
|
+
async function fetchViewerArticles(): Promise<{
|
|
643
|
+
articles: MattersArticle[];
|
|
644
|
+
userName: string;
|
|
645
|
+
}> {
|
|
646
|
+
const allArticles: MattersArticle[] = [];
|
|
647
|
+
let cursor: string | undefined;
|
|
648
|
+
let userName = "";
|
|
649
|
+
|
|
650
|
+
console.log("📡 Fetching published articles from Matters (viewer mode)...");
|
|
651
|
+
|
|
652
|
+
do {
|
|
653
|
+
const data = await graphqlQuery<ViewerArticlesResponse>(ARTICLES_QUERY, {
|
|
654
|
+
after: cursor,
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
if (!data.viewer) {
|
|
658
|
+
throw new Error("Failed to fetch viewer data");
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
userName = data.viewer.userName;
|
|
662
|
+
const { edges, pageInfo } = data.viewer.articles;
|
|
663
|
+
|
|
664
|
+
for (const edge of edges) {
|
|
665
|
+
allArticles.push(edge.node);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
console.log(` Fetched ${allArticles.length}/${data.viewer.articles.totalCount} articles...`);
|
|
669
|
+
|
|
670
|
+
cursor = pageInfo.hasNextPage ? pageInfo.endCursor : undefined;
|
|
671
|
+
} while (cursor);
|
|
672
|
+
|
|
673
|
+
return { articles: allArticles, userName };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Fetch articles using public user query (no authentication required)
|
|
678
|
+
*/
|
|
679
|
+
async function fetchUserArticles(userName: string): Promise<{
|
|
680
|
+
articles: MattersArticle[];
|
|
681
|
+
userName: string;
|
|
682
|
+
}> {
|
|
683
|
+
const allArticles: MattersArticle[] = [];
|
|
684
|
+
let cursor: string | undefined;
|
|
685
|
+
|
|
686
|
+
console.log(`📡 Fetching published articles from Matters (user mode: @${userName})...`);
|
|
687
|
+
|
|
688
|
+
do {
|
|
689
|
+
const data = await graphqlQueryPublic<UserArticlesQuery>(USER_ARTICLES_QUERY, {
|
|
690
|
+
userName,
|
|
691
|
+
after: cursor,
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
if (!data.user) {
|
|
695
|
+
throw new Error(`Failed to fetch user data for @${userName}`);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const { edges, pageInfo } = data.user.articles;
|
|
699
|
+
|
|
700
|
+
if (edges) {
|
|
701
|
+
for (const edge of edges) {
|
|
702
|
+
// Map UserArticlesQuery node to MattersArticle
|
|
703
|
+
allArticles.push({
|
|
704
|
+
id: edge.node.id,
|
|
705
|
+
title: edge.node.title,
|
|
706
|
+
slug: edge.node.slug,
|
|
707
|
+
shortHash: edge.node.shortHash,
|
|
708
|
+
content: edge.node.content,
|
|
709
|
+
summary: edge.node.summary,
|
|
710
|
+
language: edge.node.language ?? undefined,
|
|
711
|
+
createdAt: edge.node.createdAt,
|
|
712
|
+
revisedAt: edge.node.revisedAt ?? undefined,
|
|
713
|
+
cover: edge.node.cover ?? undefined,
|
|
714
|
+
tags: edge.node.tags?.map(t => ({ id: t.id, content: t.content })) ?? [],
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
console.log(` Fetched ${allArticles.length}/${data.user.articles.totalCount} articles...`);
|
|
720
|
+
|
|
721
|
+
cursor = pageInfo.hasNextPage ? (pageInfo.endCursor ?? undefined) : undefined;
|
|
722
|
+
} while (cursor);
|
|
723
|
+
|
|
724
|
+
return { articles: allArticles, userName };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Fetch all drafts with pagination
|
|
729
|
+
* Note: Drafts are only available via viewer query (requires authentication)
|
|
730
|
+
* In user mode, returns an empty array
|
|
731
|
+
*/
|
|
732
|
+
export async function fetchAllDrafts(): Promise<MattersDraft[]> {
|
|
733
|
+
// Drafts require authentication - not available via public user query
|
|
734
|
+
if (apiConfig.queryMode === "user") {
|
|
735
|
+
console.log("📡 Skipping drafts (not available in user mode)...");
|
|
736
|
+
return [];
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const allDrafts: MattersDraft[] = [];
|
|
740
|
+
let cursor: string | undefined;
|
|
741
|
+
|
|
742
|
+
console.log("📡 Fetching drafts from Matters (viewer mode)...");
|
|
743
|
+
|
|
744
|
+
do {
|
|
745
|
+
const data = await graphqlQuery<ViewerDraftsResponse>(DRAFTS_QUERY, {
|
|
746
|
+
after: cursor,
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
if (!data.viewer) {
|
|
750
|
+
throw new Error("Failed to fetch viewer data");
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const { edges, pageInfo } = data.viewer.drafts;
|
|
754
|
+
|
|
755
|
+
for (const edge of edges) {
|
|
756
|
+
allDrafts.push(edge.node);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
console.log(` Fetched ${allDrafts.length} drafts...`);
|
|
760
|
+
|
|
761
|
+
cursor = pageInfo.hasNextPage ? pageInfo.endCursor : undefined;
|
|
762
|
+
} while (cursor);
|
|
763
|
+
|
|
764
|
+
return allDrafts;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Fetch drafts created since a given timestamp
|
|
769
|
+
*
|
|
770
|
+
* Like fetchAllArticlesSince, fetches all drafts and filters client-side.
|
|
771
|
+
* We filter by createdAt only (not updatedAt) for consistency with articles.
|
|
772
|
+
*
|
|
773
|
+
* @param since - ISO timestamp to filter drafts (optional, fetches all if not provided)
|
|
774
|
+
*/
|
|
775
|
+
export async function fetchAllDraftsSince(since?: string): Promise<MattersDraft[]> {
|
|
776
|
+
const drafts = await fetchAllDrafts();
|
|
777
|
+
|
|
778
|
+
if (!since) {
|
|
779
|
+
console.log(` 📅 No lastSyncedAt, returning all ${drafts.length} drafts`);
|
|
780
|
+
return drafts;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const sinceDate = new Date(since);
|
|
784
|
+
const filteredDrafts = drafts.filter((draft) => {
|
|
785
|
+
const draftDate = new Date(draft.createdAt);
|
|
786
|
+
return draftDate > sinceDate;
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
console.log(` 📅 Filtered to ${filteredDrafts.length} new drafts since ${since}`);
|
|
790
|
+
return filteredDrafts;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Fetch all collections with pagination
|
|
795
|
+
* Uses viewer query (authenticated) or user query (public) based on apiConfig.queryMode
|
|
796
|
+
*/
|
|
797
|
+
export async function fetchAllCollections(): Promise<MattersCollection[]> {
|
|
798
|
+
if (apiConfig.queryMode === "user") {
|
|
799
|
+
return fetchUserCollections(apiConfig.testUserName);
|
|
800
|
+
}
|
|
801
|
+
return fetchViewerCollections();
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Fetch collections using authenticated viewer query
|
|
806
|
+
*/
|
|
807
|
+
async function fetchViewerCollections(): Promise<MattersCollection[]> {
|
|
808
|
+
const allCollections: MattersCollection[] = [];
|
|
809
|
+
let cursor: string | undefined;
|
|
810
|
+
|
|
811
|
+
console.log("📡 Fetching collections from Matters (viewer mode)...");
|
|
812
|
+
|
|
813
|
+
do {
|
|
814
|
+
const data = await graphqlQuery<ViewerCollectionsResponse>(COLLECTIONS_QUERY, {
|
|
815
|
+
after: cursor,
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
if (!data.viewer) {
|
|
819
|
+
throw new Error("Failed to fetch viewer data");
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const { edges, pageInfo } = data.viewer.collections;
|
|
823
|
+
|
|
824
|
+
for (const edge of edges) {
|
|
825
|
+
const collection: MattersCollection = {
|
|
826
|
+
id: edge.node.id,
|
|
827
|
+
title: edge.node.title,
|
|
828
|
+
description: edge.node.description,
|
|
829
|
+
cover: edge.node.cover,
|
|
830
|
+
articles: edge.node.articles.edges.map((e) => e.node),
|
|
831
|
+
};
|
|
832
|
+
allCollections.push(collection);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
console.log(` Fetched ${allCollections.length}/${data.viewer.collections.totalCount} collections...`);
|
|
836
|
+
|
|
837
|
+
cursor = pageInfo.hasNextPage ? pageInfo.endCursor : undefined;
|
|
838
|
+
} while (cursor);
|
|
839
|
+
|
|
840
|
+
return allCollections;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Fetch collections using public user query (no authentication required)
|
|
845
|
+
*/
|
|
846
|
+
async function fetchUserCollections(userName: string): Promise<MattersCollection[]> {
|
|
847
|
+
const allCollections: MattersCollection[] = [];
|
|
848
|
+
let cursor: string | undefined;
|
|
849
|
+
|
|
850
|
+
console.log(`📡 Fetching collections from Matters (user mode: @${userName})...`);
|
|
851
|
+
|
|
852
|
+
do {
|
|
853
|
+
const data = await graphqlQueryPublic<UserCollectionsQuery>(USER_COLLECTIONS_QUERY, {
|
|
854
|
+
userName,
|
|
855
|
+
after: cursor,
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
if (!data.user) {
|
|
859
|
+
throw new Error(`Failed to fetch user data for @${userName}`);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const { edges, pageInfo } = data.user.collections;
|
|
863
|
+
|
|
864
|
+
if (edges) {
|
|
865
|
+
for (const edge of edges) {
|
|
866
|
+
const collection: MattersCollection = {
|
|
867
|
+
id: edge.node.id,
|
|
868
|
+
title: edge.node.title,
|
|
869
|
+
description: edge.node.description ?? undefined,
|
|
870
|
+
cover: edge.node.cover ?? undefined,
|
|
871
|
+
articles: edge.node.articles.edges?.map((e) => e.node) ?? [],
|
|
872
|
+
};
|
|
873
|
+
allCollections.push(collection);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
console.log(` Fetched ${allCollections.length}/${data.user.collections.totalCount} collections...`);
|
|
878
|
+
|
|
879
|
+
cursor = pageInfo.hasNextPage ? (pageInfo.endCursor ?? undefined) : undefined;
|
|
880
|
+
} while (cursor);
|
|
881
|
+
|
|
882
|
+
return allCollections;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Fetch user profile including displayName, bio, and language preference
|
|
887
|
+
* Uses viewer query (authenticated) or user query (public) based on apiConfig.queryMode
|
|
888
|
+
*/
|
|
889
|
+
export async function fetchUserProfile(): Promise<MattersUserProfile> {
|
|
890
|
+
if (apiConfig.queryMode === "user") {
|
|
891
|
+
return fetchUserProfilePublic(apiConfig.testUserName);
|
|
892
|
+
}
|
|
893
|
+
return fetchViewerProfile();
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Fetch profile using authenticated viewer query
|
|
898
|
+
*/
|
|
899
|
+
async function fetchViewerProfile(): Promise<MattersUserProfile> {
|
|
900
|
+
console.log("📡 Fetching user profile from Matters (viewer mode)...");
|
|
901
|
+
|
|
902
|
+
const data = await graphqlQuery<ViewerProfileResponse>(PROFILE_QUERY);
|
|
903
|
+
|
|
904
|
+
if (!data.viewer) {
|
|
905
|
+
throw new Error("Failed to fetch user profile");
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const profile: MattersUserProfile = {
|
|
909
|
+
userName: data.viewer.userName,
|
|
910
|
+
displayName: data.viewer.displayName,
|
|
911
|
+
description: data.viewer.info?.description,
|
|
912
|
+
avatar: data.viewer.avatar,
|
|
913
|
+
profileCover: data.viewer.info?.profileCover,
|
|
914
|
+
language: data.viewer.settings?.language,
|
|
915
|
+
pinnedWorks: (data.viewer.pinnedWorks || []).map((work) => ({
|
|
916
|
+
id: work.id,
|
|
917
|
+
type: work.__typename === "Article" ? "article" as const : "collection" as const,
|
|
918
|
+
title: work.title,
|
|
919
|
+
slug: work.__typename === "Article" ? work.slug : undefined,
|
|
920
|
+
shortHash: work.__typename === "Article" ? work.shortHash : undefined,
|
|
921
|
+
cover: work.cover,
|
|
922
|
+
})),
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
console.log(` Profile: ${profile.displayName} (@${profile.userName})`);
|
|
926
|
+
console.log(` Language: ${profile.language || "not set"}`);
|
|
927
|
+
|
|
928
|
+
return profile;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Fetch profile using public user query (no authentication required)
|
|
933
|
+
*/
|
|
934
|
+
async function fetchUserProfilePublic(userName: string): Promise<MattersUserProfile> {
|
|
935
|
+
console.log(`📡 Fetching user profile from Matters (user mode: @${userName})...`);
|
|
936
|
+
|
|
937
|
+
const data = await graphqlQueryPublic<UserProfileQuery>(USER_PROFILE_QUERY, {
|
|
938
|
+
userName,
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
if (!data.user) {
|
|
942
|
+
throw new Error(`Failed to fetch user profile for @${userName}`);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Cast to access pinnedWorks which may not be in the generated types yet
|
|
946
|
+
const userData = data.user as NonNullable<UserProfileQuery["user"]> & {
|
|
947
|
+
pinnedWorks?: Array<{
|
|
948
|
+
id: string;
|
|
949
|
+
pinned: boolean;
|
|
950
|
+
title: string;
|
|
951
|
+
cover?: string;
|
|
952
|
+
__typename?: string;
|
|
953
|
+
slug?: string;
|
|
954
|
+
shortHash?: string;
|
|
955
|
+
}>;
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
const profile: MattersUserProfile = {
|
|
959
|
+
userName: userData.userName ?? userName,
|
|
960
|
+
displayName: userData.displayName ?? userName,
|
|
961
|
+
description: userData.info?.description ?? undefined,
|
|
962
|
+
avatar: userData.avatar ?? undefined,
|
|
963
|
+
profileCover: userData.info?.profileCover ?? undefined,
|
|
964
|
+
language: userData.settings?.language,
|
|
965
|
+
pinnedWorks: (userData.pinnedWorks || []).map((work) => ({
|
|
966
|
+
id: work.id,
|
|
967
|
+
type: work.__typename === "Article" ? "article" as const : "collection" as const,
|
|
968
|
+
title: work.title,
|
|
969
|
+
slug: work.__typename === "Article" ? work.slug : undefined,
|
|
970
|
+
shortHash: work.__typename === "Article" ? work.shortHash : undefined,
|
|
971
|
+
cover: work.cover,
|
|
972
|
+
})),
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
console.log(` Profile: ${profile.displayName} (@${profile.userName})`);
|
|
976
|
+
console.log(` Language: ${profile.language || "not set"}`);
|
|
977
|
+
|
|
978
|
+
return profile;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// ============================================================================
|
|
982
|
+
// Social Data Fetching Functions
|
|
983
|
+
// ============================================================================
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Fetch `commentCount` for every published article in one (paginated) pass.
|
|
987
|
+
*
|
|
988
|
+
* Returns a map keyed by `shortHash`. Used by the social-sync loop to skip
|
|
989
|
+
* the per-article comments query when the count hasn't moved since last sync.
|
|
990
|
+
*
|
|
991
|
+
* Picks the query mode (viewer/user) from `apiConfig.queryMode`, matching
|
|
992
|
+
* `fetchAllArticles()`.
|
|
993
|
+
*/
|
|
994
|
+
export async function fetchAllArticleCommentCounts(): Promise<Map<string, number>> {
|
|
995
|
+
const counts = new Map<string, number>();
|
|
996
|
+
let cursor: string | undefined;
|
|
997
|
+
|
|
998
|
+
if (apiConfig.queryMode === "user") {
|
|
999
|
+
const userName = apiConfig.testUserName;
|
|
1000
|
+
do {
|
|
1001
|
+
const data = await graphqlQueryPublic<UserArticleCommentCountsResponse>(
|
|
1002
|
+
USER_ARTICLE_COMMENT_COUNTS_QUERY,
|
|
1003
|
+
{ userName, after: cursor }
|
|
1004
|
+
);
|
|
1005
|
+
|
|
1006
|
+
const articles = data.user?.articles;
|
|
1007
|
+
if (!articles) break;
|
|
1008
|
+
|
|
1009
|
+
for (const edge of articles.edges) {
|
|
1010
|
+
counts.set(edge.node.shortHash, edge.node.commentCount);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
cursor = articles.pageInfo.hasNextPage ? articles.pageInfo.endCursor : undefined;
|
|
1014
|
+
} while (cursor);
|
|
1015
|
+
} else {
|
|
1016
|
+
do {
|
|
1017
|
+
const data = await graphqlQuery<ViewerArticleCommentCountsResponse>(
|
|
1018
|
+
VIEWER_ARTICLE_COMMENT_COUNTS_QUERY,
|
|
1019
|
+
{ after: cursor }
|
|
1020
|
+
);
|
|
1021
|
+
|
|
1022
|
+
if (!data.viewer) break;
|
|
1023
|
+
|
|
1024
|
+
const { edges, pageInfo } = data.viewer.articles;
|
|
1025
|
+
for (const edge of edges) {
|
|
1026
|
+
counts.set(edge.node.shortHash, edge.node.commentCount);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
cursor = pageInfo.hasNextPage ? pageInfo.endCursor : undefined;
|
|
1030
|
+
} while (cursor);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
return counts;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Fetch all comments for an article with pagination
|
|
1038
|
+
* Works in both authenticated and public modes
|
|
1039
|
+
*/
|
|
1040
|
+
export async function fetchArticleComments(
|
|
1041
|
+
shortHash: string,
|
|
1042
|
+
knownIds?: Set<string>,
|
|
1043
|
+
sinceTimestamp?: string
|
|
1044
|
+
): Promise<MattersComment[]> {
|
|
1045
|
+
const allComments: MattersComment[] = [];
|
|
1046
|
+
let cursor: string | undefined;
|
|
1047
|
+
|
|
1048
|
+
console.log(` 📝 Fetching comments for article ${shortHash}...`);
|
|
1049
|
+
|
|
1050
|
+
do {
|
|
1051
|
+
// Comments can be fetched publicly via article query
|
|
1052
|
+
const data = await graphqlQueryPublic<ArticleCommentsResponse>(
|
|
1053
|
+
ARTICLE_COMMENTS_QUERY,
|
|
1054
|
+
{ shortHash, after: cursor }
|
|
1055
|
+
);
|
|
1056
|
+
|
|
1057
|
+
if (!data.article) {
|
|
1058
|
+
console.warn(` ⚠️ Article ${shortHash} not found`);
|
|
1059
|
+
return [];
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const { edges, pageInfo } = data.article.comments;
|
|
1063
|
+
|
|
1064
|
+
for (const edge of edges) {
|
|
1065
|
+
const node = edge.node;
|
|
1066
|
+
allComments.push({
|
|
1067
|
+
id: node.id,
|
|
1068
|
+
content: node.content,
|
|
1069
|
+
createdAt: node.createdAt,
|
|
1070
|
+
state: node.state as "active" | "archived" | "banned" | "collapsed",
|
|
1071
|
+
upvotes: node.upvotes,
|
|
1072
|
+
author: {
|
|
1073
|
+
id: node.author.id,
|
|
1074
|
+
userName: node.author.userName,
|
|
1075
|
+
displayName: node.author.displayName,
|
|
1076
|
+
avatar: node.author.avatar,
|
|
1077
|
+
},
|
|
1078
|
+
replyToId: node.replyTo?.id,
|
|
1079
|
+
replyToAuthor: node.replyTo?.author?.userName,
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// Early exit: if all comments on this page are already known, no need
|
|
1084
|
+
// to paginate further (comments are sorted newest-first)
|
|
1085
|
+
if (knownIds && knownIds.size > 0 && edges.length > 0) {
|
|
1086
|
+
const allKnown = edges.every(edge => knownIds.has(edge.node.id));
|
|
1087
|
+
if (allKnown) {
|
|
1088
|
+
console.log(` 📝 All comments on this page already known, stopping early`);
|
|
1089
|
+
break;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// Timestamp-based early exit: comments are sorted newest-first, so once
|
|
1094
|
+
// the oldest comment on this page is at or before sinceTimestamp, all
|
|
1095
|
+
// remaining pages are older — stop pagination.
|
|
1096
|
+
if (sinceTimestamp && edges.length > 0) {
|
|
1097
|
+
const oldestOnPage = edges[edges.length - 1].node.createdAt;
|
|
1098
|
+
if (oldestOnPage <= sinceTimestamp) {
|
|
1099
|
+
break;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
cursor = pageInfo.hasNextPage ? pageInfo.endCursor : undefined;
|
|
1104
|
+
} while (cursor);
|
|
1105
|
+
|
|
1106
|
+
// Filter out comments at or before sinceTimestamp (handles the last page
|
|
1107
|
+
// which may contain a mix of old and new comments)
|
|
1108
|
+
if (sinceTimestamp) {
|
|
1109
|
+
const filtered = allComments.filter(c => c.createdAt > sinceTimestamp);
|
|
1110
|
+
console.log(` 📝 Found ${filtered.length} new comments (${allComments.length - filtered.length} older than last sync, skipped)`);
|
|
1111
|
+
return filtered;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
console.log(` 📝 Found ${allComments.length} comments`);
|
|
1115
|
+
return allComments;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* Fetch all donations for an article with pagination
|
|
1120
|
+
* Works in both authenticated and public modes
|
|
1121
|
+
*/
|
|
1122
|
+
export async function fetchArticleDonations(shortHash: string): Promise<MattersDonation[]> {
|
|
1123
|
+
const allDonations: MattersDonation[] = [];
|
|
1124
|
+
let cursor: string | undefined;
|
|
1125
|
+
|
|
1126
|
+
console.log(` 💰 Fetching donations for article ${shortHash}...`);
|
|
1127
|
+
|
|
1128
|
+
do {
|
|
1129
|
+
const data = await graphqlQueryPublic<ArticleDonationsResponse>(
|
|
1130
|
+
ARTICLE_DONATIONS_QUERY,
|
|
1131
|
+
{ shortHash, after: cursor }
|
|
1132
|
+
);
|
|
1133
|
+
|
|
1134
|
+
if (!data.article) {
|
|
1135
|
+
console.warn(` ⚠️ Article ${shortHash} not found`);
|
|
1136
|
+
return [];
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
const { edges, pageInfo } = data.article.donations;
|
|
1140
|
+
|
|
1141
|
+
for (const edge of edges) {
|
|
1142
|
+
const node = edge.node;
|
|
1143
|
+
allDonations.push({
|
|
1144
|
+
id: node.id,
|
|
1145
|
+
sender: {
|
|
1146
|
+
id: node.sender.id,
|
|
1147
|
+
userName: node.sender.userName,
|
|
1148
|
+
displayName: node.sender.displayName,
|
|
1149
|
+
avatar: node.sender.avatar,
|
|
1150
|
+
},
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
cursor = pageInfo.hasNextPage ? pageInfo.endCursor : undefined;
|
|
1155
|
+
} while (cursor);
|
|
1156
|
+
|
|
1157
|
+
console.log(` 💰 Found ${allDonations.length} donations`);
|
|
1158
|
+
return allDonations;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* Fetch all appreciations for an article with pagination
|
|
1163
|
+
* Works in both authenticated and public modes
|
|
1164
|
+
*/
|
|
1165
|
+
export async function fetchArticleAppreciations(shortHash: string): Promise<MattersAppreciation[]> {
|
|
1166
|
+
const allAppreciations: MattersAppreciation[] = [];
|
|
1167
|
+
let cursor: string | undefined;
|
|
1168
|
+
|
|
1169
|
+
console.log(` 👏 Fetching appreciations for article ${shortHash}...`);
|
|
1170
|
+
|
|
1171
|
+
do {
|
|
1172
|
+
const data = await graphqlQueryPublic<ArticleAppreciationsResponse>(
|
|
1173
|
+
ARTICLE_APPRECIATIONS_QUERY,
|
|
1174
|
+
{ shortHash, after: cursor }
|
|
1175
|
+
);
|
|
1176
|
+
|
|
1177
|
+
if (!data.article) {
|
|
1178
|
+
console.warn(` ⚠️ Article ${shortHash} not found`);
|
|
1179
|
+
return [];
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
const { edges, pageInfo } = data.article.appreciationsReceived;
|
|
1183
|
+
|
|
1184
|
+
for (const edge of edges) {
|
|
1185
|
+
const node = edge.node;
|
|
1186
|
+
allAppreciations.push({
|
|
1187
|
+
amount: node.amount,
|
|
1188
|
+
createdAt: node.createdAt,
|
|
1189
|
+
sender: {
|
|
1190
|
+
id: node.sender.id,
|
|
1191
|
+
userName: node.sender.userName,
|
|
1192
|
+
displayName: node.sender.displayName,
|
|
1193
|
+
avatar: node.sender.avatar,
|
|
1194
|
+
},
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
cursor = pageInfo.hasNextPage ? pageInfo.endCursor : undefined;
|
|
1199
|
+
} while (cursor);
|
|
1200
|
+
|
|
1201
|
+
const totalClaps = allAppreciations.reduce((sum, a) => sum + a.amount, 0);
|
|
1202
|
+
console.log(` 👏 Found ${allAppreciations.length} appreciators (${totalClaps} total claps)`);
|
|
1203
|
+
return allAppreciations;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// ============================================================================
|
|
1207
|
+
// Incremental Sync Functions
|
|
1208
|
+
// ============================================================================
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* Fetch articles created since a given timestamp
|
|
1212
|
+
*
|
|
1213
|
+
* Note: The Matters API doesn't support direct datetime filtering on viewer.articles,
|
|
1214
|
+
* so we fetch all articles and filter client-side by createdAt.
|
|
1215
|
+
*
|
|
1216
|
+
* We intentionally filter by createdAt only (not revisedAt) to implement a
|
|
1217
|
+
* "download new content only" model. Remote edits are ignored to avoid
|
|
1218
|
+
* overwriting local changes.
|
|
1219
|
+
*
|
|
1220
|
+
* @param since - ISO timestamp to filter articles (optional, fetches all if not provided)
|
|
1221
|
+
*/
|
|
1222
|
+
export async function fetchAllArticlesSince(since?: string): Promise<{
|
|
1223
|
+
articles: MattersArticle[];
|
|
1224
|
+
userName: string;
|
|
1225
|
+
}> {
|
|
1226
|
+
const { articles, userName } = await fetchAllArticles();
|
|
1227
|
+
|
|
1228
|
+
if (!since) {
|
|
1229
|
+
console.log(` 📅 No lastSyncedAt, returning all ${articles.length} articles`);
|
|
1230
|
+
return { articles, userName };
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const sinceDate = new Date(since);
|
|
1234
|
+
const filteredArticles = articles.filter((article) => {
|
|
1235
|
+
// Filter by createdAt only - ignore revisedAt to avoid overwriting local edits
|
|
1236
|
+
const articleDate = new Date(article.createdAt);
|
|
1237
|
+
return articleDate > sinceDate;
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
console.log(` 📅 Filtered to ${filteredArticles.length} new articles since ${since}`);
|
|
1241
|
+
return { articles: filteredArticles, userName };
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// ============================================================================
|
|
1245
|
+
// Syndication Functions (Draft/Collection Creation)
|
|
1246
|
+
// ============================================================================
|
|
1247
|
+
|
|
1248
|
+
export const SINGLE_FILE_UPLOAD_MUTATION = `
|
|
1249
|
+
mutation SingleFileUpload($input: SingleFileUploadInput!) {
|
|
1250
|
+
singleFileUpload(input: $input) {
|
|
1251
|
+
id
|
|
1252
|
+
path
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
`;
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* Upload an asset to Matters by sending its BYTES via multipart — the same
|
|
1260
|
+
* mechanism Matters' own editor uses — instead of asking Matters to fetch it
|
|
1261
|
+
* by URL.
|
|
1262
|
+
*
|
|
1263
|
+
* Why bytes, not URL: Matters' server cannot reliably fetch assets from
|
|
1264
|
+
* arbitrary deployed sites (e.g. Caddy/moss-seta-hosted sites return
|
|
1265
|
+
* `UNABLE_TO_UPLOAD_FROM_URL`), and the `embedaudio` asset type rejects
|
|
1266
|
+
* url-upload entirely. So callers read the asset bytes from the local build
|
|
1267
|
+
* output (`readSiteFile`, already base64) and POST them here.
|
|
1268
|
+
*
|
|
1269
|
+
* Uses the GraphQL multipart request spec (operations/map/file) via
|
|
1270
|
+
* `httpPostMultipart`. Requires the `apollo-require-preflight` header — without
|
|
1271
|
+
* it Matters' Apollo server rejects the multipart POST as potential CSRF.
|
|
1272
|
+
*
|
|
1273
|
+
* @param base64 - File bytes, base64-encoded (e.g. straight from readSiteFile)
|
|
1274
|
+
* @param filename - Display filename for the upload
|
|
1275
|
+
* @param contentType - MIME type (e.g. "image/jpeg", "audio/mpeg")
|
|
1276
|
+
* @param assetType - Matters AssetType: "embed" (image), "embedaudio" (audio), or "cover"
|
|
1277
|
+
* @param entityId - Draft ID the asset attaches to (required by singleFileUpload)
|
|
1278
|
+
* @returns The uploaded asset's `{ id, path }` (path = the Matters CDN URL)
|
|
1279
|
+
*/
|
|
1280
|
+
export async function uploadAssetMultipart(
|
|
1281
|
+
base64: string,
|
|
1282
|
+
filename: string,
|
|
1283
|
+
contentType: string,
|
|
1284
|
+
assetType: "embed" | "embedaudio" | "cover",
|
|
1285
|
+
entityId: string,
|
|
1286
|
+
): Promise<{ id: string; path: string }> {
|
|
1287
|
+
const token = await authHeaderToken();
|
|
1288
|
+
if (!token) {
|
|
1289
|
+
throw new Error("No access token available. Please login first.");
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
const operations = JSON.stringify({
|
|
1293
|
+
query: SINGLE_FILE_UPLOAD_MUTATION,
|
|
1294
|
+
variables: { input: { type: assetType, entityType: "draft", entityId, file: null } },
|
|
1295
|
+
});
|
|
1296
|
+
const map = JSON.stringify({ "0": ["variables.input.file"] });
|
|
1297
|
+
|
|
1298
|
+
const response = await httpPostMultipart(
|
|
1299
|
+
apiConfig.endpoint,
|
|
1300
|
+
{
|
|
1301
|
+
textFields: [
|
|
1302
|
+
{ name: "operations", value: operations },
|
|
1303
|
+
{ name: "map", value: map },
|
|
1304
|
+
],
|
|
1305
|
+
files: [{ field: "0", filename, contentType, contentBase64: base64 }],
|
|
1306
|
+
},
|
|
1307
|
+
{
|
|
1308
|
+
headers: {
|
|
1309
|
+
"x-access-token": token,
|
|
1310
|
+
// Required: Matters' Apollo server treats a bare multipart POST as a
|
|
1311
|
+
// potential CSRF attack and rejects it without this preflight opt-in.
|
|
1312
|
+
"apollo-require-preflight": "true",
|
|
1313
|
+
},
|
|
1314
|
+
timeoutMs: 60000,
|
|
1315
|
+
},
|
|
1316
|
+
);
|
|
1317
|
+
|
|
1318
|
+
const data = await handleGraphqlResponse<{ singleFileUpload: { id: string; path: string } }>(
|
|
1319
|
+
response,
|
|
1320
|
+
true,
|
|
1321
|
+
);
|
|
1322
|
+
return data.singleFileUpload;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* Create or update a draft on Matters
|
|
1327
|
+
*/
|
|
1328
|
+
export async function createDraft(input: PutDraftInput): Promise<MattersDraftWithArticle> {
|
|
1329
|
+
console.log(` 📝 Creating draft: ${input.title}`);
|
|
1330
|
+
|
|
1331
|
+
const data = await graphqlQuery<PutDraftResponse>(PUT_DRAFT_MUTATION, {
|
|
1332
|
+
input,
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
console.log(` ✅ Draft created with ID: ${data.putDraft.id}`);
|
|
1336
|
+
return data.putDraft;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/**
|
|
1340
|
+
* Fetch a draft by ID to check its publish state
|
|
1341
|
+
*/
|
|
1342
|
+
export async function fetchDraft(draftId: string): Promise<MattersDraftWithArticle | null> {
|
|
1343
|
+
interface GetDraftResponse {
|
|
1344
|
+
node: MattersDraftWithArticle | null;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
const data = await graphqlQuery<GetDraftResponse>(GET_DRAFT_QUERY, {
|
|
1348
|
+
id: draftId,
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
return data.node;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* Create a new collection on Matters
|
|
1356
|
+
*/
|
|
1357
|
+
export async function createCollection(input: PutCollectionInput): Promise<{ id: string; title: string }> {
|
|
1358
|
+
console.log(` 📁 Creating collection: ${input.title}`);
|
|
1359
|
+
|
|
1360
|
+
const data = await graphqlQuery<PutCollectionResponse>(PUT_COLLECTION_MUTATION, {
|
|
1361
|
+
input,
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
console.log(` ✅ Collection created with ID: ${data.putCollection.id}`);
|
|
1365
|
+
return data.putCollection;
|
|
1366
|
+
}
|