@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/sync.ts
ADDED
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync logic for articles, drafts, and collections
|
|
3
|
+
*
|
|
4
|
+
* FILE STRUCTURE DESIGN:
|
|
5
|
+
* ----------------------
|
|
6
|
+
* All content is organized under a single content folder:
|
|
7
|
+
* - English: article/
|
|
8
|
+
* - Chinese: 文章/
|
|
9
|
+
*
|
|
10
|
+
* The folder name is determined by the user's Matters.town language preference
|
|
11
|
+
* (viewer.settings.language). Chinese is used for zh_hans or zh_hant.
|
|
12
|
+
*
|
|
13
|
+
* COLLECTION MODES:
|
|
14
|
+
* -----------------
|
|
15
|
+
* The plugin automatically detects the appropriate collection mode:
|
|
16
|
+
*
|
|
17
|
+
* 1. FOLDER MODE (default): Used when all articles belong to 0-1 collections
|
|
18
|
+
* - Collections are folders: article/{collection}/index.md
|
|
19
|
+
* - Articles in collections: article/{collection}/{article}.md
|
|
20
|
+
* - Standalone articles: article/{article}.md
|
|
21
|
+
*
|
|
22
|
+
* 2. FILE MODE: Used when any article belongs to 2+ collections
|
|
23
|
+
* - Collections are files: article/{collection}.md (with order: field)
|
|
24
|
+
* - All articles at: article/{article}.md (with collections: field)
|
|
25
|
+
*
|
|
26
|
+
* This automatic detection ensures no article duplication while maintaining
|
|
27
|
+
* the simplest possible structure for the user's content.
|
|
28
|
+
*
|
|
29
|
+
* TWO-PHASE SYNC:
|
|
30
|
+
* ---------------
|
|
31
|
+
* Media download is NOT done during markdown sync. This function writes
|
|
32
|
+
* markdown files with remote URLs intact. Call downloadMediaAndUpdate()
|
|
33
|
+
* afterward to download and localize media assets.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import type {
|
|
37
|
+
MattersArticle,
|
|
38
|
+
MattersDraft,
|
|
39
|
+
MattersCollection,
|
|
40
|
+
MattersUserProfile,
|
|
41
|
+
SyncResult,
|
|
42
|
+
SyncResultWithMap,
|
|
43
|
+
} from "./types";
|
|
44
|
+
import type { MattersPluginConfig } from "./config";
|
|
45
|
+
import { slugify, reportError } from "./utils";
|
|
46
|
+
import { overallProgress, type ProgressReporter } from "./progress";
|
|
47
|
+
import { generateFrontmatter, parseFrontmatter } from "./converter";
|
|
48
|
+
import { htmlToMarkdown, readFile, writeFile, listFiles, listProjectTree } from "@symbiosis-lab/moss-api";
|
|
49
|
+
import { isMattersUrl, articleUrl, extractShortHash } from "./domain";
|
|
50
|
+
|
|
51
|
+
// Canonical home for extractShortHash is ./domain (it owns Matters URL knowledge).
|
|
52
|
+
// Re-exported here so existing `import { extractShortHash } from "../sync"` callers
|
|
53
|
+
// and tests keep resolving it.
|
|
54
|
+
export { extractShortHash } from "./domain";
|
|
55
|
+
|
|
56
|
+
// ============================================================================
|
|
57
|
+
// Exported Functions for Folder Detection
|
|
58
|
+
// ============================================================================
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get default folder names. Only the drafts folder is fixed (`_drafts`); the
|
|
62
|
+
* article folder is language-aware via `folderNameForLanguage` (see
|
|
63
|
+
* `getArticleFolderName`).
|
|
64
|
+
*/
|
|
65
|
+
export function getDefaultFolderNames(): {
|
|
66
|
+
article: string;
|
|
67
|
+
drafts: string;
|
|
68
|
+
} {
|
|
69
|
+
return { article: "articles", drafts: "_drafts" };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Map a Matters language preference to the article folder name.
|
|
74
|
+
* Chinese (`zh_hans` / `zh_hant`) → `文章`; everything else → `articles`.
|
|
75
|
+
* Restores the language-aware naming that had regressed to a hardcoded
|
|
76
|
+
* `articles` (it was disabled because it read the viewer-only
|
|
77
|
+
* `settings.language`; we now also accept a public per-article language). (G)
|
|
78
|
+
*/
|
|
79
|
+
export function folderNameForLanguage(language?: string | null): string {
|
|
80
|
+
return language === "zh_hans" || language === "zh_hant" ? "文章" : "articles";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the site's language for folder naming. Prefers an explicit/authed
|
|
85
|
+
* value, then the public per-article majority (the only language signal
|
|
86
|
+
* available in unauthenticated/public-fetch mode, since `settings.language`
|
|
87
|
+
* is viewer-only). Returns undefined if no signal — caller defaults to English.
|
|
88
|
+
*/
|
|
89
|
+
export function resolveContentLanguage(
|
|
90
|
+
explicit: string | null | undefined,
|
|
91
|
+
articleLanguages: Array<string | null | undefined>,
|
|
92
|
+
): string | undefined {
|
|
93
|
+
if (explicit) return explicit;
|
|
94
|
+
const counts = new Map<string, number>();
|
|
95
|
+
for (const l of articleLanguages) {
|
|
96
|
+
if (l) counts.set(l, (counts.get(l) ?? 0) + 1);
|
|
97
|
+
}
|
|
98
|
+
let best: string | undefined;
|
|
99
|
+
let bestN = 0;
|
|
100
|
+
for (const [l, n] of counts) {
|
|
101
|
+
if (n > bestN) {
|
|
102
|
+
best = l;
|
|
103
|
+
bestN = n;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return best;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Check if drafts should be synced based on config.
|
|
111
|
+
* Default is FALSE - user must explicitly enable draft sync.
|
|
112
|
+
*/
|
|
113
|
+
export function shouldSyncDrafts(config: MattersPluginConfig): boolean {
|
|
114
|
+
return config.sync_drafts ?? false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Scan project files for Matters syndication content.
|
|
119
|
+
* Returns the top-level folder name and the Matters username if found.
|
|
120
|
+
*
|
|
121
|
+
* Shared helper used by both detectArticleFolder() and detectBoundUser().
|
|
122
|
+
*/
|
|
123
|
+
async function scanForMattersContent(): Promise<{ folder: string | null; userName: string | null }> {
|
|
124
|
+
try {
|
|
125
|
+
const allFiles = await listFiles();
|
|
126
|
+
const mdFiles = allFiles.filter((f) => f.endsWith(".md"));
|
|
127
|
+
|
|
128
|
+
for (const filePath of mdFiles) {
|
|
129
|
+
const segments = filePath.split("/");
|
|
130
|
+
if (segments.length < 2) continue; // Skip root-level files
|
|
131
|
+
|
|
132
|
+
const topFolder = segments[0];
|
|
133
|
+
|
|
134
|
+
// Skip hidden and underscore folders
|
|
135
|
+
if (topFolder.startsWith(".") || topFolder.startsWith("_")) continue;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const content = await readFile(filePath);
|
|
139
|
+
const parsed = parseFrontmatter(content);
|
|
140
|
+
|
|
141
|
+
if (
|
|
142
|
+
parsed?.frontmatter?.syndicated &&
|
|
143
|
+
Array.isArray(parsed.frontmatter.syndicated)
|
|
144
|
+
) {
|
|
145
|
+
const mattersUrl = parsed.frontmatter.syndicated.find(
|
|
146
|
+
(url: string) => isMattersUrl(url)
|
|
147
|
+
);
|
|
148
|
+
if (mattersUrl) {
|
|
149
|
+
// Extract username from URL: https://matters.town/@username/slug-hash
|
|
150
|
+
const match = mattersUrl.match(/\/@([^/]+)\//);
|
|
151
|
+
const userName = match ? match[1] : null;
|
|
152
|
+
return { folder: topFolder, userName };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { folder: null, userName: null };
|
|
161
|
+
} catch {
|
|
162
|
+
return { folder: null, userName: null };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Detect the article folder by scanning for files with Matters syndication URLs.
|
|
168
|
+
* Returns the folder name if found, or null if no existing articles.
|
|
169
|
+
*/
|
|
170
|
+
export async function detectArticleFolder(): Promise<string | null> {
|
|
171
|
+
const { folder } = await scanForMattersContent();
|
|
172
|
+
return folder;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Detect the Matters username bound to this project by scanning syndication URLs.
|
|
177
|
+
* Returns the username if found, or null if no Matters content exists.
|
|
178
|
+
*/
|
|
179
|
+
export async function detectBoundUser(): Promise<string | null> {
|
|
180
|
+
const { userName } = await scanForMattersContent();
|
|
181
|
+
return userName;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Get the article folder name to use for syncing.
|
|
186
|
+
*
|
|
187
|
+
* Priority:
|
|
188
|
+
* 1. Explicit config (articleFolder) - user override
|
|
189
|
+
* 2. Auto-detected from existing content - finds folder with Matters-synced files
|
|
190
|
+
* 3. Default "articles" - for new projects
|
|
191
|
+
*/
|
|
192
|
+
export async function getArticleFolderName(
|
|
193
|
+
config: MattersPluginConfig,
|
|
194
|
+
language?: string,
|
|
195
|
+
): Promise<string> {
|
|
196
|
+
// 1. Check if explicitly configured
|
|
197
|
+
if (config.articleFolder) {
|
|
198
|
+
return config.articleFolder;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 2. Auto-detect from existing content
|
|
202
|
+
const detected = await detectArticleFolder();
|
|
203
|
+
if (detected) {
|
|
204
|
+
return detected;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 3. Fall back to a language-derived default (Chinese → 文章, else articles)
|
|
208
|
+
return folderNameForLanguage(language ?? config.language);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ============================================================================
|
|
212
|
+
// Helper Functions
|
|
213
|
+
// ============================================================================
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Check if any article belongs to multiple collections
|
|
217
|
+
* Returns true if file-mode (collections as .md files) should be used
|
|
218
|
+
*/
|
|
219
|
+
function hasMultiCollectionArticles(collections: MattersCollection[]): boolean {
|
|
220
|
+
const articleCollectionCount = new Map<string, number>();
|
|
221
|
+
|
|
222
|
+
for (const collection of collections) {
|
|
223
|
+
for (const article of collection.articles) {
|
|
224
|
+
const count = articleCollectionCount.get(article.shortHash) || 0;
|
|
225
|
+
articleCollectionCount.set(article.shortHash, count + 1);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const count of articleCollectionCount.values()) {
|
|
230
|
+
if (count > 1) return true;
|
|
231
|
+
}
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Check if remote article is newer than local
|
|
237
|
+
*/
|
|
238
|
+
export function isRemoteNewer(
|
|
239
|
+
localUpdated: string | undefined,
|
|
240
|
+
remoteUpdated: string | undefined
|
|
241
|
+
): boolean {
|
|
242
|
+
if (!localUpdated) return true;
|
|
243
|
+
if (!remoteUpdated) return false;
|
|
244
|
+
|
|
245
|
+
const localDate = new Date(localUpdated);
|
|
246
|
+
const remoteDate = new Date(remoteUpdated);
|
|
247
|
+
|
|
248
|
+
return remoteDate > localDate;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Scan local markdown files to find all synced Matters articles
|
|
254
|
+
* Returns array of { shortHash, path, title, uid } for all articles with Matters syndicated URLs
|
|
255
|
+
*
|
|
256
|
+
* The uid comes from frontmatter and is used as the key for local social data storage.
|
|
257
|
+
* When uid is null (file hasn't been built yet), callers should fall back to path.
|
|
258
|
+
*/
|
|
259
|
+
export async function scanLocalArticles(): Promise<Array<{ shortHash: string; path: string; title: string; uid: string | null }>> {
|
|
260
|
+
const articles: Array<{ shortHash: string; path: string; title: string; uid: string | null }> = [];
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
// List all files in the project
|
|
264
|
+
const allFiles = await listFiles();
|
|
265
|
+
|
|
266
|
+
// Filter to markdown files only
|
|
267
|
+
const files = allFiles.filter((f) => f.endsWith(".md"));
|
|
268
|
+
|
|
269
|
+
for (const file of files) {
|
|
270
|
+
// Skip node_modules, .moss, and other non-content directories
|
|
271
|
+
if (
|
|
272
|
+
file.startsWith("node_modules/") ||
|
|
273
|
+
file.startsWith(".moss/") ||
|
|
274
|
+
file.startsWith("_drafts/") ||
|
|
275
|
+
file.startsWith(".") ||
|
|
276
|
+
file === "index.md" ||
|
|
277
|
+
file === "README.md"
|
|
278
|
+
) {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
const content = await readFile(file);
|
|
284
|
+
const parsed = parseFrontmatter(content);
|
|
285
|
+
|
|
286
|
+
if (
|
|
287
|
+
parsed?.frontmatter?.syndicated &&
|
|
288
|
+
Array.isArray(parsed.frontmatter.syndicated)
|
|
289
|
+
) {
|
|
290
|
+
// Find Matters URL in syndicated array
|
|
291
|
+
const mattersUrl = parsed.frontmatter.syndicated.find(
|
|
292
|
+
(url: string) => typeof url === "string" && isMattersUrl(url)
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
if (mattersUrl) {
|
|
296
|
+
const shortHash = extractShortHash(mattersUrl);
|
|
297
|
+
if (shortHash) {
|
|
298
|
+
const uid = typeof parsed.frontmatter.uid === "string"
|
|
299
|
+
? parsed.frontmatter.uid
|
|
300
|
+
: null;
|
|
301
|
+
articles.push({
|
|
302
|
+
shortHash,
|
|
303
|
+
path: file,
|
|
304
|
+
title: (parsed.frontmatter.title as string) || file,
|
|
305
|
+
uid,
|
|
306
|
+
});
|
|
307
|
+
} else {
|
|
308
|
+
// Visible signal rather than a silent drop: an article with a
|
|
309
|
+
// valid Matters syndicated URL whose shortHash can't be parsed
|
|
310
|
+
// will get no comments/social data, and the user would otherwise
|
|
311
|
+
// have no way to know why.
|
|
312
|
+
console.warn(
|
|
313
|
+
`[matters] could not extract shortHash from syndicated URL "${mattersUrl}" (${file}) — skipping social fetch`
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
} catch {
|
|
319
|
+
// Skip files that can't be read or parsed
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
} catch (error) {
|
|
323
|
+
console.warn(`Failed to scan local articles: ${error}`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return articles;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Find an available filename by adding sequence numbers if needed
|
|
331
|
+
*/
|
|
332
|
+
async function findAvailableFilename(
|
|
333
|
+
basePath: string,
|
|
334
|
+
slug: string
|
|
335
|
+
): Promise<string> {
|
|
336
|
+
let filename = `${basePath}/${slug}.md`;
|
|
337
|
+
let counter = 1;
|
|
338
|
+
|
|
339
|
+
while (true) {
|
|
340
|
+
try {
|
|
341
|
+
await readFile(filename);
|
|
342
|
+
counter++;
|
|
343
|
+
filename = `${basePath}/${slug}-${counter}.md`;
|
|
344
|
+
} catch {
|
|
345
|
+
return filename;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ============================================================================
|
|
351
|
+
// Main Sync Function
|
|
352
|
+
// ============================================================================
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Sync articles, drafts, and collections to local markdown files
|
|
356
|
+
* Media is NOT downloaded here - use downloadMediaAndUpdate() after this
|
|
357
|
+
*
|
|
358
|
+
* Returns both the sync result and an articlePathMap for link rewriting.
|
|
359
|
+
* The articlePathMap maps Matters URLs and shortHashes to local file paths,
|
|
360
|
+
* enabling internal link rewriting in the post-sync phase.
|
|
361
|
+
*/
|
|
362
|
+
export async function syncToLocalFiles(
|
|
363
|
+
articles: MattersArticle[],
|
|
364
|
+
drafts: MattersDraft[],
|
|
365
|
+
collections: MattersCollection[],
|
|
366
|
+
userName: string,
|
|
367
|
+
config: Record<string, unknown>,
|
|
368
|
+
profile: MattersUserProfile,
|
|
369
|
+
// moss tells us which file it detected as the homepage (e.g., "刘果.md", "index.md").
|
|
370
|
+
// When set, we skip homepage generation — the user already has a home file and the
|
|
371
|
+
// Matters plugin should not create a competing index.md. This uses the same detection
|
|
372
|
+
// logic as moss-core's home::detect_home_file_in_folder(), which considers index stems,
|
|
373
|
+
// self-named folder notes, and alphabetical fallback.
|
|
374
|
+
homepageFile?: string | null,
|
|
375
|
+
// The root folder's basename (e.g. "刘果"), from moss's project_info.folder_name.
|
|
376
|
+
// When we DO generate a home, we name it self-named (`<folder>.md`) with a
|
|
377
|
+
// `home: true` marker to match moss's folder-home convention. Falls back to
|
|
378
|
+
// `index.md` when absent (older hosts that don't supply it).
|
|
379
|
+
folderName?: string | null,
|
|
380
|
+
// Reports per-item sync progress to the unified import task so the hairline
|
|
381
|
+
// advances within the "syncing" band instead of jumping start→end. Optional:
|
|
382
|
+
// direct callers (tests) omit it and the per-item reports no-op.
|
|
383
|
+
onProgress?: ProgressReporter,
|
|
384
|
+
): Promise<SyncResultWithMap> {
|
|
385
|
+
const result: SyncResult = {
|
|
386
|
+
created: 0,
|
|
387
|
+
updated: 0,
|
|
388
|
+
skipped: 0,
|
|
389
|
+
errors: [],
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
// Map for internal link rewriting: Matters URL/shortHash → local file path
|
|
393
|
+
const articlePathMap = new Map<string, string>();
|
|
394
|
+
|
|
395
|
+
// Get folder names - auto-detect existing folder, else language-derived
|
|
396
|
+
// (Chinese → 文章). Language: authed profile.language → stale config.language
|
|
397
|
+
// → public per-article majority (the only signal in unauthenticated mode). (G)
|
|
398
|
+
const contentLanguage = resolveContentLanguage(
|
|
399
|
+
profile?.language ?? (config as MattersPluginConfig).language,
|
|
400
|
+
articles.map((a) => a.language),
|
|
401
|
+
);
|
|
402
|
+
const articleFolder = await getArticleFolderName(
|
|
403
|
+
config as MattersPluginConfig,
|
|
404
|
+
contentLanguage,
|
|
405
|
+
);
|
|
406
|
+
const folders = {
|
|
407
|
+
article: articleFolder,
|
|
408
|
+
drafts: getDefaultFolderNames().drafts,
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
// Build dedup index: shortHash → local file path
|
|
412
|
+
// Catches renamed files that still have a Matters syndicated URL in frontmatter
|
|
413
|
+
const localArticles = await scanLocalArticles();
|
|
414
|
+
const knownShortHashes = new Map<string, string>();
|
|
415
|
+
for (const local of localArticles) {
|
|
416
|
+
knownShortHashes.set(local.shortHash, local.path);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const totalItems = articles.length + drafts.length + collections.length + 1; // +1 for homepage
|
|
420
|
+
let processedItems = 0;
|
|
421
|
+
|
|
422
|
+
// Detect collection mode: folder-based or file-based
|
|
423
|
+
const useFileMode = hasMultiCollectionArticles(collections);
|
|
424
|
+
console.log(
|
|
425
|
+
`📁 Syncing ${articles.length} articles, ${drafts.length} drafts, and ${collections.length} collections...`
|
|
426
|
+
);
|
|
427
|
+
console.log(` Collection mode: ${useFileMode ? "file-based (multi-collection articles detected)" : "folder-based"}`);
|
|
428
|
+
console.log(` Content folder: ${folders.article}/`);
|
|
429
|
+
console.log(` Drafts folder: ${folders.drafts}/`);
|
|
430
|
+
|
|
431
|
+
// Build article ID → collection memberships mapping
|
|
432
|
+
const articleCollections = new Map<string, Record<string, number>>();
|
|
433
|
+
const articleFirstCollection = new Map<string, string>();
|
|
434
|
+
|
|
435
|
+
for (const collection of collections) {
|
|
436
|
+
const collectionSlug = slugify(collection.title);
|
|
437
|
+
for (let i = 0; i < collection.articles.length; i++) {
|
|
438
|
+
const article = collection.articles[i];
|
|
439
|
+
const articleKey = article.shortHash;
|
|
440
|
+
|
|
441
|
+
if (!articleCollections.has(articleKey)) {
|
|
442
|
+
articleCollections.set(articleKey, {});
|
|
443
|
+
}
|
|
444
|
+
articleCollections.get(articleKey)![collectionSlug] = i;
|
|
445
|
+
|
|
446
|
+
if (!articleFirstCollection.has(articleKey)) {
|
|
447
|
+
articleFirstCollection.set(articleKey, collectionSlug);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Build article shortHash → slug mapping for collection order field
|
|
453
|
+
const articleSlugMap = new Map<string, string>();
|
|
454
|
+
for (const article of articles) {
|
|
455
|
+
const slug = article.slug || slugify(article.title);
|
|
456
|
+
articleSlugMap.set(article.shortHash, slug);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ============================================================================
|
|
460
|
+
// Generate Homepage (index.md)
|
|
461
|
+
// ============================================================================
|
|
462
|
+
// Skip if moss already detected a home file (e.g., "刘果.md", "index.md", "readme.md").
|
|
463
|
+
// The homepageFile comes from moss's home file detection (moss-core home.rs), which
|
|
464
|
+
// considers index stems, self-named folder notes, and alphabetical fallback. When a
|
|
465
|
+
// home file exists, the Matters plugin should not create a competing index.md.
|
|
466
|
+
processedItems++;
|
|
467
|
+
onProgress?.("syncing_homepage", overallProgress("syncing_homepage", processedItems, totalItems), 100, "Creating homepage...");
|
|
468
|
+
|
|
469
|
+
if (homepageFile) {
|
|
470
|
+
console.log(` ⏭️ Skipping homepage (moss detected home file: ${homepageFile})`);
|
|
471
|
+
result.skipped++;
|
|
472
|
+
} else {
|
|
473
|
+
try {
|
|
474
|
+
// Self-named home (`<folder>.md`) + `home: true` marker, matching moss's
|
|
475
|
+
// folder-home convention. Falls back to `index.md` when the host didn't
|
|
476
|
+
// tell us the folder name.
|
|
477
|
+
const homeFilename = folderName ? `${folderName}.md` : "index.md";
|
|
478
|
+
const homepageFrontmatter = generateFrontmatter({
|
|
479
|
+
title: folderName ?? profile.displayName,
|
|
480
|
+
home: true,
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
let homepageBody = profile.description || "";
|
|
484
|
+
|
|
485
|
+
if (profile.pinnedWorks && profile.pinnedWorks.length > 0) {
|
|
486
|
+
const gridItems = profile.pinnedWorks.map((work) => {
|
|
487
|
+
if (work.type === "collection") {
|
|
488
|
+
const slug = slugify(work.title);
|
|
489
|
+
// In file mode, collections are .md files; in folder mode, they are directories
|
|
490
|
+
const collectionPath = useFileMode
|
|
491
|
+
? `/${folders.article}/${slug}`
|
|
492
|
+
: `/${folders.article}/${slug}/`;
|
|
493
|
+
return `[${work.title}](${collectionPath})`;
|
|
494
|
+
} else {
|
|
495
|
+
// Article — find its path (standalone or in collection)
|
|
496
|
+
const slug = work.slug || slugify(work.title);
|
|
497
|
+
const shortHash = work.shortHash ?? "";
|
|
498
|
+
const collectionSlug = articleFirstCollection.get(shortHash);
|
|
499
|
+
const path = collectionSlug
|
|
500
|
+
? `/${folders.article}/${collectionSlug}/${slug}/`
|
|
501
|
+
: `/${folders.article}/${slug}/`;
|
|
502
|
+
return `[${work.title}](${path})`;
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// Cells are separated by moss's canonical `+++` divider; a lone `:::`
|
|
507
|
+
// is the grid CLOSER, so using it between cells prematurely closes the
|
|
508
|
+
// grid and corrupts the homepage (B1). The single trailing `:::` closes.
|
|
509
|
+
homepageBody += "\n\n:::grid 3\n" + gridItems.join("\n+++\n") + "\n:::\n";
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const homepageContent = homepageFrontmatter + "\n\n" + homepageBody;
|
|
513
|
+
|
|
514
|
+
// Don't overwrite an existing home. moss's homepageFile check (above) already
|
|
515
|
+
// covers homes it detected; this is the on-disk backstop for the self-named
|
|
516
|
+
// target and a legacy index.md.
|
|
517
|
+
let existingHomepage: string | null = null;
|
|
518
|
+
let existingPath = "";
|
|
519
|
+
for (const candidate of [homeFilename, "index.md"]) {
|
|
520
|
+
try {
|
|
521
|
+
existingHomepage = await readFile(candidate);
|
|
522
|
+
} catch {
|
|
523
|
+
existingHomepage = null;
|
|
524
|
+
}
|
|
525
|
+
if (existingHomepage !== null) {
|
|
526
|
+
existingPath = candidate;
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (existingHomepage !== null) {
|
|
532
|
+
console.log(` ⏭️ Skipping homepage (already exists): ${existingPath}`);
|
|
533
|
+
result.skipped++;
|
|
534
|
+
} else {
|
|
535
|
+
await writeFile(homeFilename, homepageContent);
|
|
536
|
+
console.log(` ✅ Created homepage: ${homeFilename}`);
|
|
537
|
+
result.created++;
|
|
538
|
+
}
|
|
539
|
+
} catch (error) {
|
|
540
|
+
const errorMsg = `Failed to create homepage: ${error}`;
|
|
541
|
+
await reportError(errorMsg, "syncing_homepage", false);
|
|
542
|
+
console.error(` ❌ ${errorMsg}`);
|
|
543
|
+
result.errors.push(errorMsg);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Fetch project tree once for home-file detection in collection folders
|
|
548
|
+
const projectTree = await listProjectTree();
|
|
549
|
+
|
|
550
|
+
// Process collections
|
|
551
|
+
for (const collection of collections) {
|
|
552
|
+
processedItems++;
|
|
553
|
+
onProgress?.(
|
|
554
|
+
"syncing_collections",
|
|
555
|
+
overallProgress("syncing_collections", processedItems, totalItems),
|
|
556
|
+
100,
|
|
557
|
+
`Syncing collection: ${collection.title}`
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
try {
|
|
561
|
+
const collectionSlug = slugify(collection.title);
|
|
562
|
+
|
|
563
|
+
// Determine path based on mode
|
|
564
|
+
// All collections live under the article/ folder
|
|
565
|
+
const collectionPath = useFileMode
|
|
566
|
+
? `${folders.article}/${collectionSlug}.md` // File mode: collection as .md file
|
|
567
|
+
: `${folders.article}/${collectionSlug}/${collectionSlug}.md`; // Folder mode: self-named folder home
|
|
568
|
+
|
|
569
|
+
// In folder mode, skip if the folder already has a home file (self-named note, etc.)
|
|
570
|
+
if (!useFileMode) {
|
|
571
|
+
const folderPrefix = `${folders.article}/${collectionSlug}/`;
|
|
572
|
+
const homeInFolder = projectTree.find(
|
|
573
|
+
(f) => f.path.startsWith(folderPrefix) && f.is_home
|
|
574
|
+
);
|
|
575
|
+
if (homeInFolder) {
|
|
576
|
+
console.log(` ⏭️ Skipping collection index (folder has home file: ${homeInFolder.path})`);
|
|
577
|
+
result.skipped++;
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
let existingContent: string | null = null;
|
|
583
|
+
try {
|
|
584
|
+
existingContent = await readFile(collectionPath);
|
|
585
|
+
} catch {
|
|
586
|
+
// File doesn't exist
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Build order field for collections (list of article slugs/paths)
|
|
590
|
+
let orderField: string[] | undefined;
|
|
591
|
+
if (collection.articles.length > 0) {
|
|
592
|
+
if (useFileMode) {
|
|
593
|
+
// File mode: full paths relative to project root
|
|
594
|
+
orderField = collection.articles
|
|
595
|
+
.map((a) => {
|
|
596
|
+
const slug = articleSlugMap.get(a.shortHash);
|
|
597
|
+
return slug ? `${folders.article}/${slug}` : null;
|
|
598
|
+
})
|
|
599
|
+
.filter((s): s is string => s !== null);
|
|
600
|
+
} else {
|
|
601
|
+
// Folder mode: bare slugs (articles are inside the collection folder)
|
|
602
|
+
orderField = collection.articles
|
|
603
|
+
.map((a) => articleSlugMap.get(a.shortHash) ?? null)
|
|
604
|
+
.filter((s): s is string => s !== null);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const frontmatter = generateFrontmatter({
|
|
609
|
+
title: collection.title,
|
|
610
|
+
// A folder-mode collection's landing page IS that folder's home.
|
|
611
|
+
// (File-mode collections are plain `.md` pages, not folder homes.)
|
|
612
|
+
home: !useFileMode,
|
|
613
|
+
description: collection.description,
|
|
614
|
+
cover: collection.cover, // Keep remote URL, will be downloaded in phase 2
|
|
615
|
+
order: orderField,
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
const fullContent = `${frontmatter}\n\n${collection.description || ""}`;
|
|
619
|
+
|
|
620
|
+
if (existingContent !== null) {
|
|
621
|
+
console.log(` ⏭️ Skipping collection (already exists): ${collectionPath}`);
|
|
622
|
+
result.skipped++;
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
await writeFile(collectionPath, fullContent);
|
|
627
|
+
console.log(` ✅ Created collection: ${collectionPath}`);
|
|
628
|
+
result.created++;
|
|
629
|
+
} catch (error) {
|
|
630
|
+
const errorMsg = `Failed to sync collection "${collection.title}": ${error}`;
|
|
631
|
+
await reportError(errorMsg, "syncing_collections", false);
|
|
632
|
+
console.error(` ❌ ${errorMsg}`);
|
|
633
|
+
result.errors.push(errorMsg);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Process published articles
|
|
638
|
+
for (const article of articles) {
|
|
639
|
+
processedItems++;
|
|
640
|
+
onProgress?.(
|
|
641
|
+
"syncing_articles",
|
|
642
|
+
overallProgress("syncing_articles", processedItems, totalItems),
|
|
643
|
+
100,
|
|
644
|
+
`Syncing article: ${article.title}`
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
try {
|
|
648
|
+
const articleSlug = article.slug || slugify(article.title);
|
|
649
|
+
const mattersUrl = articleUrl(userName, article.slug, article.shortHash);
|
|
650
|
+
|
|
651
|
+
// Determine file location based on mode and collection membership
|
|
652
|
+
// All articles live under the article/ folder
|
|
653
|
+
let filename: string;
|
|
654
|
+
if (useFileMode) {
|
|
655
|
+
// File mode: all articles directly under article/, collections via frontmatter
|
|
656
|
+
filename = `${folders.article}/${articleSlug}.md`;
|
|
657
|
+
} else {
|
|
658
|
+
// Folder mode: articles in their first collection's folder
|
|
659
|
+
const firstCollectionSlug = articleFirstCollection.get(article.shortHash);
|
|
660
|
+
if (firstCollectionSlug) {
|
|
661
|
+
filename = `${folders.article}/${firstCollectionSlug}/${articleSlug}.md`;
|
|
662
|
+
} else {
|
|
663
|
+
// Standalone articles (not in any collection) go directly under article/
|
|
664
|
+
filename = `${folders.article}/${articleSlug}.md`;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Check if article already exists locally (even under a different filename)
|
|
669
|
+
const existingLocalPath = knownShortHashes.get(article.shortHash);
|
|
670
|
+
if (existingLocalPath) {
|
|
671
|
+
// Article exists locally — use actual path for link rewriting
|
|
672
|
+
articlePathMap.set(mattersUrl, existingLocalPath);
|
|
673
|
+
articlePathMap.set(article.shortHash, existingLocalPath);
|
|
674
|
+
console.log(` ⏭️ Skipping (already synced): ${existingLocalPath}`);
|
|
675
|
+
result.skipped++;
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// New article — map the computed filename for link rewriting
|
|
680
|
+
articlePathMap.set(mattersUrl, filename);
|
|
681
|
+
articlePathMap.set(article.shortHash, filename);
|
|
682
|
+
|
|
683
|
+
// Build collections field for frontmatter
|
|
684
|
+
const allCollections = articleCollections.get(article.shortHash) || {};
|
|
685
|
+
let collectionsField: Record<string, number> | string[] | undefined;
|
|
686
|
+
|
|
687
|
+
if (useFileMode) {
|
|
688
|
+
// File mode: list all collections
|
|
689
|
+
if (Object.keys(allCollections).length > 0) {
|
|
690
|
+
collectionsField = allCollections;
|
|
691
|
+
}
|
|
692
|
+
} else {
|
|
693
|
+
// Folder mode: only additional collections (not the first one where article lives)
|
|
694
|
+
const firstCollectionSlug = articleFirstCollection.get(article.shortHash);
|
|
695
|
+
const additionalCollections: Record<string, number> = {};
|
|
696
|
+
for (const [slug, order] of Object.entries(allCollections)) {
|
|
697
|
+
if (slug !== firstCollectionSlug) {
|
|
698
|
+
additionalCollections[slug] = order;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
if (Object.keys(additionalCollections).length > 0) {
|
|
702
|
+
collectionsField = additionalCollections;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Check if file already exists - never overwrite existing files
|
|
707
|
+
// This implements "download new content only" model
|
|
708
|
+
let fileExists = false;
|
|
709
|
+
try {
|
|
710
|
+
await readFile(filename);
|
|
711
|
+
fileExists = true;
|
|
712
|
+
} catch {
|
|
713
|
+
// File doesn't exist
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (fileExists) {
|
|
717
|
+
// Never overwrite existing files - protects local edits
|
|
718
|
+
console.log(` ⏭️ Skipping (file exists): ${filename}`);
|
|
719
|
+
result.skipped++;
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Convert HTML to Markdown via moss's shared htmd converter (keep remote
|
|
724
|
+
// URLs; downloaded + rewritten to wikilinks in phase 2)
|
|
725
|
+
const markdownContent = await htmlToMarkdown(article.content);
|
|
726
|
+
|
|
727
|
+
const frontmatter = generateFrontmatter({
|
|
728
|
+
title: article.title,
|
|
729
|
+
description: article.summary,
|
|
730
|
+
date: article.createdAt,
|
|
731
|
+
updated: article.revisedAt,
|
|
732
|
+
// Matters tag strings can carry leading/trailing whitespace (e.g.
|
|
733
|
+
// `"React "`). Trim each and drop any that collapse to empty so the
|
|
734
|
+
// frontmatter `tags:` list is clean (B10).
|
|
735
|
+
tags: article.tags
|
|
736
|
+
.map((t) => t.content.trim())
|
|
737
|
+
.filter((t) => t.length > 0),
|
|
738
|
+
cover: article.cover, // Keep remote URL, will be downloaded in phase 2
|
|
739
|
+
syndicated: [mattersUrl],
|
|
740
|
+
collections: collectionsField,
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
const fullContent = `${frontmatter}\n\n${markdownContent}`;
|
|
744
|
+
|
|
745
|
+
await writeFile(filename, fullContent);
|
|
746
|
+
console.log(` ✅ Created: ${filename}`);
|
|
747
|
+
result.created++;
|
|
748
|
+
} catch (error) {
|
|
749
|
+
const errorMsg = `Failed to sync article "${article.title}": ${error}`;
|
|
750
|
+
await reportError(errorMsg, "syncing_articles", false);
|
|
751
|
+
console.error(` ❌ ${errorMsg}`);
|
|
752
|
+
result.errors.push(errorMsg);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Process drafts (disabled by default - must be explicitly enabled)
|
|
757
|
+
if (shouldSyncDrafts(config)) {
|
|
758
|
+
for (const draft of drafts) {
|
|
759
|
+
processedItems++;
|
|
760
|
+
const draftTitle = draft.title || "Untitled";
|
|
761
|
+
onProgress?.(
|
|
762
|
+
"syncing_drafts",
|
|
763
|
+
overallProgress("syncing_drafts", processedItems, totalItems),
|
|
764
|
+
100,
|
|
765
|
+
`Syncing draft: ${draftTitle}`
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
try {
|
|
769
|
+
const slug = slugify(draft.title || "untitled");
|
|
770
|
+
const filename = await findAvailableFilename(folders.drafts, slug);
|
|
771
|
+
|
|
772
|
+
// Check if file already exists - never overwrite existing files
|
|
773
|
+
let fileExists = false;
|
|
774
|
+
try {
|
|
775
|
+
await readFile(filename);
|
|
776
|
+
fileExists = true;
|
|
777
|
+
} catch {
|
|
778
|
+
// File doesn't exist
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (fileExists) {
|
|
782
|
+
// Never overwrite existing files - protects local edits
|
|
783
|
+
console.log(` ⏭️ Skipping draft (file exists): ${filename}`);
|
|
784
|
+
result.skipped++;
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Convert HTML to Markdown (keep remote URLs, will be downloaded in phase 2)
|
|
789
|
+
const markdownContent = await htmlToMarkdown(draft.content);
|
|
790
|
+
|
|
791
|
+
const frontmatter = generateFrontmatter({
|
|
792
|
+
title: draft.title || "Untitled Draft",
|
|
793
|
+
date: draft.createdAt,
|
|
794
|
+
updated: draft.updatedAt,
|
|
795
|
+
// Trim + drop empties, same as the published-article path (B10).
|
|
796
|
+
tags: (draft.tags || [])
|
|
797
|
+
.map((t) => t.trim())
|
|
798
|
+
.filter((t) => t.length > 0),
|
|
799
|
+
cover: draft.cover, // Keep remote URL, will be downloaded in phase 2
|
|
800
|
+
syndicated: [],
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
const fullContent = `${frontmatter}\n\n${markdownContent}`;
|
|
804
|
+
|
|
805
|
+
await writeFile(filename, fullContent);
|
|
806
|
+
console.log(` ✅ Created draft: ${filename}`);
|
|
807
|
+
result.created++;
|
|
808
|
+
} catch (error) {
|
|
809
|
+
const errorMsg = `Failed to sync draft "${draftTitle}": ${error}`;
|
|
810
|
+
await reportError(errorMsg, "syncing_drafts", false);
|
|
811
|
+
console.error(` ❌ ${errorMsg}`);
|
|
812
|
+
result.errors.push(errorMsg);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
return { result, articlePathMap };
|
|
818
|
+
}
|