@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
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Weighted progress estimation for monotonic 0-100% progress reporting.
3
+ *
4
+ * Each phase has a weight proportional to its typical duration.
5
+ * `overallProgress()` computes a single 0-100 value that increases
6
+ * monotonically across phase boundaries, eliminating the oscillation
7
+ * caused by per-phase (current/total) resets.
8
+ *
9
+ * Design decision: Weights are tuned from observed runtime characteristics.
10
+ * downloading_media and fetching_social dominate because they involve
11
+ * network I/O per article. Authentication and fetching are fast API calls.
12
+ */
13
+
14
+ const PHASE_WEIGHTS = [
15
+ { name: "authentication", weight: 5 },
16
+ { name: "fetching_articles", weight: 5 },
17
+ { name: "fetching_drafts", weight: 3 },
18
+ { name: "fetching_collections", weight: 2 },
19
+ { name: "fetching_profile", weight: 2 },
20
+ { name: "syncing", weight: 13 },
21
+ { name: "downloading_media", weight: 35 },
22
+ { name: "rewriting_links", weight: 5 },
23
+ { name: "fetching_social", weight: 25 },
24
+ { name: "complete", weight: 2 },
25
+ ] as const;
26
+
27
+ const TOTAL_WEIGHT = PHASE_WEIGHTS.reduce((s, p) => s + p.weight, 0);
28
+
29
+ /**
30
+ * Map sub-phase names to their parent phase.
31
+ * sync.ts reports granular sub-phases (syncing_homepage, syncing_collections,
32
+ * syncing_articles, syncing_drafts) that all fall within the "syncing" weight band.
33
+ */
34
+ const SUB_PHASE_MAP: Record<string, string> = {
35
+ syncing_homepage: "syncing",
36
+ syncing_collections: "syncing",
37
+ syncing_articles: "syncing",
38
+ syncing_drafts: "syncing",
39
+ };
40
+
41
+ /**
42
+ * Sink for import sub-phase progress. Shaped to match the old `reportProgress`
43
+ * call sites — `current`/`total` are an absolute 0-100 overall value (as
44
+ * returned by {@link overallProgress}) over `total = 100`. The wired reporter
45
+ * (in `main.ts`) converts that to the unified task's 0-1 fraction and forwards
46
+ * it to `task.progress()`. Threaded into the long sub-phases (media download,
47
+ * per-item sync) so the import hairline advances THROUGH them instead of
48
+ * stalling — replacing the legacy SDK `reportProgress` path, which the progress
49
+ * panel drops for the `process` hook.
50
+ */
51
+ export type ProgressReporter = (
52
+ phase: string,
53
+ current: number,
54
+ total: number,
55
+ message?: string,
56
+ ) => void;
57
+
58
+ /**
59
+ * Compute overall progress (0-100) given current phase and progress within it.
60
+ *
61
+ * @param phase - Current phase name (must match a PHASE_WEIGHTS entry or SUB_PHASE_MAP key)
62
+ * @param current - Current item within the phase
63
+ * @param total - Total items in the phase
64
+ * @returns Integer 0-100 representing overall progress
65
+ */
66
+ export function overallProgress(phase: string, current: number, total: number): number {
67
+ // Resolve sub-phases to their parent
68
+ const resolvedPhase = SUB_PHASE_MAP[phase] ?? phase;
69
+
70
+ let done = 0;
71
+ let found = false;
72
+
73
+ for (const p of PHASE_WEIGHTS) {
74
+ if (p.name === resolvedPhase) {
75
+ done += p.weight * (total > 0 ? Math.min(current / total, 1) : 0);
76
+ found = true;
77
+ break;
78
+ }
79
+ done += p.weight;
80
+ }
81
+
82
+ // Unknown phase: return 0 to stay safe (monotonicity preserved since
83
+ // the caller should only use known phases)
84
+ if (!found) {
85
+ return 0;
86
+ }
87
+
88
+ return Math.round((done / TOTAL_WEIGHT) * 100);
89
+ }
@@ -0,0 +1,85 @@
1
+ # Testing queries - no authentication required
2
+ # These queries use the `user` field which returns public user data
3
+ # Used for E2E tests against matters.icu without credentials
4
+
5
+ query UserArticles($userName: String!, $after: String) {
6
+ user(input: { userName: $userName }) {
7
+ id
8
+ userName
9
+ articles(input: { first: 50, after: $after, filter: { state: active } }) {
10
+ totalCount
11
+ pageInfo {
12
+ endCursor
13
+ hasNextPage
14
+ }
15
+ edges {
16
+ node {
17
+ id
18
+ title
19
+ slug
20
+ shortHash
21
+ content
22
+ summary
23
+ language
24
+ createdAt
25
+ revisedAt
26
+ tags {
27
+ id
28
+ content
29
+ }
30
+ cover
31
+ }
32
+ }
33
+ }
34
+ }
35
+ }
36
+
37
+ # Note: Drafts are NOT available via user query (requires authentication)
38
+ # For testing draft functionality, use mocked data
39
+
40
+ query UserCollections($userName: String!, $after: String) {
41
+ user(input: { userName: $userName }) {
42
+ id
43
+ collections(input: { first: 50, after: $after }) {
44
+ totalCount
45
+ pageInfo {
46
+ endCursor
47
+ hasNextPage
48
+ }
49
+ edges {
50
+ node {
51
+ id
52
+ title
53
+ description
54
+ cover
55
+ articles(input: { first: 100 }) {
56
+ edges {
57
+ node {
58
+ id
59
+ shortHash
60
+ title
61
+ slug
62
+ }
63
+ }
64
+ }
65
+ }
66
+ }
67
+ }
68
+ }
69
+ }
70
+
71
+ query UserProfile($userName: String!) {
72
+ user(input: { userName: $userName }) {
73
+ id
74
+ userName
75
+ displayName
76
+ info {
77
+ description
78
+ profileCover
79
+ }
80
+ avatar
81
+ settings {
82
+ language
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,104 @@
1
+ # Production queries - require authentication via x-access-token header
2
+ # These queries use the `viewer` field which returns the authenticated user
3
+
4
+ query ViewerArticles($after: String) {
5
+ viewer {
6
+ id
7
+ userName
8
+ articles(input: { first: 50, after: $after, filter: { state: active } }) {
9
+ totalCount
10
+ pageInfo {
11
+ endCursor
12
+ hasNextPage
13
+ }
14
+ edges {
15
+ node {
16
+ id
17
+ title
18
+ slug
19
+ shortHash
20
+ content
21
+ summary
22
+ createdAt
23
+ revisedAt
24
+ tags {
25
+ id
26
+ content
27
+ }
28
+ cover
29
+ }
30
+ }
31
+ }
32
+ }
33
+ }
34
+
35
+ query ViewerDrafts($after: String) {
36
+ viewer {
37
+ id
38
+ drafts(input: { first: 50, after: $after }) {
39
+ pageInfo {
40
+ endCursor
41
+ hasNextPage
42
+ }
43
+ edges {
44
+ node {
45
+ id
46
+ title
47
+ content
48
+ summary
49
+ createdAt
50
+ updatedAt
51
+ tags
52
+ cover
53
+ }
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ query ViewerCollections($after: String) {
60
+ viewer {
61
+ id
62
+ collections(input: { first: 50, after: $after }) {
63
+ totalCount
64
+ pageInfo {
65
+ endCursor
66
+ hasNextPage
67
+ }
68
+ edges {
69
+ node {
70
+ id
71
+ title
72
+ description
73
+ cover
74
+ articles(input: { first: 100 }) {
75
+ edges {
76
+ node {
77
+ id
78
+ shortHash
79
+ title
80
+ slug
81
+ }
82
+ }
83
+ }
84
+ }
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ query ViewerProfile {
91
+ viewer {
92
+ id
93
+ userName
94
+ displayName
95
+ info {
96
+ description
97
+ profileCover
98
+ }
99
+ avatar
100
+ settings {
101
+ language
102
+ }
103
+ }
104
+ }
package/src/social.ts ADDED
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Social data storage module for Matters plugin
3
+ *
4
+ * Stores social interactions (comments, donations, appreciations) in
5
+ * .moss/data/social/matters.json (moved from .moss/social/ in commit 3436fd636).
6
+ *
7
+ * Schema Documentation:
8
+ * ---------------------
9
+ * The file stores a MattersSocialData object with:
10
+ * - schemaVersion: "1.0.0" - Version for future migrations
11
+ * - updatedAt: ISO timestamp of last update
12
+ * - articles: Map of source .md path (project-relative) to ArticleSocialData
13
+ *
14
+ * Each ArticleSocialData contains:
15
+ * - comments: Array of MattersComment
16
+ * - donations: Array of MattersDonation
17
+ * - appreciations: Array of MattersAppreciation
18
+ *
19
+ * Merge Strategy:
20
+ * ---------------
21
+ * When syncing, we use upsert semantics:
22
+ * - New items (by ID) are added
23
+ * - Existing items (by ID) are updated
24
+ * - Items are NEVER removed (to preserve data from different sync runs)
25
+ *
26
+ * This allows multiple plugins to write to separate files in .moss/data/social/
27
+ * and moss can aggregate them when rendering.
28
+ */
29
+
30
+ import { writeFile, readFile, fileExists } from "@symbiosis-lab/moss-api";
31
+ import type {
32
+ MattersSocialData,
33
+ ArticleSocialData,
34
+ MattersComment,
35
+ MattersDonation,
36
+ MattersAppreciation,
37
+ } from "./types";
38
+
39
+ // ============================================================================
40
+ // Constants
41
+ // ============================================================================
42
+
43
+ /** Canonical path: written and read by both the plugin and moss readers. */
44
+ const SOCIAL_FILE_PATH = ".moss/data/social/matters.json";
45
+
46
+ /**
47
+ * Legacy path written by plugin versions prior to commit 3436fd636 (Apr 8).
48
+ * moss readers moved to .moss/data/social/ but the plugin continued writing
49
+ * the old path — see issue #793. reconcileLegacySocialData() detects this
50
+ * file, merges it into SOCIAL_FILE_PATH, and renames it to
51
+ * LEGACY_SOCIAL_FILE_MIGRATED so the one-time migration is idempotent.
52
+ */
53
+ const LEGACY_SOCIAL_FILE_PATH = ".moss/social/matters.json";
54
+ const LEGACY_SOCIAL_FILE_MIGRATED = ".moss/social/matters.json.migrated-bak";
55
+ const SCHEMA_VERSION = "1.0.0";
56
+
57
+ // ============================================================================
58
+ // Legacy Migration
59
+ // ============================================================================
60
+
61
+ /**
62
+ * Merge comments from legacy into current, deduped by ID.
63
+ * Prefers the entry with MORE comments when both sides have the same article.
64
+ */
65
+ export function mergeCommentsDeduped(
66
+ current: MattersComment[],
67
+ legacy: MattersComment[]
68
+ ): MattersComment[] {
69
+ const commentMap = new Map<string, MattersComment>();
70
+ for (const c of current) commentMap.set(c.id, c);
71
+ for (const c of legacy) {
72
+ if (!commentMap.has(c.id)) commentMap.set(c.id, c);
73
+ }
74
+ return Array.from(commentMap.values());
75
+ }
76
+
77
+ /**
78
+ * Reconcile legacy .moss/social/matters.json into .moss/data/social/matters.json.
79
+ *
80
+ * One-time migration: if the legacy file exists, its articles are union-merged
81
+ * into `current` (the data already loaded from the canonical path):
82
+ *
83
+ * - shortHash-keyed entries in legacy are remapped → uid via `shortHashToUid`.
84
+ * - uid-keyed entries merge directly.
85
+ * - unknown keys carry over as-is (archive).
86
+ * - Comments are deduped by ID; the side with MORE comments wins per article.
87
+ * - `lastKnownCommentCount` is cleared when it exceeds the actual stored count
88
+ * (poisoned entries) so the next sync refetches.
89
+ *
90
+ * After merging, the result is written to SOCIAL_FILE_PATH and the legacy file
91
+ * is renamed to LEGACY_SOCIAL_FILE_MIGRATED. Idempotent: if legacy file does
92
+ * not exist (or the migrated-bak file already exists) this is a no-op.
93
+ *
94
+ * @param current - Already-loaded canonical store (mutated in place).
95
+ * @param shortHashToUid - Mapping produced by scanLocalArticles(): shortHash → uid.
96
+ * @returns `true` if a migration was performed, `false` if no-op.
97
+ */
98
+ export async function reconcileLegacySocialData(
99
+ current: MattersSocialData,
100
+ shortHashToUid: Map<string, string>
101
+ ): Promise<boolean> {
102
+ // Idempotent guard: legacy file must exist and not yet migrated.
103
+ const legacyExists = await fileExists(LEGACY_SOCIAL_FILE_PATH);
104
+ if (!legacyExists) return false;
105
+
106
+ const migratedExists = await fileExists(LEGACY_SOCIAL_FILE_MIGRATED);
107
+ if (migratedExists) return false;
108
+
109
+ // Read the legacy file ONCE and reuse the content for both parse and bak-copy.
110
+ let legacyContent: string;
111
+ let legacyData: MattersSocialData;
112
+ try {
113
+ legacyContent = await readFile(LEGACY_SOCIAL_FILE_PATH);
114
+ legacyData = JSON.parse(legacyContent) as MattersSocialData;
115
+ if (!legacyData.schemaVersion || !legacyData.articles) {
116
+ console.warn("[matters] Legacy social file invalid — skipping reconcile");
117
+ return false;
118
+ }
119
+ } catch (e) {
120
+ console.warn(`[matters] Could not read legacy social file: ${e}`);
121
+ return false;
122
+ }
123
+
124
+ console.log(`[matters] Reconciling legacy social data (${Object.keys(legacyData.articles).length} entries)`);
125
+
126
+ for (const [legacyKey, legacyArticle] of Object.entries(legacyData.articles)) {
127
+ // Resolve the canonical key: remap shortHash → uid if we have a mapping.
128
+ const uid = shortHashToUid.get(legacyKey);
129
+ const canonicalKey = uid ?? legacyKey;
130
+
131
+ const existing = current.articles[canonicalKey];
132
+ if (existing) {
133
+ // Prefer the richer side (more comments) then deduplicate.
134
+ const merged = existing.comments.length >= legacyArticle.comments.length
135
+ ? mergeCommentsDeduped(existing.comments, legacyArticle.comments)
136
+ : mergeCommentsDeduped(legacyArticle.comments, existing.comments);
137
+
138
+ // Clear a poisoned lastKnownCommentCount (stored count > actual comments).
139
+ const mergedCount = merged.length;
140
+ const storedCount = existing.lastKnownCommentCount;
141
+ const clearCount = storedCount !== undefined && storedCount > mergedCount;
142
+
143
+ current.articles[canonicalKey] = {
144
+ ...existing,
145
+ comments: merged,
146
+ lastKnownCommentCount: clearCount ? undefined : storedCount,
147
+ };
148
+ } else {
149
+ // No existing entry — bring the legacy article in as-is.
150
+ const storedCount = legacyArticle.lastKnownCommentCount;
151
+ const clearCount =
152
+ storedCount !== undefined && storedCount > legacyArticle.comments.length;
153
+ current.articles[canonicalKey] = {
154
+ ...legacyArticle,
155
+ lastKnownCommentCount: clearCount ? undefined : storedCount,
156
+ };
157
+ }
158
+ }
159
+
160
+ // Write the merged result to the canonical path.
161
+ await saveSocialData(current);
162
+
163
+ // Retire the legacy file by writing a migrated-bak copy (reuse already-read content).
164
+ try {
165
+ await writeFile(LEGACY_SOCIAL_FILE_MIGRATED, legacyContent);
166
+ console.log("[matters] Legacy social file archived to .migrated-bak");
167
+ } catch (e) {
168
+ // Non-fatal: canonical data is already saved; the guard on migratedExists
169
+ // will run the migration again on the next sync, which is safe (idempotent
170
+ // merge). Log only so operators can inspect.
171
+ console.warn(`[matters] Could not write migrated-bak (will retry next sync): ${e}`);
172
+ return true;
173
+ }
174
+
175
+ // Overwrite legacy path with a forwarding stub so naive readers see a message
176
+ // rather than stale data.
177
+ try {
178
+ const stub = JSON.stringify({
179
+ schemaVersion: "1.0.0",
180
+ updatedAt: new Date().toISOString(),
181
+ articles: {},
182
+ _migrated: true,
183
+ _note: "Data moved to .moss/data/social/matters.json (issue #793)",
184
+ }, null, 2);
185
+ await writeFile(LEGACY_SOCIAL_FILE_PATH, stub);
186
+ } catch {
187
+ // Non-fatal; backed-up copy is already in migrated-bak.
188
+ }
189
+
190
+ console.log(`[matters] Legacy reconcile complete: ${Object.keys(legacyData.articles).length} entries merged`);
191
+ return true;
192
+ }
193
+
194
+ // ============================================================================
195
+ // Core Functions
196
+ // ============================================================================
197
+
198
+ /**
199
+ * Create an empty social data structure
200
+ */
201
+ function createEmptySocialData(): MattersSocialData {
202
+ return {
203
+ schemaVersion: SCHEMA_VERSION,
204
+ updatedAt: new Date().toISOString(),
205
+ articles: {},
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Create an empty article social data structure
211
+ */
212
+ function createEmptyArticleSocialData(): ArticleSocialData {
213
+ return {
214
+ comments: [],
215
+ donations: [],
216
+ appreciations: [],
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Load social data from .moss/data/social/matters.json
222
+ *
223
+ * Returns empty data structure if file doesn't exist or is invalid.
224
+ */
225
+ export async function loadSocialData(): Promise<MattersSocialData> {
226
+ try {
227
+ const content = await readFile(SOCIAL_FILE_PATH);
228
+ const data = JSON.parse(content) as MattersSocialData;
229
+
230
+ // Validate schema version
231
+ if (!data.schemaVersion || !data.articles) {
232
+ console.warn("Invalid social data file, creating new one");
233
+ return createEmptySocialData();
234
+ }
235
+
236
+ return data;
237
+ } catch {
238
+ // File doesn't exist or is invalid
239
+ return createEmptySocialData();
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Save social data to .moss/data/social/matters.json
245
+ *
246
+ * Creates the .moss/data/social/ directory if it doesn't exist (handled by writeFile).
247
+ *
248
+ * @throws Error if the file cannot be written (permissions, disk full, etc.)
249
+ */
250
+ export async function saveSocialData(data: MattersSocialData): Promise<void> {
251
+ data.updatedAt = new Date().toISOString();
252
+ const content = JSON.stringify(data, null, 2);
253
+
254
+ console.log(`[matters] saveSocialData: Writing ${content.length} bytes to ${SOCIAL_FILE_PATH}`);
255
+ console.log(`[matters] saveSocialData: ${Object.keys(data.articles).length} articles in data`);
256
+
257
+ try {
258
+ const result = await writeFile(SOCIAL_FILE_PATH, content);
259
+ console.log(`[matters] saveSocialData: writeFile returned:`, result);
260
+ } catch (error) {
261
+ // Log the error with context for debugging
262
+ console.error(`[matters] saveSocialData: FAILED to write to ${SOCIAL_FILE_PATH}:`, error);
263
+ throw error; // Re-throw to propagate to caller
264
+ }
265
+ }
266
+
267
+ // ============================================================================
268
+ // Merge Functions
269
+ // ============================================================================
270
+
271
+ /**
272
+ * Merge comments using upsert semantics (by ID)
273
+ */
274
+ function mergeComments(
275
+ existing: MattersComment[],
276
+ incoming: MattersComment[]
277
+ ): MattersComment[] {
278
+ const commentMap = new Map<string, MattersComment>();
279
+
280
+ // Add existing comments
281
+ for (const comment of existing) {
282
+ commentMap.set(comment.id, comment);
283
+ }
284
+
285
+ // Upsert incoming comments
286
+ for (const comment of incoming) {
287
+ commentMap.set(comment.id, comment);
288
+ }
289
+
290
+ return Array.from(commentMap.values());
291
+ }
292
+
293
+ /**
294
+ * Merge donations using upsert semantics (by ID)
295
+ */
296
+ function mergeDonations(
297
+ existing: MattersDonation[],
298
+ incoming: MattersDonation[]
299
+ ): MattersDonation[] {
300
+ const donationMap = new Map<string, MattersDonation>();
301
+
302
+ for (const donation of existing) {
303
+ donationMap.set(donation.id, donation);
304
+ }
305
+
306
+ for (const donation of incoming) {
307
+ donationMap.set(donation.id, donation);
308
+ }
309
+
310
+ return Array.from(donationMap.values());
311
+ }
312
+
313
+ /**
314
+ * Merge appreciations using upsert semantics
315
+ * Note: Appreciations don't have unique IDs, so we use sender.id + createdAt as key
316
+ */
317
+ function mergeAppreciations(
318
+ existing: MattersAppreciation[],
319
+ incoming: MattersAppreciation[]
320
+ ): MattersAppreciation[] {
321
+ const appreciationMap = new Map<string, MattersAppreciation>();
322
+
323
+ const getKey = (a: MattersAppreciation) => `${a.sender.id}_${a.createdAt}`;
324
+
325
+ for (const appreciation of existing) {
326
+ appreciationMap.set(getKey(appreciation), appreciation);
327
+ }
328
+
329
+ for (const appreciation of incoming) {
330
+ appreciationMap.set(getKey(appreciation), appreciation);
331
+ }
332
+
333
+ return Array.from(appreciationMap.values());
334
+ }
335
+
336
+ /**
337
+ * Merge new social data into existing data for a specific article
338
+ *
339
+ * Uses upsert semantics: adds new items, updates existing, never removes.
340
+ *
341
+ * @param data - Existing social data structure (will be mutated)
342
+ * @param articleKey - Article identifier (source .md path, project-relative)
343
+ * @param comments - New comments to merge
344
+ * @param donations - New donations to merge
345
+ * @param appreciations - New appreciations to merge
346
+ * @param commentCount - Optional remote commentCount to record as
347
+ * `lastKnownCommentCount` so the next sync can skip fetching when nothing
348
+ * has changed. Pass only when you actually fetched comments — leave
349
+ * undefined for syndicate-time merges that don't observe remote state.
350
+ * @returns The mutated data object
351
+ */
352
+ export function mergeSocialData(
353
+ data: MattersSocialData,
354
+ articleKey: string,
355
+ comments: MattersComment[],
356
+ donations: MattersDonation[],
357
+ appreciations: MattersAppreciation[],
358
+ commentCount?: number
359
+ ): MattersSocialData {
360
+ // Get or create article entry
361
+ const existing = data.articles[articleKey] || createEmptyArticleSocialData();
362
+
363
+ // Merge each type
364
+ data.articles[articleKey] = {
365
+ comments: mergeComments(existing.comments, comments),
366
+ donations: mergeDonations(existing.donations, donations),
367
+ appreciations: mergeAppreciations(existing.appreciations, appreciations),
368
+ lastKnownCommentCount:
369
+ commentCount !== undefined ? commentCount : existing.lastKnownCommentCount,
370
+ };
371
+
372
+ return data;
373
+ }
374
+
375
+ // ============================================================================
376
+ // Helper Functions
377
+ // ============================================================================
378
+
379
+ /**
380
+ * Get social data for a specific article
381
+ */
382
+ export function getArticleSocialData(
383
+ data: MattersSocialData,
384
+ articleKey: string
385
+ ): ArticleSocialData | undefined {
386
+ return data.articles[articleKey];
387
+ }
388
+
389
+ /**
390
+ * Get total counts for an article's social interactions
391
+ */
392
+ export function getSocialCounts(
393
+ data: MattersSocialData,
394
+ articleKey: string
395
+ ): { comments: number; donations: number; appreciations: number; totalClaps: number } {
396
+ const articleData = data.articles[articleKey];
397
+
398
+ if (!articleData) {
399
+ return { comments: 0, donations: 0, appreciations: 0, totalClaps: 0 };
400
+ }
401
+
402
+ const totalClaps = articleData.appreciations.reduce(
403
+ (sum, a) => sum + a.amount,
404
+ 0
405
+ );
406
+
407
+ return {
408
+ comments: articleData.comments.length,
409
+ donations: articleData.donations.length,
410
+ appreciations: articleData.appreciations.length,
411
+ totalClaps,
412
+ };
413
+ }