@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/progress.ts
ADDED
|
@@ -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
|
+
}
|