@symbiosis-lab/moss-plugin-matters 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +18 -0
  3. package/assets/icon.svg +1 -0
  4. package/assets/manifest.json +36 -0
  5. package/codegen.ts +26 -0
  6. package/e2e/moss-cli.test.ts +338 -0
  7. package/features/api/fetch-articles.feature +39 -0
  8. package/features/auth/wallet-auth.feature +27 -0
  9. package/features/download/retry-logic.feature +36 -0
  10. package/features/download/self-correcting.feature +83 -0
  11. package/features/download/worker-pool.feature +29 -0
  12. package/features/social/fetch-social-data.feature +40 -0
  13. package/features/steps/api.steps.ts +180 -0
  14. package/features/steps/download.steps.ts +423 -0
  15. package/features/steps/incremental-sync.steps.ts +105 -0
  16. package/features/steps/self-correcting.steps.ts +575 -0
  17. package/features/steps/social.steps.ts +257 -0
  18. package/features/steps/syndication.steps.ts +264 -0
  19. package/features/steps/wallet-auth.steps.ts +185 -0
  20. package/features/sync/article-sync.feature +49 -0
  21. package/features/sync/homepage-grid.feature +43 -0
  22. package/features/sync/incremental-sync.feature +28 -0
  23. package/features/syndication/create-draft.feature +35 -0
  24. package/package.json +58 -0
  25. package/src/__generated__/schema.graphql +4289 -0
  26. package/src/__generated__/types.ts +5355 -0
  27. package/src/__tests__/api.test.ts +678 -0
  28. package/src/__tests__/auth-route.test.ts +38 -0
  29. package/src/__tests__/auth-routing.test.ts +462 -0
  30. package/src/__tests__/auto-detect.test.ts +412 -0
  31. package/src/__tests__/binding-guard.test.ts +256 -0
  32. package/src/__tests__/config.test.ts +212 -0
  33. package/src/__tests__/converter.test.ts +289 -0
  34. package/src/__tests__/credential.test.ts +332 -0
  35. package/src/__tests__/domain.test.ts +341 -0
  36. package/src/__tests__/downloader.test.ts +679 -0
  37. package/src/__tests__/folder-detection.test.ts +289 -0
  38. package/src/__tests__/force-fresh-login.test.ts +236 -0
  39. package/src/__tests__/main.test.ts +2437 -0
  40. package/src/__tests__/progress.test.ts +93 -0
  41. package/src/__tests__/session.test.ts +375 -0
  42. package/src/__tests__/social-integration.test.ts +386 -0
  43. package/src/__tests__/social-sync-logic.test.ts +107 -0
  44. package/src/__tests__/social.test.ts +788 -0
  45. package/src/__tests__/sync.test.ts +1273 -0
  46. package/src/__tests__/syndication-toast-law.test.ts +649 -0
  47. package/src/__tests__/syndication.test.ts +125 -0
  48. package/src/__tests__/test-profile-escape.test.ts +209 -0
  49. package/src/__tests__/url-detect.test.ts +79 -0
  50. package/src/__tests__/utils.test.ts +226 -0
  51. package/src/api.ts +1366 -0
  52. package/src/auth-route.ts +38 -0
  53. package/src/config.ts +80 -0
  54. package/src/converter.ts +305 -0
  55. package/src/credential.ts +329 -0
  56. package/src/domain.ts +183 -0
  57. package/src/downloader.ts +761 -0
  58. package/src/main.ts +2092 -0
  59. package/src/progress.ts +89 -0
  60. package/src/queries/user.graphql +85 -0
  61. package/src/queries/viewer.graphql +104 -0
  62. package/src/social.ts +413 -0
  63. package/src/sync.ts +818 -0
  64. package/src/types.ts +477 -0
  65. package/src/url-detect.ts +49 -0
  66. package/src/utils.ts +305 -0
  67. package/test-fixtures/syndication-test-site/input/index.md +8 -0
  68. package/test-fixtures/syndication-test-site/input/posts/rich-test-article.md +90 -0
  69. package/test-helpers/TEST_ACCOUNT.md +151 -0
  70. package/test-helpers/api-client.ts +252 -0
  71. package/test-helpers/fixtures/articles.ts +147 -0
  72. package/test-helpers/wallet-auth.ts +305 -0
  73. package/test-setup/e2e.ts +93 -0
  74. package/tsconfig.json +23 -0
  75. package/vitest.config.ts +39 -0
@@ -0,0 +1,212 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { setupMockTauri, type MockTauriContext } from "@symbiosis-lab/moss-api/testing";
3
+
4
+ import {
5
+ getConfig,
6
+ saveConfig,
7
+ type MattersPluginConfig,
8
+ } from "../config";
9
+
10
+ describe("Config Module", () => {
11
+ let ctx: MockTauriContext;
12
+
13
+ beforeEach(() => {
14
+ ctx = setupMockTauri({ pluginName: "matters-syndicator" });
15
+ });
16
+
17
+ afterEach(() => {
18
+ ctx.cleanup();
19
+ });
20
+
21
+ describe("getConfig", () => {
22
+ it("returns default config when file does not exist", async () => {
23
+ // No file set up = file doesn't exist
24
+ const config = await getConfig();
25
+ expect(config).toEqual({});
26
+ });
27
+
28
+ it("returns parsed config when file exists", async () => {
29
+ const savedConfig: MattersPluginConfig = {
30
+ userName: "testuser",
31
+ language: "zh_hant",
32
+ };
33
+ // Set up the config file in the plugin's storage directory
34
+ ctx.filesystem.setFile(
35
+ `${ctx.projectPath}/.moss/plugins/matters-syndicator/config.json`,
36
+ JSON.stringify(savedConfig)
37
+ );
38
+
39
+ const config = await getConfig();
40
+ expect(config).toEqual(savedConfig);
41
+ });
42
+
43
+ it("returns empty config on parse error", async () => {
44
+ ctx.filesystem.setFile(
45
+ `${ctx.projectPath}/.moss/plugins/matters-syndicator/config.json`,
46
+ "invalid json {{{"
47
+ );
48
+
49
+ const config = await getConfig();
50
+ expect(config).toEqual({});
51
+ });
52
+ });
53
+
54
+ describe("saveConfig", () => {
55
+ it("writes config to plugin storage", async () => {
56
+ const config: MattersPluginConfig = {
57
+ userName: "testuser",
58
+ language: "en",
59
+ };
60
+
61
+ await saveConfig(config);
62
+
63
+ const savedContent = ctx.filesystem.getFile(
64
+ `${ctx.projectPath}/.moss/plugins/matters-syndicator/config.json`
65
+ );
66
+ expect(savedContent).toBeDefined();
67
+ expect(JSON.parse(savedContent!.content)).toEqual(config);
68
+ });
69
+
70
+ it("preserves existing fields when updating", async () => {
71
+ const existingConfig: MattersPluginConfig = {
72
+ userName: "olduser",
73
+ language: "zh_hant",
74
+ };
75
+ ctx.filesystem.setFile(
76
+ `${ctx.projectPath}/.moss/plugins/matters-syndicator/config.json`,
77
+ JSON.stringify(existingConfig)
78
+ );
79
+
80
+ // Get existing config, merge, and save
81
+ const existing = await getConfig();
82
+ const merged = { ...existing, userName: "newuser" };
83
+ await saveConfig(merged);
84
+
85
+ const savedContent = ctx.filesystem.getFile(
86
+ `${ctx.projectPath}/.moss/plugins/matters-syndicator/config.json`
87
+ );
88
+ expect(JSON.parse(savedContent!.content)).toEqual({
89
+ userName: "newuser",
90
+ language: "zh_hant",
91
+ });
92
+ });
93
+ });
94
+
95
+ describe("Config schema", () => {
96
+ it("supports userName field", async () => {
97
+ const config: MattersPluginConfig = { userName: "Matty" };
98
+ ctx.filesystem.setFile(
99
+ `${ctx.projectPath}/.moss/plugins/matters-syndicator/config.json`,
100
+ JSON.stringify(config)
101
+ );
102
+
103
+ const result = await getConfig();
104
+ expect(result.userName).toBe("Matty");
105
+ });
106
+
107
+ it("supports language field", async () => {
108
+ const config: MattersPluginConfig = { language: "zh_hans" };
109
+ ctx.filesystem.setFile(
110
+ `${ctx.projectPath}/.moss/plugins/matters-syndicator/config.json`,
111
+ JSON.stringify(config)
112
+ );
113
+
114
+ const result = await getConfig();
115
+ expect(result.language).toBe("zh_hans");
116
+ });
117
+
118
+ it("supports both userName and language", async () => {
119
+ const config: MattersPluginConfig = {
120
+ userName: "刘果",
121
+ language: "zh_hant",
122
+ };
123
+ ctx.filesystem.setFile(
124
+ `${ctx.projectPath}/.moss/plugins/matters-syndicator/config.json`,
125
+ JSON.stringify(config)
126
+ );
127
+
128
+ const result = await getConfig();
129
+ expect(result.userName).toBe("刘果");
130
+ expect(result.language).toBe("zh_hant");
131
+ });
132
+
133
+ it("handles empty config object", async () => {
134
+ ctx.filesystem.setFile(
135
+ `${ctx.projectPath}/.moss/plugins/matters-syndicator/config.json`,
136
+ "{}"
137
+ );
138
+
139
+ const result = await getConfig();
140
+ expect(result).toEqual({});
141
+ expect(result.userName).toBeUndefined();
142
+ expect(result.language).toBeUndefined();
143
+ });
144
+
145
+ it("supports lastSyncedAt field for incremental sync", async () => {
146
+ const timestamp = "2024-01-15T12:00:00.000Z";
147
+ const config: MattersPluginConfig = {
148
+ userName: "testuser",
149
+ lastSyncedAt: timestamp,
150
+ };
151
+ ctx.filesystem.setFile(
152
+ `${ctx.projectPath}/.moss/plugins/matters-syndicator/config.json`,
153
+ JSON.stringify(config)
154
+ );
155
+
156
+ const result = await getConfig();
157
+ expect(result.lastSyncedAt).toBe(timestamp);
158
+ });
159
+
160
+ it("stores and retrieves all config fields together", async () => {
161
+ const config: MattersPluginConfig = {
162
+ userName: "testuser",
163
+ language: "zh_hant",
164
+ lastSyncedAt: "2024-01-15T12:00:00.000Z",
165
+ };
166
+ ctx.filesystem.setFile(
167
+ `${ctx.projectPath}/.moss/plugins/matters-syndicator/config.json`,
168
+ JSON.stringify(config)
169
+ );
170
+
171
+ const result = await getConfig();
172
+ expect(result).toEqual(config);
173
+ });
174
+ });
175
+
176
+ describe("Incremental Sync", () => {
177
+ it("can update lastSyncedAt while preserving other fields", async () => {
178
+ const initialConfig: MattersPluginConfig = {
179
+ userName: "testuser",
180
+ language: "en",
181
+ };
182
+ ctx.filesystem.setFile(
183
+ `${ctx.projectPath}/.moss/plugins/matters-syndicator/config.json`,
184
+ JSON.stringify(initialConfig)
185
+ );
186
+
187
+ // Read, update, and save
188
+ const config = await getConfig();
189
+ const newTimestamp = new Date().toISOString();
190
+ await saveConfig({ ...config, lastSyncedAt: newTimestamp });
191
+
192
+ const savedContent = ctx.filesystem.getFile(
193
+ `${ctx.projectPath}/.moss/plugins/matters-syndicator/config.json`
194
+ );
195
+ const parsed = JSON.parse(savedContent!.content);
196
+
197
+ expect(parsed.userName).toBe("testuser");
198
+ expect(parsed.language).toBe("en");
199
+ expect(parsed.lastSyncedAt).toBe(newTimestamp);
200
+ });
201
+
202
+ it("lastSyncedAt is undefined on first sync", async () => {
203
+ ctx.filesystem.setFile(
204
+ `${ctx.projectPath}/.moss/plugins/matters-syndicator/config.json`,
205
+ JSON.stringify({ userName: "testuser" })
206
+ );
207
+
208
+ const config = await getConfig();
209
+ expect(config.lastSyncedAt).toBeUndefined();
210
+ });
211
+ });
212
+ });
@@ -0,0 +1,289 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ generateFrontmatter,
4
+ parseFrontmatter,
5
+ regenerateFrontmatter,
6
+ extractRemoteImageUrls,
7
+ extractMarkdownLinks,
8
+ } from "../converter";
9
+
10
+ // NOTE: the `htmlToMarkdown` describe block was deleted with the hand-rolled
11
+ // converter (B4). Production HTML→Markdown now runs through moss's shared Rust
12
+ // `htmd` converter (imported from `@symbiosis-lab/moss-api`), so the old tests
13
+ // no longer have a unit under test. They also enshrined the lone-backslash
14
+ // `<br>` output (`line1\\\nline2`) — i.e. the exact B3 bug the project fixed —
15
+ // which would be false confidence to keep.
16
+
17
+ describe("generateFrontmatter", () => {
18
+ it("generates basic frontmatter", () => {
19
+ const result = generateFrontmatter({ title: "Test Title" });
20
+ expect(result).toContain("---");
21
+ expect(result).toContain('title: "Test Title"');
22
+ });
23
+
24
+ it("escapes quotes in title", () => {
25
+ const result = generateFrontmatter({ title: 'Test "Quote"' });
26
+ expect(result).toContain('title: "Test \\"Quote\\""');
27
+ });
28
+
29
+ it("includes date and updated", () => {
30
+ const result = generateFrontmatter({
31
+ title: "Test",
32
+ date: "2024-01-01",
33
+ updated: "2024-01-02",
34
+ });
35
+ expect(result).toContain('date: "2024-01-01"');
36
+ expect(result).toContain('updated: "2024-01-02"');
37
+ });
38
+
39
+ it("includes tags array", () => {
40
+ const result = generateFrontmatter({
41
+ title: "Test",
42
+ tags: ["tag1", "tag2"],
43
+ });
44
+ expect(result).toContain("tags:");
45
+ expect(result).toContain(' - "tag1"');
46
+ expect(result).toContain(' - "tag2"');
47
+ });
48
+
49
+ it("includes cover", () => {
50
+ const result = generateFrontmatter({
51
+ title: "Test",
52
+ cover: "cover.jpg",
53
+ });
54
+ expect(result).toContain('cover: "cover.jpg"');
55
+ });
56
+
57
+ it("includes syndicated URLs", () => {
58
+ const result = generateFrontmatter({
59
+ title: "Test",
60
+ syndicated: ["https://example.com/article"],
61
+ });
62
+ expect(result).toContain("syndicated:");
63
+ expect(result).toContain(' - "https://example.com/article"');
64
+ });
65
+
66
+ it("includes collections mapping", () => {
67
+ const result = generateFrontmatter({
68
+ title: "Test",
69
+ collections: { "my-collection": 0, "other-collection": 1 },
70
+ });
71
+ expect(result).toContain("collections:");
72
+ expect(result).toContain(" my-collection: 0");
73
+ expect(result).toContain(" other-collection: 1");
74
+ });
75
+ });
76
+
77
+ describe("parseFrontmatter", () => {
78
+ it("parses basic frontmatter", () => {
79
+ const content = `---
80
+ title: "Test Title"
81
+ ---
82
+ Body content`;
83
+ const result = parseFrontmatter(content);
84
+ expect(result).not.toBeNull();
85
+ expect(result?.frontmatter.title).toBe("Test Title");
86
+ expect(result?.body).toBe("Body content");
87
+ });
88
+
89
+ it("parses array values", () => {
90
+ const content = `---
91
+ title: "Test"
92
+ tags:
93
+ - "tag1"
94
+ - "tag2"
95
+ ---
96
+ Body`;
97
+ const result = parseFrontmatter(content);
98
+ expect(result?.frontmatter.tags).toEqual(["tag1", "tag2"]);
99
+ });
100
+
101
+ it("returns null for content without frontmatter", () => {
102
+ const content = "Just some text without frontmatter";
103
+ expect(parseFrontmatter(content)).toBeNull();
104
+ });
105
+
106
+ it("returns null for malformed frontmatter", () => {
107
+ const content = `---
108
+ title: Test
109
+ No closing delimiter`;
110
+ expect(parseFrontmatter(content)).toBeNull();
111
+ });
112
+
113
+ it("handles empty body", () => {
114
+ const content = `---
115
+ title: "Test"
116
+ ---
117
+ `;
118
+ const result = parseFrontmatter(content);
119
+ expect(result?.body).toBe("");
120
+ });
121
+ });
122
+
123
+ describe("regenerateFrontmatter", () => {
124
+ it("regenerates basic frontmatter", () => {
125
+ const result = regenerateFrontmatter({ title: "Test" });
126
+ expect(result).toContain("---");
127
+ expect(result).toContain('title: "Test"');
128
+ });
129
+
130
+ it("preserves field order", () => {
131
+ const result = regenerateFrontmatter({
132
+ tags: ["a", "b"],
133
+ title: "Test",
134
+ date: "2024-01-01",
135
+ });
136
+ const lines = result.split("\n");
137
+ const titleIndex = lines.findIndex((l) => l.includes("title:"));
138
+ const dateIndex = lines.findIndex((l) => l.includes("date:"));
139
+ const tagsIndex = lines.findIndex((l) => l === "tags:");
140
+ expect(titleIndex).toBeLessThan(dateIndex);
141
+ expect(dateIndex).toBeLessThan(tagsIndex);
142
+ });
143
+
144
+ it("handles nested objects", () => {
145
+ const result = regenerateFrontmatter({
146
+ collections: { "col-1": 0, "col-2": 1 },
147
+ });
148
+ expect(result).toContain("collections:");
149
+ expect(result).toContain(" col-1: 0");
150
+ });
151
+ });
152
+
153
+ describe("extractRemoteImageUrls", () => {
154
+ it("extracts HTTP image URLs", () => {
155
+ const content = "![alt](http://example.com/image.jpg)";
156
+ const result = extractRemoteImageUrls(content);
157
+ expect(result).toHaveLength(1);
158
+ expect(result[0].url).toBe("http://example.com/image.jpg");
159
+ });
160
+
161
+ it("extracts HTTPS image URLs", () => {
162
+ const content = "![alt](https://example.com/image.png)";
163
+ const result = extractRemoteImageUrls(content);
164
+ expect(result).toHaveLength(1);
165
+ expect(result[0].url).toBe("https://example.com/image.png");
166
+ });
167
+
168
+ it("ignores local image paths", () => {
169
+ const content = "![alt](./images/local.jpg)";
170
+ const result = extractRemoteImageUrls(content);
171
+ expect(result).toHaveLength(0);
172
+ });
173
+
174
+ it("ignores relative paths", () => {
175
+ const content = "![alt](assets/image.jpg)";
176
+ const result = extractRemoteImageUrls(content);
177
+ expect(result).toHaveLength(0);
178
+ });
179
+
180
+ it("deduplicates URLs", () => {
181
+ const content = `
182
+ ![img1](https://example.com/image.jpg)
183
+ ![img2](https://example.com/image.jpg)
184
+ `;
185
+ const result = extractRemoteImageUrls(content);
186
+ expect(result).toHaveLength(1);
187
+ });
188
+
189
+ it("extracts multiple different URLs", () => {
190
+ const content = `
191
+ ![img1](https://example.com/image1.jpg)
192
+ ![img2](https://example.com/image2.png)
193
+ `;
194
+ const result = extractRemoteImageUrls(content);
195
+ expect(result).toHaveLength(2);
196
+ });
197
+
198
+ it("generates local filenames", () => {
199
+ const content = "![alt](https://cdn.example.com/uploads/photo.jpg)";
200
+ const result = extractRemoteImageUrls(content);
201
+ expect(result[0].localFilename).toBe("photo.jpg");
202
+ });
203
+
204
+ it("handles empty content", () => {
205
+ const result = extractRemoteImageUrls("");
206
+ expect(result).toHaveLength(0);
207
+ });
208
+
209
+ it("handles content with no images", () => {
210
+ const content = "Just some text without images";
211
+ const result = extractRemoteImageUrls(content);
212
+ expect(result).toHaveLength(0);
213
+ });
214
+ });
215
+
216
+ describe("extractMarkdownLinks", () => {
217
+ it("extracts markdown links", () => {
218
+ const content = "Check out [my link](https://example.com)";
219
+ const result = extractMarkdownLinks(content);
220
+ expect(result).toHaveLength(1);
221
+ expect(result[0].url).toBe("https://example.com");
222
+ expect(result[0].fullMatch).toBe("[my link](https://example.com)");
223
+ });
224
+
225
+ it("does NOT extract image syntax", () => {
226
+ const content = "![alt text](https://example.com/image.jpg)";
227
+ const result = extractMarkdownLinks(content);
228
+ expect(result).toHaveLength(0);
229
+ });
230
+
231
+ it("extracts links but not images in mixed content", () => {
232
+ const content = `
233
+ Here is a [link](https://example.com) and
234
+ an image ![image](https://example.com/img.png) and
235
+ another [second link](https://other.com).
236
+ `;
237
+ const result = extractMarkdownLinks(content);
238
+ expect(result).toHaveLength(2);
239
+ expect(result[0].url).toBe("https://example.com");
240
+ expect(result[1].url).toBe("https://other.com");
241
+ });
242
+
243
+ it("extracts Matters.town article links", () => {
244
+ const content = "Read my [previous article](https://matters.town/@alice/hello-world-abc123)";
245
+ const result = extractMarkdownLinks(content);
246
+ expect(result).toHaveLength(1);
247
+ expect(result[0].url).toBe("https://matters.town/@alice/hello-world-abc123");
248
+ });
249
+
250
+ it("handles multiple links on same line", () => {
251
+ const content = "[link1](https://a.com) and [link2](https://b.com)";
252
+ const result = extractMarkdownLinks(content);
253
+ expect(result).toHaveLength(2);
254
+ expect(result[0].url).toBe("https://a.com");
255
+ expect(result[1].url).toBe("https://b.com");
256
+ });
257
+
258
+ it("handles links with empty text", () => {
259
+ const content = "[](https://example.com)";
260
+ const result = extractMarkdownLinks(content);
261
+ expect(result).toHaveLength(1);
262
+ expect(result[0].url).toBe("https://example.com");
263
+ });
264
+
265
+ it("handles relative links", () => {
266
+ const content = "[local](./path/to/file.md)";
267
+ const result = extractMarkdownLinks(content);
268
+ expect(result).toHaveLength(1);
269
+ expect(result[0].url).toBe("./path/to/file.md");
270
+ });
271
+
272
+ it("handles empty content", () => {
273
+ const result = extractMarkdownLinks("");
274
+ expect(result).toHaveLength(0);
275
+ });
276
+
277
+ it("handles content with no links", () => {
278
+ const content = "Just plain text without any links";
279
+ const result = extractMarkdownLinks(content);
280
+ expect(result).toHaveLength(0);
281
+ });
282
+
283
+ it("trims whitespace from URLs", () => {
284
+ const content = "[link]( https://example.com )";
285
+ const result = extractMarkdownLinks(content);
286
+ expect(result).toHaveLength(1);
287
+ expect(result[0].url).toBe("https://example.com");
288
+ });
289
+ });