@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
|
@@ -0,0 +1,1273 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { isRemoteNewer } from "../sync";
|
|
3
|
+
|
|
4
|
+
describe("isRemoteNewer", () => {
|
|
5
|
+
it("returns true when local is undefined", () => {
|
|
6
|
+
expect(isRemoteNewer(undefined, "2024-01-01")).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("returns false when remote is undefined", () => {
|
|
10
|
+
expect(isRemoteNewer("2024-01-01", undefined)).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns true when remote is newer", () => {
|
|
14
|
+
expect(isRemoteNewer("2024-01-01", "2024-01-02")).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns false when local is newer", () => {
|
|
18
|
+
expect(isRemoteNewer("2024-01-02", "2024-01-01")).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns false when dates are equal", () => {
|
|
22
|
+
expect(isRemoteNewer("2024-01-01", "2024-01-01")).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("handles ISO date strings with time", () => {
|
|
26
|
+
expect(isRemoteNewer("2024-01-01T10:00:00Z", "2024-01-01T12:00:00Z")).toBe(true);
|
|
27
|
+
expect(isRemoteNewer("2024-01-01T12:00:00Z", "2024-01-01T10:00:00Z")).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns true when both are undefined (local missing means should update)", () => {
|
|
31
|
+
// When local is undefined, we should update regardless of remote
|
|
32
|
+
expect(isRemoteNewer(undefined, undefined)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Tests for real Matters.town date formats
|
|
36
|
+
describe("Matters.town date formats", () => {
|
|
37
|
+
it("handles full ISO format with milliseconds", () => {
|
|
38
|
+
// Real Matters API format: "2025-05-09T18:32:27.769Z"
|
|
39
|
+
expect(isRemoteNewer(
|
|
40
|
+
"2025-05-09T18:32:27.769Z",
|
|
41
|
+
"2025-05-09T18:32:27.769Z"
|
|
42
|
+
)).toBe(false);
|
|
43
|
+
|
|
44
|
+
expect(isRemoteNewer(
|
|
45
|
+
"2025-05-08T21:10:51.834Z",
|
|
46
|
+
"2025-05-09T18:32:27.769Z"
|
|
47
|
+
)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("handles comparison between different precision levels", () => {
|
|
51
|
+
// Local might have different precision than remote
|
|
52
|
+
expect(isRemoteNewer(
|
|
53
|
+
"2025-05-09T18:32:27Z",
|
|
54
|
+
"2025-05-09T18:32:27.769Z"
|
|
55
|
+
)).toBe(true); // Remote is technically later by 769ms
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("handles date-only vs full ISO comparison", () => {
|
|
59
|
+
// Edge case: local file has date-only, remote has full timestamp
|
|
60
|
+
expect(isRemoteNewer(
|
|
61
|
+
"2025-05-09",
|
|
62
|
+
"2025-05-09T18:32:27.769Z"
|
|
63
|
+
)).toBe(true); // Date-only is treated as 00:00:00
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("handles same day with remote later time", () => {
|
|
67
|
+
expect(isRemoteNewer(
|
|
68
|
+
"2025-05-09T00:00:00.000Z",
|
|
69
|
+
"2025-05-09T18:32:27.769Z"
|
|
70
|
+
)).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("syncToLocalFiles", () => {
|
|
76
|
+
it("exports syncToLocalFiles function", async () => {
|
|
77
|
+
const module = await import("../sync");
|
|
78
|
+
expect(typeof module.syncToLocalFiles).toBe("function");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ============================================================================
|
|
83
|
+
// Homepage Grid Generation Tests
|
|
84
|
+
// ============================================================================
|
|
85
|
+
|
|
86
|
+
describe("syncToLocalFiles - homepage grid from pinned works", () => {
|
|
87
|
+
let ctx: MockTauriContext;
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
ctx = setupMockTauri({ projectPath: "/test-project" });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
ctx.cleanup();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("should generate :::grid 3 homepage when pinnedWorks has collections", async () => {
|
|
98
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
99
|
+
const result = await syncToLocalFiles(
|
|
100
|
+
[], // no articles
|
|
101
|
+
[], // no drafts
|
|
102
|
+
[
|
|
103
|
+
{ id: "c1", title: "Travel Notes", description: "My travels", articles: [], cover: "https://example.com/cover.jpg" },
|
|
104
|
+
{ id: "c2", title: "Tech Essays", description: "Tech writing", articles: [], cover: undefined },
|
|
105
|
+
],
|
|
106
|
+
"testuser",
|
|
107
|
+
{},
|
|
108
|
+
{
|
|
109
|
+
displayName: "Test User",
|
|
110
|
+
userName: "testuser",
|
|
111
|
+
description: "Hello world",
|
|
112
|
+
pinnedWorks: [
|
|
113
|
+
{ id: "c1", type: "collection", title: "Travel Notes", cover: "https://example.com/cover.jpg" },
|
|
114
|
+
{ id: "c2", type: "collection", title: "Tech Essays", cover: undefined },
|
|
115
|
+
],
|
|
116
|
+
}
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
expect(result.result.created).toBeGreaterThanOrEqual(1);
|
|
120
|
+
const homepage = ctx.filesystem.getFile(`${ctx.projectPath}/index.md`)?.content;
|
|
121
|
+
expect(homepage).toBeDefined();
|
|
122
|
+
expect(homepage).toContain(":::grid 3");
|
|
123
|
+
expect(homepage).toContain("[Travel Notes](/articles/travel-notes/)");
|
|
124
|
+
expect(homepage).toContain("[Tech Essays](/articles/tech-essays/)");
|
|
125
|
+
// Grid CELLS must be separated by moss's canonical `+++` divider, NOT `:::`
|
|
126
|
+
// (a lone `:::` is the grid CLOSER — using it between cells prematurely
|
|
127
|
+
// closes the grid and corrupts the homepage). Regression guard for B1.
|
|
128
|
+
expect(homepage).toMatch(
|
|
129
|
+
/\[Travel Notes\]\([^)]*\)\n\+\+\+\n\[Tech Essays\]\([^)]*\)/
|
|
130
|
+
);
|
|
131
|
+
// The grid must open with :::grid and close with a single ::: — exactly two
|
|
132
|
+
// `:::` occurrences (open marker prefix + closer), no `:::` between cells.
|
|
133
|
+
expect((homepage!.match(/^:::$/gm) || []).length).toBe(1);
|
|
134
|
+
expect(homepage).toContain(":::");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("forwards sync progress to the onProgress reporter (unified task, not the dropped legacy path)", async () => {
|
|
138
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
139
|
+
const onProgress = vi.fn();
|
|
140
|
+
await syncToLocalFiles(
|
|
141
|
+
[], // no articles
|
|
142
|
+
[], // no drafts
|
|
143
|
+
[], // no collections
|
|
144
|
+
"testuser",
|
|
145
|
+
{},
|
|
146
|
+
{ displayName: "Test User", userName: "testuser", description: "", pinnedWorks: [] },
|
|
147
|
+
null, // homepageFile
|
|
148
|
+
null, // folderName
|
|
149
|
+
onProgress,
|
|
150
|
+
);
|
|
151
|
+
// The per-item sync now drives the unified import task (was the legacy
|
|
152
|
+
// reportProgress path, which the panel dropped for the `process` hook).
|
|
153
|
+
expect(onProgress).toHaveBeenCalledWith(
|
|
154
|
+
"syncing_homepage",
|
|
155
|
+
expect.any(Number),
|
|
156
|
+
100,
|
|
157
|
+
expect.any(String),
|
|
158
|
+
);
|
|
159
|
+
// …carrying a real (non-zero) overall fraction, not a constant 0.
|
|
160
|
+
const homepageCall = onProgress.mock.calls.find((c) => c[0] === "syncing_homepage");
|
|
161
|
+
expect(homepageCall?.[1]).toBeGreaterThan(0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("should generate :::grid 3 homepage with pinned articles", async () => {
|
|
165
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
166
|
+
const result = await syncToLocalFiles(
|
|
167
|
+
[
|
|
168
|
+
{
|
|
169
|
+
id: "a1", title: "My Article", slug: "my-article", shortHash: "abc123",
|
|
170
|
+
content: "<p>Content</p>", summary: "Summary",
|
|
171
|
+
createdAt: "2024-01-01T00:00:00Z", tags: [],
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
[],
|
|
175
|
+
[],
|
|
176
|
+
"testuser",
|
|
177
|
+
{},
|
|
178
|
+
{
|
|
179
|
+
displayName: "Test User",
|
|
180
|
+
userName: "testuser",
|
|
181
|
+
description: "Bio text",
|
|
182
|
+
pinnedWorks: [
|
|
183
|
+
{ id: "a1", type: "article", title: "My Article", slug: "my-article", shortHash: "abc123" },
|
|
184
|
+
],
|
|
185
|
+
}
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const homepage = ctx.filesystem.getFile(`${ctx.projectPath}/index.md`)?.content;
|
|
189
|
+
expect(homepage).toBeDefined();
|
|
190
|
+
expect(homepage).toContain(":::grid 3");
|
|
191
|
+
expect(homepage).toContain("[My Article](/articles/my-article/)");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("should generate :::grid 3 homepage with mixed pinned works", async () => {
|
|
195
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
196
|
+
await syncToLocalFiles(
|
|
197
|
+
[
|
|
198
|
+
{
|
|
199
|
+
id: "a1", title: "Standalone Article", slug: "standalone-article", shortHash: "hash1",
|
|
200
|
+
content: "<p>Content</p>", summary: "Summary",
|
|
201
|
+
createdAt: "2024-01-01T00:00:00Z", tags: [],
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
[],
|
|
205
|
+
[
|
|
206
|
+
{ id: "c1", title: "My Collection", description: "Desc", articles: [], cover: undefined },
|
|
207
|
+
],
|
|
208
|
+
"testuser",
|
|
209
|
+
{},
|
|
210
|
+
{
|
|
211
|
+
displayName: "Test User",
|
|
212
|
+
userName: "testuser",
|
|
213
|
+
description: "Bio",
|
|
214
|
+
pinnedWorks: [
|
|
215
|
+
{ id: "c1", type: "collection", title: "My Collection" },
|
|
216
|
+
{ id: "a1", type: "article", title: "Standalone Article", slug: "standalone-article", shortHash: "hash1" },
|
|
217
|
+
],
|
|
218
|
+
}
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const homepage = ctx.filesystem.getFile(`${ctx.projectPath}/index.md`)?.content;
|
|
222
|
+
expect(homepage).toContain(":::grid 3");
|
|
223
|
+
expect(homepage).toContain("[My Collection](/articles/my-collection/)");
|
|
224
|
+
expect(homepage).toContain("[Standalone Article](/articles/standalone-article/)");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("should generate plain homepage when pinnedWorks is empty", async () => {
|
|
228
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
229
|
+
await syncToLocalFiles(
|
|
230
|
+
[], [], [], "testuser", {},
|
|
231
|
+
{ displayName: "Test User", userName: "testuser", description: "Just a bio", pinnedWorks: [] }
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const homepage = ctx.filesystem.getFile(`${ctx.projectPath}/index.md`)?.content;
|
|
235
|
+
expect(homepage).toBeDefined();
|
|
236
|
+
expect(homepage).not.toContain(":::grid");
|
|
237
|
+
expect(homepage).toContain("Just a bio");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("should still skip homepage when index.md already exists even with pinnedWorks", async () => {
|
|
241
|
+
ctx.filesystem.setFile(`${ctx.projectPath}/index.md`, "---\ntitle: \"Existing\"\n---\n\nMy custom homepage");
|
|
242
|
+
|
|
243
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
244
|
+
const result = await syncToLocalFiles(
|
|
245
|
+
[], [], [], "testuser", {},
|
|
246
|
+
{
|
|
247
|
+
displayName: "Test User",
|
|
248
|
+
userName: "testuser",
|
|
249
|
+
description: "Bio",
|
|
250
|
+
pinnedWorks: [
|
|
251
|
+
{ id: "c1", type: "collection", title: "Pinned Collection" },
|
|
252
|
+
],
|
|
253
|
+
}
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
expect(result.result.skipped).toBeGreaterThanOrEqual(1);
|
|
257
|
+
const homepage = ctx.filesystem.getFile(`${ctx.projectPath}/index.md`)?.content;
|
|
258
|
+
expect(homepage).toContain("My custom homepage");
|
|
259
|
+
expect(homepage).not.toContain(":::grid");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("should link pinned article to its collection folder when in a collection", async () => {
|
|
263
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
264
|
+
await syncToLocalFiles(
|
|
265
|
+
[
|
|
266
|
+
{
|
|
267
|
+
id: "a1", title: "Article In Collection", slug: "article-in-collection", shortHash: "hash1",
|
|
268
|
+
content: "<p>Content</p>", summary: "Summary",
|
|
269
|
+
createdAt: "2024-01-01T00:00:00Z", tags: [],
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
[],
|
|
273
|
+
[
|
|
274
|
+
{
|
|
275
|
+
id: "c1", title: "My Series", description: "A series",
|
|
276
|
+
articles: [{ id: "a1", shortHash: "hash1", title: "Article In Collection", slug: "article-in-collection" }],
|
|
277
|
+
cover: undefined,
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
"testuser",
|
|
281
|
+
{},
|
|
282
|
+
{
|
|
283
|
+
displayName: "Test User",
|
|
284
|
+
userName: "testuser",
|
|
285
|
+
description: "Bio",
|
|
286
|
+
pinnedWorks: [
|
|
287
|
+
{ id: "a1", type: "article", title: "Article In Collection", slug: "article-in-collection", shortHash: "hash1" },
|
|
288
|
+
],
|
|
289
|
+
}
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const homepage = ctx.filesystem.getFile(`${ctx.projectPath}/index.md`)?.content;
|
|
293
|
+
expect(homepage).toContain(":::grid 3");
|
|
294
|
+
// Article should link to its collection folder path
|
|
295
|
+
expect(homepage).toContain("[Article In Collection](/articles/my-series/article-in-collection/)");
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// ============================================================================
|
|
300
|
+
// Homepage skip when moss detects existing home file
|
|
301
|
+
// ============================================================================
|
|
302
|
+
|
|
303
|
+
describe("syncToLocalFiles - skip homepage when homepageFile is set", () => {
|
|
304
|
+
let ctx: MockTauriContext;
|
|
305
|
+
|
|
306
|
+
beforeEach(() => {
|
|
307
|
+
ctx = setupMockTauri({ projectPath: "/test-project" });
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
afterEach(() => {
|
|
311
|
+
ctx.cleanup();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("should skip homepage creation when homepageFile indicates an existing home file", async () => {
|
|
315
|
+
// moss detected "刘果.md" as the home file — Matters should NOT create index.md
|
|
316
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
317
|
+
const result = await syncToLocalFiles(
|
|
318
|
+
[], [], [], "testuser", {},
|
|
319
|
+
{ displayName: "Test User", userName: "testuser", description: "Bio", pinnedWorks: [] },
|
|
320
|
+
"刘果.md", // homepageFile — moss already found a home file
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
// Homepage should be skipped
|
|
324
|
+
expect(result.result.skipped).toBeGreaterThanOrEqual(1);
|
|
325
|
+
// index.md should NOT be created
|
|
326
|
+
const indexFile = ctx.filesystem.getFile(`${ctx.projectPath}/index.md`);
|
|
327
|
+
expect(indexFile).toBeUndefined();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("should skip homepage creation when homepageFile is index.md", async () => {
|
|
331
|
+
// moss detected "index.md" as the home file — even if readFile would fail,
|
|
332
|
+
// the homepageFile flag should short-circuit
|
|
333
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
334
|
+
const result = await syncToLocalFiles(
|
|
335
|
+
[], [], [], "testuser", {},
|
|
336
|
+
{ displayName: "Test User", userName: "testuser", description: "Bio", pinnedWorks: [] },
|
|
337
|
+
"index.md",
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
expect(result.result.skipped).toBeGreaterThanOrEqual(1);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("should still create homepage when homepageFile is null", async () => {
|
|
344
|
+
// No home file detected by moss — Matters should create index.md as before
|
|
345
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
346
|
+
const result = await syncToLocalFiles(
|
|
347
|
+
[], [], [], "testuser", {},
|
|
348
|
+
{ displayName: "Test User", userName: "testuser", description: "Bio", pinnedWorks: [] },
|
|
349
|
+
null,
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
expect(result.result.created).toBeGreaterThanOrEqual(1);
|
|
353
|
+
const indexFile = ctx.filesystem.getFile(`${ctx.projectPath}/index.md`);
|
|
354
|
+
expect(indexFile).toBeDefined();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("should still create homepage when homepageFile is undefined (backwards compat)", async () => {
|
|
358
|
+
// homepageFile not passed at all — existing behavior preserved
|
|
359
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
360
|
+
const result = await syncToLocalFiles(
|
|
361
|
+
[], [], [], "testuser", {},
|
|
362
|
+
{ displayName: "Test User", userName: "testuser", description: "Bio", pinnedWorks: [] },
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
expect(result.result.created).toBeGreaterThanOrEqual(1);
|
|
366
|
+
const indexFile = ctx.filesystem.getFile(`${ctx.projectPath}/index.md`);
|
|
367
|
+
expect(indexFile).toBeDefined();
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// ============================================================================
|
|
372
|
+
// Self-named home + home:true marker (bug #2)
|
|
373
|
+
// Generated homes follow moss's folder-home convention: a self-named
|
|
374
|
+
// `<folder>.md` file carrying a `home: true` frontmatter marker.
|
|
375
|
+
// ============================================================================
|
|
376
|
+
|
|
377
|
+
describe("syncToLocalFiles - self-named home with home:true marker", () => {
|
|
378
|
+
let ctx: MockTauriContext;
|
|
379
|
+
|
|
380
|
+
beforeEach(() => {
|
|
381
|
+
ctx = setupMockTauri({ projectPath: "/test-project" });
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
afterEach(() => {
|
|
385
|
+
ctx.cleanup();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const baseProfile = {
|
|
389
|
+
displayName: "Test User",
|
|
390
|
+
userName: "testuser",
|
|
391
|
+
description: "Bio",
|
|
392
|
+
pinnedWorks: [],
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
it("creates a self-named <folder>.md home carrying home:true when none exists", async () => {
|
|
396
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
397
|
+
const result = await syncToLocalFiles(
|
|
398
|
+
[], [], [], "testuser", {}, baseProfile,
|
|
399
|
+
null, // homepageFile — moss detected none
|
|
400
|
+
"刘果", // folderName — root folder basename
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
expect(result.result.created).toBeGreaterThanOrEqual(1);
|
|
404
|
+
const home = ctx.filesystem.getFile(`${ctx.projectPath}/刘果.md`)?.content;
|
|
405
|
+
expect(home).toBeDefined();
|
|
406
|
+
expect(home).toContain("home: true");
|
|
407
|
+
// Feature C: homepage title must use the vault folder name, not the Matters displayName.
|
|
408
|
+
expect(home).toContain('title: "刘果"');
|
|
409
|
+
// Must NOT create a competing index.md.
|
|
410
|
+
expect(ctx.filesystem.getFile(`${ctx.projectPath}/index.md`)).toBeUndefined();
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("falls back to index.md (still carrying home:true) when folderName is absent", async () => {
|
|
414
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
415
|
+
await syncToLocalFiles([], [], [], "testuser", {}, baseProfile, null);
|
|
416
|
+
|
|
417
|
+
const home = ctx.filesystem.getFile(`${ctx.projectPath}/index.md`)?.content;
|
|
418
|
+
expect(home).toBeDefined();
|
|
419
|
+
expect(home).toContain("home: true");
|
|
420
|
+
// When folderName is null/undefined, the homepage title falls back to the
|
|
421
|
+
// Matters profile displayName (the `folderName ?? profile.displayName` branch).
|
|
422
|
+
expect(home).toContain('title: "Test User"');
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("does not overwrite an existing self-named home", async () => {
|
|
426
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
427
|
+
ctx.filesystem.setFile(`${ctx.projectPath}/刘果.md`, "---\nhome: true\n---\n# Mine\n");
|
|
428
|
+
|
|
429
|
+
const result = await syncToLocalFiles(
|
|
430
|
+
[], [], [], "testuser", {}, baseProfile,
|
|
431
|
+
null, // moss didn't flag it, but the file is on disk
|
|
432
|
+
"刘果",
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
expect(result.result.skipped).toBeGreaterThanOrEqual(1);
|
|
436
|
+
expect(ctx.filesystem.getFile(`${ctx.projectPath}/刘果.md`)?.content).toContain("# Mine");
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// ============================================================================
|
|
441
|
+
// Folder-mode Collection Order Tests
|
|
442
|
+
// ============================================================================
|
|
443
|
+
|
|
444
|
+
describe("syncToLocalFiles - folder-mode collection order", () => {
|
|
445
|
+
let ctx: MockTauriContext;
|
|
446
|
+
|
|
447
|
+
beforeEach(() => {
|
|
448
|
+
ctx = setupMockTauri({ projectPath: "/test-project" });
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
afterEach(() => {
|
|
452
|
+
ctx.cleanup();
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("should generate order field with bare slugs in folder mode", async () => {
|
|
456
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
457
|
+
await syncToLocalFiles(
|
|
458
|
+
[
|
|
459
|
+
{
|
|
460
|
+
id: "a1", title: "First Article", slug: "first-article", shortHash: "hash1",
|
|
461
|
+
content: "<p>First</p>", summary: "First",
|
|
462
|
+
createdAt: "2024-01-01T00:00:00Z", tags: [],
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
id: "a2", title: "Second Article", slug: "second-article", shortHash: "hash2",
|
|
466
|
+
content: "<p>Second</p>", summary: "Second",
|
|
467
|
+
createdAt: "2024-01-02T00:00:00Z", tags: [],
|
|
468
|
+
},
|
|
469
|
+
],
|
|
470
|
+
[],
|
|
471
|
+
[{
|
|
472
|
+
id: "c1",
|
|
473
|
+
title: "My Collection",
|
|
474
|
+
description: "Collection desc",
|
|
475
|
+
cover: undefined,
|
|
476
|
+
articles: [
|
|
477
|
+
{ id: "a1", shortHash: "hash1", title: "First Article", slug: "first-article" },
|
|
478
|
+
{ id: "a2", shortHash: "hash2", title: "Second Article", slug: "second-article" },
|
|
479
|
+
],
|
|
480
|
+
}],
|
|
481
|
+
"testuser",
|
|
482
|
+
{},
|
|
483
|
+
{ displayName: "Test User", userName: "testuser", description: "", pinnedWorks: [] }
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
const collectionIndex = ctx.filesystem.getFile(`${ctx.projectPath}/articles/my-collection/my-collection.md`)?.content;
|
|
487
|
+
expect(collectionIndex).toBeDefined();
|
|
488
|
+
expect(collectionIndex).toContain("order:");
|
|
489
|
+
expect(collectionIndex).toContain("first-article");
|
|
490
|
+
expect(collectionIndex).toContain("second-article");
|
|
491
|
+
// In folder mode, order should NOT have full paths
|
|
492
|
+
expect(collectionIndex).not.toContain("posts/");
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("creates a self-named collection home <slug>.md carrying home:true in folder mode", async () => {
|
|
496
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
497
|
+
await syncToLocalFiles(
|
|
498
|
+
[
|
|
499
|
+
{
|
|
500
|
+
id: "a1", title: "First Article", slug: "first-article", shortHash: "hash1",
|
|
501
|
+
content: "<p>First</p>", summary: "First",
|
|
502
|
+
createdAt: "2024-01-01T00:00:00Z", tags: [],
|
|
503
|
+
},
|
|
504
|
+
],
|
|
505
|
+
[],
|
|
506
|
+
[{
|
|
507
|
+
id: "c1",
|
|
508
|
+
title: "My Collection",
|
|
509
|
+
description: "Collection desc",
|
|
510
|
+
cover: undefined,
|
|
511
|
+
articles: [
|
|
512
|
+
{ id: "a1", shortHash: "hash1", title: "First Article", slug: "first-article" },
|
|
513
|
+
],
|
|
514
|
+
}],
|
|
515
|
+
"testuser",
|
|
516
|
+
{},
|
|
517
|
+
{ displayName: "Test User", userName: "testuser", description: "", pinnedWorks: [] }
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
const home = ctx.filesystem.getFile(`${ctx.projectPath}/articles/my-collection/my-collection.md`)?.content;
|
|
521
|
+
expect(home).toBeDefined();
|
|
522
|
+
expect(home).toContain("home: true");
|
|
523
|
+
expect(home).toContain("order:");
|
|
524
|
+
// No competing index.md in the collection folder.
|
|
525
|
+
expect(ctx.filesystem.getFile(`${ctx.projectPath}/articles/my-collection/index.md`)).toBeUndefined();
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("should preserve article ordering from Matters API", async () => {
|
|
529
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
530
|
+
await syncToLocalFiles(
|
|
531
|
+
[
|
|
532
|
+
{
|
|
533
|
+
id: "a1", title: "Third", slug: "third", shortHash: "h3",
|
|
534
|
+
content: "<p>3</p>", summary: "3", createdAt: "2024-01-03T00:00:00Z", tags: [],
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
id: "a2", title: "First", slug: "first", shortHash: "h1",
|
|
538
|
+
content: "<p>1</p>", summary: "1", createdAt: "2024-01-01T00:00:00Z", tags: [],
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
id: "a3", title: "Second", slug: "second", shortHash: "h2",
|
|
542
|
+
content: "<p>2</p>", summary: "2", createdAt: "2024-01-02T00:00:00Z", tags: [],
|
|
543
|
+
},
|
|
544
|
+
],
|
|
545
|
+
[],
|
|
546
|
+
[{
|
|
547
|
+
id: "c1",
|
|
548
|
+
title: "Ordered Collection",
|
|
549
|
+
description: "",
|
|
550
|
+
cover: undefined,
|
|
551
|
+
articles: [
|
|
552
|
+
// Matters API returns articles in specific order
|
|
553
|
+
{ id: "a2", shortHash: "h1", title: "First", slug: "first" },
|
|
554
|
+
{ id: "a3", shortHash: "h2", title: "Second", slug: "second" },
|
|
555
|
+
{ id: "a1", shortHash: "h3", title: "Third", slug: "third" },
|
|
556
|
+
],
|
|
557
|
+
}],
|
|
558
|
+
"testuser",
|
|
559
|
+
{},
|
|
560
|
+
{ displayName: "Test User", userName: "testuser", description: "", pinnedWorks: [] }
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
const collectionIndex = ctx.filesystem.getFile(`${ctx.projectPath}/articles/ordered-collection/ordered-collection.md`)?.content;
|
|
564
|
+
expect(collectionIndex).toBeDefined();
|
|
565
|
+
// Order should match Matters API order: first, second, third
|
|
566
|
+
const orderMatch = collectionIndex!.match(/order:\n([\s\S]*?)---/);
|
|
567
|
+
expect(orderMatch).toBeTruthy();
|
|
568
|
+
const orderLines = orderMatch![1].trim().split("\n").map((l: string) => l.trim());
|
|
569
|
+
expect(orderLines[0]).toContain("first");
|
|
570
|
+
expect(orderLines[1]).toContain("second");
|
|
571
|
+
expect(orderLines[2]).toContain("third");
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
// ============================================================================
|
|
576
|
+
// Integration Tests with Mock Tauri
|
|
577
|
+
// ============================================================================
|
|
578
|
+
|
|
579
|
+
import { vi, beforeEach, afterEach } from "vitest";
|
|
580
|
+
import { setupMockTauri, type MockTauriContext } from "@symbiosis-lab/moss-api/testing";
|
|
581
|
+
|
|
582
|
+
// ============================================================================
|
|
583
|
+
// Tag whitespace trimming (B10)
|
|
584
|
+
// ============================================================================
|
|
585
|
+
|
|
586
|
+
describe("syncToLocalFiles - tag whitespace trimming", () => {
|
|
587
|
+
let ctx: MockTauriContext;
|
|
588
|
+
|
|
589
|
+
beforeEach(() => {
|
|
590
|
+
ctx = setupMockTauri({ projectPath: "/test-project" });
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
afterEach(() => {
|
|
594
|
+
ctx.cleanup();
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("trims leading/trailing whitespace from article tags and drops empties (B10)", async () => {
|
|
598
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
599
|
+
await syncToLocalFiles(
|
|
600
|
+
[
|
|
601
|
+
{
|
|
602
|
+
id: "a1",
|
|
603
|
+
title: "Tagged Article",
|
|
604
|
+
slug: "tagged-article",
|
|
605
|
+
shortHash: "tag123",
|
|
606
|
+
content: "<p>Content</p>",
|
|
607
|
+
summary: "Summary",
|
|
608
|
+
createdAt: "2024-01-01T00:00:00Z",
|
|
609
|
+
// Matters can hand us tags with surrounding whitespace + an all-blank one.
|
|
610
|
+
tags: [
|
|
611
|
+
{ id: "t1", content: " tag " },
|
|
612
|
+
{ id: "t2", content: "React\t" },
|
|
613
|
+
{ id: "t3", content: " " },
|
|
614
|
+
],
|
|
615
|
+
},
|
|
616
|
+
],
|
|
617
|
+
[],
|
|
618
|
+
[],
|
|
619
|
+
"testuser",
|
|
620
|
+
{},
|
|
621
|
+
{ displayName: "Test User", userName: "testuser", description: "", pinnedWorks: [] }
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
const article = ctx.filesystem.getFile(
|
|
625
|
+
`${ctx.projectPath}/articles/tagged-article.md`
|
|
626
|
+
)?.content;
|
|
627
|
+
expect(article).toBeDefined();
|
|
628
|
+
// Emitted trimmed — exact list entries, never the padded form.
|
|
629
|
+
expect(article).toContain(' - "tag"');
|
|
630
|
+
expect(article).toContain(' - "React"');
|
|
631
|
+
expect(article).not.toContain(' - " tag "');
|
|
632
|
+
expect(article).not.toContain('React\t');
|
|
633
|
+
// The all-whitespace tag is dropped entirely (no empty list entry).
|
|
634
|
+
expect(article).not.toContain(' - ""');
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
describe("syncToLocalFiles - skip unchanged content", () => {
|
|
639
|
+
let ctx: MockTauriContext;
|
|
640
|
+
|
|
641
|
+
beforeEach(() => {
|
|
642
|
+
ctx = setupMockTauri({ projectPath: "/test-project" });
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
afterEach(() => {
|
|
646
|
+
ctx.cleanup();
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it("should skip homepage when content is unchanged", async () => {
|
|
650
|
+
// Setup: Create existing homepage with same content that would be generated
|
|
651
|
+
// The generateFrontmatter function creates frontmatter in a specific format
|
|
652
|
+
const existingHomepage = `---
|
|
653
|
+
title: "Test User"
|
|
654
|
+
---
|
|
655
|
+
|
|
656
|
+
Test bio`;
|
|
657
|
+
ctx.filesystem.setFile(`${ctx.projectPath}/index.md`, existingHomepage);
|
|
658
|
+
|
|
659
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
660
|
+
const result = await syncToLocalFiles(
|
|
661
|
+
[], // no articles
|
|
662
|
+
[], // no drafts
|
|
663
|
+
[], // no collections
|
|
664
|
+
"testuser",
|
|
665
|
+
{},
|
|
666
|
+
{ displayName: "Test User", userName: "testuser", description: "Test bio", pinnedWorks: [] }
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
// Homepage should be skipped, not created
|
|
670
|
+
expect(result.result.skipped).toBeGreaterThanOrEqual(1);
|
|
671
|
+
expect(result.result.created).toBe(0);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it("should skip homepage when local file already exists", async () => {
|
|
675
|
+
// Setup: Create existing homepage with DIFFERENT content
|
|
676
|
+
const existingHomepage = `---
|
|
677
|
+
title: "Old Name"
|
|
678
|
+
---
|
|
679
|
+
|
|
680
|
+
Old bio`;
|
|
681
|
+
ctx.filesystem.setFile(`${ctx.projectPath}/index.md`, existingHomepage);
|
|
682
|
+
|
|
683
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
684
|
+
const result = await syncToLocalFiles(
|
|
685
|
+
[], // no articles
|
|
686
|
+
[], // no drafts
|
|
687
|
+
[], // no collections
|
|
688
|
+
"testuser",
|
|
689
|
+
{},
|
|
690
|
+
{ displayName: "New Name", userName: "testuser", description: "New bio", pinnedWorks: [] }
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
// Homepage should be skipped (local file preserved)
|
|
694
|
+
expect(result.result.skipped).toBeGreaterThanOrEqual(1);
|
|
695
|
+
|
|
696
|
+
// Verify local content was NOT overwritten
|
|
697
|
+
const preservedContent = ctx.filesystem.getFile(`${ctx.projectPath}/index.md`)?.content;
|
|
698
|
+
expect(preservedContent).toContain("Old Name");
|
|
699
|
+
expect(preservedContent).toContain("Old bio");
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
it("should skip collection when content is unchanged", async () => {
|
|
703
|
+
// Setup: Create existing collection with same content that sync would generate
|
|
704
|
+
const existingCollection = `---
|
|
705
|
+
title: "Test Collection"
|
|
706
|
+
description: "Collection description"
|
|
707
|
+
---
|
|
708
|
+
|
|
709
|
+
Collection description`;
|
|
710
|
+
ctx.filesystem.setFile(`${ctx.projectPath}/articles/test-collection/index.md`, existingCollection);
|
|
711
|
+
|
|
712
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
713
|
+
const result = await syncToLocalFiles(
|
|
714
|
+
[], // no articles
|
|
715
|
+
[], // no drafts
|
|
716
|
+
[{
|
|
717
|
+
id: "1",
|
|
718
|
+
title: "Test Collection",
|
|
719
|
+
description: "Collection description",
|
|
720
|
+
articles: [],
|
|
721
|
+
cover: null
|
|
722
|
+
}],
|
|
723
|
+
"testuser",
|
|
724
|
+
{},
|
|
725
|
+
{ displayName: "Test User", userName: "testuser", description: "", pinnedWorks: [] }
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
// Collection should be skipped if content matches
|
|
729
|
+
// result.skipped should include the collection
|
|
730
|
+
expect(result.result.skipped).toBeGreaterThanOrEqual(1);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it("should skip collection when local file already exists", async () => {
|
|
734
|
+
// Setup: Create existing collection with DIFFERENT content
|
|
735
|
+
const existingCollection = `---
|
|
736
|
+
title: "Old Collection Name"
|
|
737
|
+
---
|
|
738
|
+
|
|
739
|
+
Old description`;
|
|
740
|
+
ctx.filesystem.setFile(`${ctx.projectPath}/articles/test-collection/index.md`, existingCollection);
|
|
741
|
+
|
|
742
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
743
|
+
const result = await syncToLocalFiles(
|
|
744
|
+
[], // no articles
|
|
745
|
+
[], // no drafts
|
|
746
|
+
[{
|
|
747
|
+
id: "1",
|
|
748
|
+
title: "Test Collection",
|
|
749
|
+
description: "New description",
|
|
750
|
+
articles: [],
|
|
751
|
+
cover: null
|
|
752
|
+
}],
|
|
753
|
+
"testuser",
|
|
754
|
+
{},
|
|
755
|
+
{ displayName: "Test User", userName: "testuser", description: "", pinnedWorks: [] }
|
|
756
|
+
);
|
|
757
|
+
|
|
758
|
+
// Collection should be skipped (local file preserved)
|
|
759
|
+
expect(result.result.skipped).toBeGreaterThanOrEqual(1);
|
|
760
|
+
|
|
761
|
+
// Verify local content was NOT overwritten
|
|
762
|
+
const preservedContent = ctx.filesystem.getFile(`${ctx.projectPath}/articles/test-collection/index.md`)?.content;
|
|
763
|
+
expect(preservedContent).toContain("Old Collection Name");
|
|
764
|
+
expect(preservedContent).toContain("Old description");
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it("should skip article when file has been renamed but still has syndicated URL", async () => {
|
|
768
|
+
// Setup: Article exists locally at a RENAMED path, but has the original syndicated URL
|
|
769
|
+
const renamedArticle = `---
|
|
770
|
+
title: "My Better Title"
|
|
771
|
+
date: "2024-01-01T00:00:00Z"
|
|
772
|
+
syndicated:
|
|
773
|
+
- "https://matters.town/@testuser/original-title-abc123"
|
|
774
|
+
---
|
|
775
|
+
|
|
776
|
+
Article content`;
|
|
777
|
+
ctx.filesystem.setFile(`${ctx.projectPath}/articles/my-better-title.md`, renamedArticle);
|
|
778
|
+
|
|
779
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
780
|
+
const result = await syncToLocalFiles(
|
|
781
|
+
[
|
|
782
|
+
{
|
|
783
|
+
id: "a1",
|
|
784
|
+
title: "Original Title",
|
|
785
|
+
slug: "original-title",
|
|
786
|
+
shortHash: "abc123",
|
|
787
|
+
content: "<p>Article content</p>",
|
|
788
|
+
summary: "Summary",
|
|
789
|
+
createdAt: "2024-01-01T00:00:00Z",
|
|
790
|
+
tags: [],
|
|
791
|
+
},
|
|
792
|
+
],
|
|
793
|
+
[],
|
|
794
|
+
[],
|
|
795
|
+
"testuser",
|
|
796
|
+
{},
|
|
797
|
+
{
|
|
798
|
+
displayName: "Test User",
|
|
799
|
+
userName: "testuser",
|
|
800
|
+
description: "",
|
|
801
|
+
pinnedWorks: [],
|
|
802
|
+
}
|
|
803
|
+
);
|
|
804
|
+
|
|
805
|
+
// Article should be skipped (not duplicated at articles/original-title.md)
|
|
806
|
+
// skipped count includes homepage (already doesn't exist, so homepage is created)
|
|
807
|
+
// The article itself should be skipped
|
|
808
|
+
const articleFile = ctx.filesystem.getFile(`${ctx.projectPath}/articles/original-title.md`);
|
|
809
|
+
expect(articleFile).toBeUndefined();
|
|
810
|
+
|
|
811
|
+
// The renamed file should still exist untouched
|
|
812
|
+
const renamedFile = ctx.filesystem.getFile(`${ctx.projectPath}/articles/my-better-title.md`);
|
|
813
|
+
expect(renamedFile).toBeDefined();
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
it("should use actual local path in articlePathMap when file is renamed", async () => {
|
|
817
|
+
// Setup: Article exists at a renamed path
|
|
818
|
+
const renamedArticle = `---
|
|
819
|
+
title: "Renamed Article"
|
|
820
|
+
syndicated:
|
|
821
|
+
- "https://matters.town/@testuser/some-slug-xyz789"
|
|
822
|
+
---
|
|
823
|
+
|
|
824
|
+
Content`;
|
|
825
|
+
ctx.filesystem.setFile(`${ctx.projectPath}/articles/custom-name.md`, renamedArticle);
|
|
826
|
+
|
|
827
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
828
|
+
const result = await syncToLocalFiles(
|
|
829
|
+
[
|
|
830
|
+
{
|
|
831
|
+
id: "a1",
|
|
832
|
+
title: "Some Slug",
|
|
833
|
+
slug: "some-slug",
|
|
834
|
+
shortHash: "xyz789",
|
|
835
|
+
content: "<p>Content</p>",
|
|
836
|
+
summary: "Summary",
|
|
837
|
+
createdAt: "2024-01-01T00:00:00Z",
|
|
838
|
+
tags: [],
|
|
839
|
+
},
|
|
840
|
+
],
|
|
841
|
+
[],
|
|
842
|
+
[],
|
|
843
|
+
"testuser",
|
|
844
|
+
{},
|
|
845
|
+
{ displayName: "Test", userName: "testuser", description: "", pinnedWorks: [] }
|
|
846
|
+
);
|
|
847
|
+
|
|
848
|
+
// articlePathMap should map to the actual renamed path, not the computed one
|
|
849
|
+
const mattersUrl = "https://matters.town/@testuser/some-slug-xyz789";
|
|
850
|
+
expect(result.articlePathMap.get(mattersUrl)).toBe("articles/custom-name.md");
|
|
851
|
+
expect(result.articlePathMap.get("xyz789")).toBe("articles/custom-name.md");
|
|
852
|
+
});
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
// ============================================================================
|
|
856
|
+
// Collection skip when folder already has a home file
|
|
857
|
+
// ============================================================================
|
|
858
|
+
|
|
859
|
+
describe("syncToLocalFiles - skip collection index when folder has home file", () => {
|
|
860
|
+
let ctx: MockTauriContext;
|
|
861
|
+
|
|
862
|
+
beforeEach(() => {
|
|
863
|
+
ctx = setupMockTauri({ projectPath: "/test-project" });
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
afterEach(() => {
|
|
867
|
+
ctx.cleanup();
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it("should skip collection index.md when folder has a self-named note", async () => {
|
|
871
|
+
// Simulate: user has articles/my-collection/my-collection.md (Obsidian folder note)
|
|
872
|
+
ctx.filesystem.setFile(
|
|
873
|
+
`${ctx.projectPath}/articles/my-collection/my-collection.md`,
|
|
874
|
+
"---\ntitle: \"My Collection\"\n---\n\nUser's custom content"
|
|
875
|
+
);
|
|
876
|
+
|
|
877
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
878
|
+
const result = await syncToLocalFiles(
|
|
879
|
+
[], // no articles
|
|
880
|
+
[], // no drafts
|
|
881
|
+
[{
|
|
882
|
+
id: "c1",
|
|
883
|
+
title: "My Collection",
|
|
884
|
+
description: "Collection desc",
|
|
885
|
+
articles: [],
|
|
886
|
+
cover: null,
|
|
887
|
+
}],
|
|
888
|
+
"testuser",
|
|
889
|
+
{},
|
|
890
|
+
{ displayName: "Test User", userName: "testuser", description: "", pinnedWorks: [] }
|
|
891
|
+
);
|
|
892
|
+
|
|
893
|
+
// Collection should be skipped — self-named note is the home file
|
|
894
|
+
expect(result.result.skipped).toBeGreaterThanOrEqual(1);
|
|
895
|
+
// index.md should NOT be created
|
|
896
|
+
const indexFile = ctx.filesystem.getFile(`${ctx.projectPath}/articles/my-collection/index.md`);
|
|
897
|
+
expect(indexFile).toBeUndefined();
|
|
898
|
+
// Self-named note should be untouched
|
|
899
|
+
const selfNamed = ctx.filesystem.getFile(`${ctx.projectPath}/articles/my-collection/my-collection.md`);
|
|
900
|
+
expect(selfNamed?.content).toContain("User's custom content");
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it("should still create collection index.md when folder has no home file", async () => {
|
|
904
|
+
// No pre-existing files in the collection folder
|
|
905
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
906
|
+
const result = await syncToLocalFiles(
|
|
907
|
+
[], [], [],
|
|
908
|
+
"testuser", {},
|
|
909
|
+
{ displayName: "Test User", userName: "testuser", description: "", pinnedWorks: [] }
|
|
910
|
+
);
|
|
911
|
+
|
|
912
|
+
// With no collections, nothing to test — this is a control case
|
|
913
|
+
expect(result.result.created).toBeGreaterThanOrEqual(0);
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
it("should still create the self-named collection home when folder only has non-home files", async () => {
|
|
917
|
+
// Folder has an article but no home file
|
|
918
|
+
ctx.filesystem.setFile(
|
|
919
|
+
`${ctx.projectPath}/articles/my-collection/some-article.md`,
|
|
920
|
+
"---\ntitle: \"Some Article\"\n---\n\nArticle content"
|
|
921
|
+
);
|
|
922
|
+
|
|
923
|
+
const { syncToLocalFiles } = await import("../sync");
|
|
924
|
+
const result = await syncToLocalFiles(
|
|
925
|
+
[
|
|
926
|
+
{
|
|
927
|
+
id: "a1", title: "Some Article", slug: "some-article", shortHash: "hash1",
|
|
928
|
+
content: "<p>Article content</p>", summary: "Summary",
|
|
929
|
+
createdAt: "2024-01-01T00:00:00Z", tags: [],
|
|
930
|
+
},
|
|
931
|
+
],
|
|
932
|
+
[],
|
|
933
|
+
[{
|
|
934
|
+
id: "c1",
|
|
935
|
+
title: "My Collection",
|
|
936
|
+
description: "Collection desc",
|
|
937
|
+
articles: [{ id: "a1", shortHash: "hash1", title: "Some Article", slug: "some-article" }],
|
|
938
|
+
cover: null,
|
|
939
|
+
}],
|
|
940
|
+
"testuser",
|
|
941
|
+
{},
|
|
942
|
+
{ displayName: "Test User", userName: "testuser", description: "", pinnedWorks: [] }
|
|
943
|
+
);
|
|
944
|
+
|
|
945
|
+
// Self-named collection home SHOULD be created since some-article.md is not a home file
|
|
946
|
+
const home = ctx.filesystem.getFile(`${ctx.projectPath}/articles/my-collection/my-collection.md`);
|
|
947
|
+
expect(home).toBeDefined();
|
|
948
|
+
expect(home?.content).toContain("home: true");
|
|
949
|
+
// and no competing index.md
|
|
950
|
+
expect(ctx.filesystem.getFile(`${ctx.projectPath}/articles/my-collection/index.md`)).toBeUndefined();
|
|
951
|
+
});
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
// ============================================================================
|
|
955
|
+
// scanLocalArticles Tests
|
|
956
|
+
// ============================================================================
|
|
957
|
+
|
|
958
|
+
describe("scanLocalArticles", () => {
|
|
959
|
+
let ctx: MockTauriContext;
|
|
960
|
+
|
|
961
|
+
beforeEach(() => {
|
|
962
|
+
ctx = setupMockTauri({ projectPath: "/test-project" });
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
afterEach(() => {
|
|
966
|
+
ctx.cleanup();
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
it("finds articles with Matters syndicated URLs", async () => {
|
|
970
|
+
const articleContent = `---
|
|
971
|
+
title: "Test Article"
|
|
972
|
+
syndicated:
|
|
973
|
+
- "https://matters.town/@testuser/test-article-abc123"
|
|
974
|
+
---
|
|
975
|
+
|
|
976
|
+
Article content`;
|
|
977
|
+
ctx.filesystem.setFile(`${ctx.projectPath}/articles/test-article.md`, articleContent);
|
|
978
|
+
|
|
979
|
+
const { scanLocalArticles } = await import("../sync");
|
|
980
|
+
const articles = await scanLocalArticles();
|
|
981
|
+
|
|
982
|
+
expect(articles).toHaveLength(1);
|
|
983
|
+
expect(articles[0].shortHash).toBe("abc123");
|
|
984
|
+
expect(articles[0].title).toBe("Test Article");
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
it("finds articles syndicated with a /a/ short-link URL", async () => {
|
|
988
|
+
const articleContent = `---
|
|
989
|
+
title: "Short Link Article"
|
|
990
|
+
uid: 9136b141
|
|
991
|
+
syndicated:
|
|
992
|
+
- "https://matters.town/a/aj5szksg7ppa"
|
|
993
|
+
---
|
|
994
|
+
|
|
995
|
+
Article content`;
|
|
996
|
+
ctx.filesystem.setFile(`${ctx.projectPath}/articles/short-link.md`, articleContent);
|
|
997
|
+
|
|
998
|
+
const { scanLocalArticles } = await import("../sync");
|
|
999
|
+
const articles = await scanLocalArticles();
|
|
1000
|
+
|
|
1001
|
+
expect(articles).toHaveLength(1);
|
|
1002
|
+
expect(articles[0].shortHash).toBe("aj5szksg7ppa");
|
|
1003
|
+
expect(articles[0].uid).toBe("9136b141");
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
it("warns and skips a Matters URL whose shortHash cannot be parsed", async () => {
|
|
1007
|
+
const articleContent = `---
|
|
1008
|
+
title: "Unparseable Syndication"
|
|
1009
|
+
syndicated:
|
|
1010
|
+
- "https://matters.town/@testuser/nohyphenslug"
|
|
1011
|
+
---
|
|
1012
|
+
|
|
1013
|
+
Article content`;
|
|
1014
|
+
ctx.filesystem.setFile(`${ctx.projectPath}/articles/unparseable.md`, articleContent);
|
|
1015
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1016
|
+
|
|
1017
|
+
const { scanLocalArticles } = await import("../sync");
|
|
1018
|
+
const articles = await scanLocalArticles();
|
|
1019
|
+
|
|
1020
|
+
expect(articles).toHaveLength(0); // excluded, not silently included
|
|
1021
|
+
expect(warn).toHaveBeenCalledWith(
|
|
1022
|
+
expect.stringContaining("could not extract shortHash")
|
|
1023
|
+
);
|
|
1024
|
+
warn.mockRestore();
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
it("ignores files without syndicated field", async () => {
|
|
1028
|
+
const articleContent = `---
|
|
1029
|
+
title: "Local Only Article"
|
|
1030
|
+
---
|
|
1031
|
+
|
|
1032
|
+
Article content`;
|
|
1033
|
+
ctx.filesystem.setFile(`${ctx.projectPath}/articles/local-article.md`, articleContent);
|
|
1034
|
+
|
|
1035
|
+
const { scanLocalArticles } = await import("../sync");
|
|
1036
|
+
const articles = await scanLocalArticles();
|
|
1037
|
+
|
|
1038
|
+
expect(articles).toHaveLength(0);
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
it("ignores files with non-Matters syndicated URLs", async () => {
|
|
1042
|
+
const articleContent = `---
|
|
1043
|
+
title: "Cross-posted Article"
|
|
1044
|
+
syndicated:
|
|
1045
|
+
- "https://dev.to/testuser/article"
|
|
1046
|
+
---
|
|
1047
|
+
|
|
1048
|
+
Article content`;
|
|
1049
|
+
ctx.filesystem.setFile(`${ctx.projectPath}/articles/devto-article.md`, articleContent);
|
|
1050
|
+
|
|
1051
|
+
const { scanLocalArticles } = await import("../sync");
|
|
1052
|
+
const articles = await scanLocalArticles();
|
|
1053
|
+
|
|
1054
|
+
expect(articles).toHaveLength(0);
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
it("skips index.md and README.md", async () => {
|
|
1058
|
+
const indexContent = `---
|
|
1059
|
+
title: "Homepage"
|
|
1060
|
+
syndicated:
|
|
1061
|
+
- "https://matters.town/@testuser/home-abc123"
|
|
1062
|
+
---`;
|
|
1063
|
+
ctx.filesystem.setFile(`${ctx.projectPath}/index.md`, indexContent);
|
|
1064
|
+
ctx.filesystem.setFile(`${ctx.projectPath}/README.md`, indexContent);
|
|
1065
|
+
|
|
1066
|
+
const { scanLocalArticles } = await import("../sync");
|
|
1067
|
+
const articles = await scanLocalArticles();
|
|
1068
|
+
|
|
1069
|
+
expect(articles).toHaveLength(0);
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
it("skips _drafts folder", async () => {
|
|
1073
|
+
const draftContent = `---
|
|
1074
|
+
title: "Draft Article"
|
|
1075
|
+
syndicated:
|
|
1076
|
+
- "https://matters.town/@testuser/draft-abc123"
|
|
1077
|
+
---`;
|
|
1078
|
+
ctx.filesystem.setFile(`${ctx.projectPath}/_drafts/draft.md`, draftContent);
|
|
1079
|
+
|
|
1080
|
+
const { scanLocalArticles } = await import("../sync");
|
|
1081
|
+
const articles = await scanLocalArticles();
|
|
1082
|
+
|
|
1083
|
+
expect(articles).toHaveLength(0);
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
it("returns uid from frontmatter when present", async () => {
|
|
1087
|
+
const articleContent = `---
|
|
1088
|
+
title: "Article With UID"
|
|
1089
|
+
uid: "abc123-def456"
|
|
1090
|
+
syndicated:
|
|
1091
|
+
- "https://matters.town/@testuser/test-article-abc123"
|
|
1092
|
+
---
|
|
1093
|
+
|
|
1094
|
+
Article content`;
|
|
1095
|
+
ctx.filesystem.setFile(`${ctx.projectPath}/articles/test-article.md`, articleContent);
|
|
1096
|
+
|
|
1097
|
+
const { scanLocalArticles } = await import("../sync");
|
|
1098
|
+
const articles = await scanLocalArticles();
|
|
1099
|
+
|
|
1100
|
+
expect(articles).toHaveLength(1);
|
|
1101
|
+
expect(articles[0].uid).toBe("abc123-def456");
|
|
1102
|
+
expect(articles[0].shortHash).toBe("abc123");
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
it("returns null uid when not present in frontmatter", async () => {
|
|
1106
|
+
const articleContent = `---
|
|
1107
|
+
title: "Article Without UID"
|
|
1108
|
+
syndicated:
|
|
1109
|
+
- "https://matters.town/@testuser/test-article-xyz789"
|
|
1110
|
+
---
|
|
1111
|
+
|
|
1112
|
+
Article content`;
|
|
1113
|
+
ctx.filesystem.setFile(`${ctx.projectPath}/articles/test-article.md`, articleContent);
|
|
1114
|
+
|
|
1115
|
+
const { scanLocalArticles } = await import("../sync");
|
|
1116
|
+
const articles = await scanLocalArticles();
|
|
1117
|
+
|
|
1118
|
+
expect(articles).toHaveLength(1);
|
|
1119
|
+
expect(articles[0].uid).toBeNull();
|
|
1120
|
+
expect(articles[0].shortHash).toBe("xyz789");
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
it("returns uid for each article independently", async () => {
|
|
1124
|
+
const articleWithUid = `---
|
|
1125
|
+
title: "Has UID"
|
|
1126
|
+
uid: "uid-111"
|
|
1127
|
+
syndicated:
|
|
1128
|
+
- "https://matters.town/@testuser/has-uid-aaa111"
|
|
1129
|
+
---
|
|
1130
|
+
|
|
1131
|
+
Content`;
|
|
1132
|
+
const articleWithoutUid = `---
|
|
1133
|
+
title: "No UID"
|
|
1134
|
+
syndicated:
|
|
1135
|
+
- "https://matters.town/@testuser/no-uid-bbb222"
|
|
1136
|
+
---
|
|
1137
|
+
|
|
1138
|
+
Content`;
|
|
1139
|
+
ctx.filesystem.setFile(`${ctx.projectPath}/articles/has-uid.md`, articleWithUid);
|
|
1140
|
+
ctx.filesystem.setFile(`${ctx.projectPath}/articles/no-uid.md`, articleWithoutUid);
|
|
1141
|
+
|
|
1142
|
+
const { scanLocalArticles } = await import("../sync");
|
|
1143
|
+
const articles = await scanLocalArticles();
|
|
1144
|
+
|
|
1145
|
+
expect(articles).toHaveLength(2);
|
|
1146
|
+
const withUid = articles.find(a => a.shortHash === "aaa111");
|
|
1147
|
+
const withoutUid = articles.find(a => a.shortHash === "bbb222");
|
|
1148
|
+
expect(withUid?.uid).toBe("uid-111");
|
|
1149
|
+
expect(withoutUid?.uid).toBeNull();
|
|
1150
|
+
});
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
// ============================================================================
|
|
1154
|
+
// detectBoundUser Tests
|
|
1155
|
+
// ============================================================================
|
|
1156
|
+
|
|
1157
|
+
describe("detectBoundUser", () => {
|
|
1158
|
+
let ctx: MockTauriContext;
|
|
1159
|
+
|
|
1160
|
+
beforeEach(() => {
|
|
1161
|
+
ctx = setupMockTauri({ projectPath: "/test-project" });
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
afterEach(() => {
|
|
1165
|
+
ctx.cleanup();
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
it("returns username from Matters syndication URL", async () => {
|
|
1169
|
+
ctx.filesystem.setFile(
|
|
1170
|
+
`${ctx.projectPath}/articles/test-article.md`,
|
|
1171
|
+
`---
|
|
1172
|
+
title: "Test Article"
|
|
1173
|
+
syndicated:
|
|
1174
|
+
- "https://matters.town/@alice/test-article-abc123"
|
|
1175
|
+
---
|
|
1176
|
+
|
|
1177
|
+
Article content`
|
|
1178
|
+
);
|
|
1179
|
+
|
|
1180
|
+
const { detectBoundUser } = await import("../sync");
|
|
1181
|
+
const userName = await detectBoundUser();
|
|
1182
|
+
|
|
1183
|
+
expect(userName).toBe("alice");
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
it("returns null when no Matters content exists", async () => {
|
|
1187
|
+
ctx.filesystem.setFile(
|
|
1188
|
+
`${ctx.projectPath}/articles/local-article.md`,
|
|
1189
|
+
`---
|
|
1190
|
+
title: "Local Only"
|
|
1191
|
+
---
|
|
1192
|
+
|
|
1193
|
+
Content`
|
|
1194
|
+
);
|
|
1195
|
+
|
|
1196
|
+
const { detectBoundUser } = await import("../sync");
|
|
1197
|
+
const userName = await detectBoundUser();
|
|
1198
|
+
|
|
1199
|
+
expect(userName).toBeNull();
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
it("returns null when syndication URLs are from other platforms", async () => {
|
|
1203
|
+
ctx.filesystem.setFile(
|
|
1204
|
+
`${ctx.projectPath}/articles/devto-article.md`,
|
|
1205
|
+
`---
|
|
1206
|
+
title: "Cross-posted"
|
|
1207
|
+
syndicated:
|
|
1208
|
+
- "https://dev.to/alice/article-123"
|
|
1209
|
+
---
|
|
1210
|
+
|
|
1211
|
+
Content`
|
|
1212
|
+
);
|
|
1213
|
+
|
|
1214
|
+
const { detectBoundUser } = await import("../sync");
|
|
1215
|
+
const userName = await detectBoundUser();
|
|
1216
|
+
|
|
1217
|
+
expect(userName).toBeNull();
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
it("returns null when no markdown files exist", async () => {
|
|
1221
|
+
// Empty project
|
|
1222
|
+
const { detectBoundUser } = await import("../sync");
|
|
1223
|
+
const userName = await detectBoundUser();
|
|
1224
|
+
|
|
1225
|
+
expect(userName).toBeNull();
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
it("ignores hidden and underscore folders", async () => {
|
|
1229
|
+
ctx.filesystem.setFile(
|
|
1230
|
+
`${ctx.projectPath}/.hidden/article.md`,
|
|
1231
|
+
`---
|
|
1232
|
+
title: "Hidden"
|
|
1233
|
+
syndicated:
|
|
1234
|
+
- "https://matters.town/@alice/hidden-abc123"
|
|
1235
|
+
---
|
|
1236
|
+
|
|
1237
|
+
Content`
|
|
1238
|
+
);
|
|
1239
|
+
ctx.filesystem.setFile(
|
|
1240
|
+
`${ctx.projectPath}/_drafts/draft.md`,
|
|
1241
|
+
`---
|
|
1242
|
+
title: "Draft"
|
|
1243
|
+
syndicated:
|
|
1244
|
+
- "https://matters.town/@alice/draft-def456"
|
|
1245
|
+
---
|
|
1246
|
+
|
|
1247
|
+
Content`
|
|
1248
|
+
);
|
|
1249
|
+
|
|
1250
|
+
const { detectBoundUser } = await import("../sync");
|
|
1251
|
+
const userName = await detectBoundUser();
|
|
1252
|
+
|
|
1253
|
+
expect(userName).toBeNull();
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
it("extracts username from matters.town URL with @ prefix", async () => {
|
|
1257
|
+
ctx.filesystem.setFile(
|
|
1258
|
+
`${ctx.projectPath}/posts/my-post.md`,
|
|
1259
|
+
`---
|
|
1260
|
+
title: "My Post"
|
|
1261
|
+
syndicated:
|
|
1262
|
+
- "https://matters.town/@bob_writer/my-post-xyz789"
|
|
1263
|
+
---
|
|
1264
|
+
|
|
1265
|
+
Content`
|
|
1266
|
+
);
|
|
1267
|
+
|
|
1268
|
+
const { detectBoundUser } = await import("../sync");
|
|
1269
|
+
const userName = await detectBoundUser();
|
|
1270
|
+
|
|
1271
|
+
expect(userName).toBe("bob_writer");
|
|
1272
|
+
});
|
|
1273
|
+
});
|