@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +18 -0
  3. package/assets/icon.svg +1 -0
  4. package/assets/manifest.json +36 -0
  5. package/codegen.ts +26 -0
  6. package/e2e/moss-cli.test.ts +338 -0
  7. package/features/api/fetch-articles.feature +39 -0
  8. package/features/auth/wallet-auth.feature +27 -0
  9. package/features/download/retry-logic.feature +36 -0
  10. package/features/download/self-correcting.feature +83 -0
  11. package/features/download/worker-pool.feature +29 -0
  12. package/features/social/fetch-social-data.feature +40 -0
  13. package/features/steps/api.steps.ts +180 -0
  14. package/features/steps/download.steps.ts +423 -0
  15. package/features/steps/incremental-sync.steps.ts +105 -0
  16. package/features/steps/self-correcting.steps.ts +575 -0
  17. package/features/steps/social.steps.ts +257 -0
  18. package/features/steps/syndication.steps.ts +264 -0
  19. package/features/steps/wallet-auth.steps.ts +185 -0
  20. package/features/sync/article-sync.feature +49 -0
  21. package/features/sync/homepage-grid.feature +43 -0
  22. package/features/sync/incremental-sync.feature +28 -0
  23. package/features/syndication/create-draft.feature +35 -0
  24. package/package.json +58 -0
  25. package/src/__generated__/schema.graphql +4289 -0
  26. package/src/__generated__/types.ts +5355 -0
  27. package/src/__tests__/api.test.ts +678 -0
  28. package/src/__tests__/auth-route.test.ts +38 -0
  29. package/src/__tests__/auth-routing.test.ts +462 -0
  30. package/src/__tests__/auto-detect.test.ts +412 -0
  31. package/src/__tests__/binding-guard.test.ts +256 -0
  32. package/src/__tests__/config.test.ts +212 -0
  33. package/src/__tests__/converter.test.ts +289 -0
  34. package/src/__tests__/credential.test.ts +332 -0
  35. package/src/__tests__/domain.test.ts +341 -0
  36. package/src/__tests__/downloader.test.ts +679 -0
  37. package/src/__tests__/folder-detection.test.ts +289 -0
  38. package/src/__tests__/force-fresh-login.test.ts +236 -0
  39. package/src/__tests__/main.test.ts +2437 -0
  40. package/src/__tests__/progress.test.ts +93 -0
  41. package/src/__tests__/session.test.ts +375 -0
  42. package/src/__tests__/social-integration.test.ts +386 -0
  43. package/src/__tests__/social-sync-logic.test.ts +107 -0
  44. package/src/__tests__/social.test.ts +788 -0
  45. package/src/__tests__/sync.test.ts +1273 -0
  46. package/src/__tests__/syndication-toast-law.test.ts +649 -0
  47. package/src/__tests__/syndication.test.ts +125 -0
  48. package/src/__tests__/test-profile-escape.test.ts +209 -0
  49. package/src/__tests__/url-detect.test.ts +79 -0
  50. package/src/__tests__/utils.test.ts +226 -0
  51. package/src/api.ts +1366 -0
  52. package/src/auth-route.ts +38 -0
  53. package/src/config.ts +80 -0
  54. package/src/converter.ts +305 -0
  55. package/src/credential.ts +329 -0
  56. package/src/domain.ts +183 -0
  57. package/src/downloader.ts +761 -0
  58. package/src/main.ts +2092 -0
  59. package/src/progress.ts +89 -0
  60. package/src/queries/user.graphql +85 -0
  61. package/src/queries/viewer.graphql +104 -0
  62. package/src/social.ts +413 -0
  63. package/src/sync.ts +818 -0
  64. package/src/types.ts +477 -0
  65. package/src/url-detect.ts +49 -0
  66. package/src/utils.ts +305 -0
  67. package/test-fixtures/syndication-test-site/input/index.md +8 -0
  68. package/test-fixtures/syndication-test-site/input/posts/rich-test-article.md +90 -0
  69. package/test-helpers/TEST_ACCOUNT.md +151 -0
  70. package/test-helpers/api-client.ts +252 -0
  71. package/test-helpers/fixtures/articles.ts +147 -0
  72. package/test-helpers/wallet-auth.ts +305 -0
  73. package/test-setup/e2e.ts +93 -0
  74. package/tsconfig.json +23 -0
  75. 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
+ }