@lapis-notes/lapis-plugin-docs 0.0.0-stage → 0.1.0

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 (196) hide show
  1. package/README.md +188 -2
  2. package/dist/docs/DocsBlockMarkdownEditor.svelte +37 -0
  3. package/dist/docs/DocsBlockMarkdownEditor.svelte.d.ts +13 -0
  4. package/dist/docs/DocsBlockRefPicker.svelte +75 -0
  5. package/dist/docs/DocsBlockRefPicker.svelte.d.ts +11 -0
  6. package/dist/docs/DocsBookEditor.svelte +141 -0
  7. package/dist/docs/DocsBookEditor.svelte.d.ts +10 -0
  8. package/dist/docs/DocsBookNavigation.svelte +838 -0
  9. package/dist/docs/DocsBookNavigation.svelte.d.ts +11 -0
  10. package/dist/docs/DocsBooksColumn.svelte +82 -0
  11. package/dist/docs/DocsBooksColumn.svelte.d.ts +10 -0
  12. package/dist/docs/DocsCardBackMatter.svelte +32 -0
  13. package/dist/docs/DocsCardBackMatter.svelte.d.ts +10 -0
  14. package/dist/docs/DocsCardBlockExtra.svelte +43 -0
  15. package/dist/docs/DocsCardBlockExtra.svelte.d.ts +9 -0
  16. package/dist/docs/DocsCardPreviewFlip.svelte +143 -0
  17. package/dist/docs/DocsCardPreviewFlip.svelte.d.ts +17 -0
  18. package/dist/docs/DocsCardPreviewStack.svelte +135 -0
  19. package/dist/docs/DocsCardPreviewStack.svelte.d.ts +13 -0
  20. package/dist/docs/DocsCardPrompt.svelte +33 -0
  21. package/dist/docs/DocsCardPrompt.svelte.d.ts +8 -0
  22. package/dist/docs/DocsCardReviewAnswer.svelte +45 -0
  23. package/dist/docs/DocsCardReviewAnswer.svelte.d.ts +8 -0
  24. package/dist/docs/DocsCardTagChips.svelte +19 -0
  25. package/dist/docs/DocsCardTagChips.svelte.d.ts +7 -0
  26. package/dist/docs/DocsCardsPanel.svelte +232 -0
  27. package/dist/docs/DocsCardsPanel.svelte.d.ts +17 -0
  28. package/dist/docs/DocsCardsReview.css +1050 -0
  29. package/dist/docs/DocsChapterFooter.svelte +248 -0
  30. package/dist/docs/DocsChapterFooter.svelte.d.ts +11 -0
  31. package/dist/docs/DocsChapterScrollRing.svelte +101 -0
  32. package/dist/docs/DocsChapterScrollRing.svelte.d.ts +6 -0
  33. package/dist/docs/DocsDisplayPanel.css +627 -0
  34. package/dist/docs/DocsDisplayPanel.svelte +78 -0
  35. package/dist/docs/DocsDisplayPanel.svelte.d.ts +16 -0
  36. package/dist/docs/DocsEditorPanel.css +325 -0
  37. package/dist/docs/DocsEditorPanel.svelte +570 -0
  38. package/dist/docs/DocsEditorPanel.svelte.d.ts +22 -0
  39. package/dist/docs/DocsFlipCard.svelte +68 -0
  40. package/dist/docs/DocsFlipCard.svelte.d.ts +8 -0
  41. package/dist/docs/DocsFolderDirectoryList.svelte +206 -0
  42. package/dist/docs/DocsFolderDirectoryList.svelte.d.ts +14 -0
  43. package/dist/docs/DocsFolderPanel.css +357 -0
  44. package/dist/docs/DocsFolderPanel.svelte +102 -0
  45. package/dist/docs/DocsFolderPanel.svelte.d.ts +17 -0
  46. package/dist/docs/DocsLinksPanel.css +61 -0
  47. package/dist/docs/DocsLinksPanel.svelte +55 -0
  48. package/dist/docs/DocsLinksPanel.svelte.d.ts +11 -0
  49. package/dist/docs/DocsMarkdownField.svelte +93 -0
  50. package/dist/docs/DocsMarkdownField.svelte.d.ts +11 -0
  51. package/dist/docs/DocsMarkdownPreview.svelte +278 -0
  52. package/dist/docs/DocsMarkdownPreview.svelte.d.ts +17 -0
  53. package/dist/docs/DocsReviewPanel.svelte +63 -0
  54. package/dist/docs/DocsReviewPanel.svelte.d.ts +11 -0
  55. package/dist/docs/DocsRichBlock.svelte +291 -0
  56. package/dist/docs/DocsRichBlock.svelte.d.ts +13 -0
  57. package/dist/docs/DocsRichDisplay.svelte +316 -0
  58. package/dist/docs/DocsRichDisplay.svelte.d.ts +14 -0
  59. package/dist/docs/DocsSlidesPreview.css +243 -0
  60. package/dist/docs/DocsSlidesPreview.svelte +206 -0
  61. package/dist/docs/DocsSlidesPreview.svelte.d.ts +10 -0
  62. package/dist/docs/DocsWorkspace.css +570 -0
  63. package/dist/docs/DocsWorkspace.svelte +831 -0
  64. package/dist/docs/DocsWorkspace.svelte.d.ts +48 -0
  65. package/dist/docs/DocsWorkspaceToolbar.svelte +211 -0
  66. package/dist/docs/DocsWorkspaceToolbar.svelte.d.ts +27 -0
  67. package/dist/docs/candidates/ChoiceMenu.svelte +114 -0
  68. package/dist/docs/candidates/ChoiceMenu.svelte.d.ts +10 -0
  69. package/dist/docs/candidates/FlipCardDeck.svelte +800 -0
  70. package/dist/docs/candidates/FlipCardDeck.svelte.d.ts +36 -0
  71. package/dist/docs/candidates/MarkdownFloatingToc.svelte +313 -0
  72. package/dist/docs/candidates/MarkdownFloatingToc.svelte.d.ts +14 -0
  73. package/dist/docs/candidates/SearchableChoicePicker.css +161 -0
  74. package/dist/docs/candidates/SearchableChoicePicker.svelte +208 -0
  75. package/dist/docs/candidates/SearchableChoicePicker.svelte.d.ts +19 -0
  76. package/dist/docs/candidates/StructuredTable.svelte +98 -0
  77. package/dist/docs/candidates/StructuredTable.svelte.d.ts +8 -0
  78. package/dist/docs/candidates/StructuredTableEditor.svelte +374 -0
  79. package/dist/docs/candidates/StructuredTableEditor.svelte.d.ts +12 -0
  80. package/dist/docs/candidates/TagEditor.svelte +31 -0
  81. package/dist/docs/candidates/TagEditor.svelte.d.ts +14 -0
  82. package/dist/docs/candidates/WorkspaceHistoryControls.svelte +112 -0
  83. package/dist/docs/candidates/WorkspaceHistoryControls.svelte.d.ts +8 -0
  84. package/dist/docs/candidates/markdown-floating-toc.css +151 -0
  85. package/dist/docs/candidates/markdown-floating-toc.d.ts +1 -0
  86. package/dist/docs/candidates/markdown-floating-toc.js +1 -0
  87. package/dist/docs/docs-anki-import.d.ts +34 -0
  88. package/dist/docs/docs-anki-import.js +516 -0
  89. package/dist/docs/docs-book-view-model.d.ts +9 -0
  90. package/dist/docs/docs-book-view-model.js +71 -0
  91. package/dist/docs/docs-card-answer-markdown.d.ts +1 -0
  92. package/dist/docs/docs-card-answer-markdown.js +63 -0
  93. package/dist/docs/docs-card-display.d.ts +10 -0
  94. package/dist/docs/docs-card-display.js +18 -0
  95. package/dist/docs/docs-display-focus.d.ts +7 -0
  96. package/dist/docs/docs-display-focus.js +42 -0
  97. package/dist/docs/docs-document-surface.d.ts +7 -0
  98. package/dist/docs/docs-document-surface.js +25 -0
  99. package/dist/docs/docs-folder-directory.d.ts +21 -0
  100. package/dist/docs/docs-folder-directory.js +56 -0
  101. package/dist/docs/docs-link-mode.d.ts +16 -0
  102. package/dist/docs/docs-link-mode.js +94 -0
  103. package/dist/docs/docs-workspace-helpers.d.ts +25 -0
  104. package/dist/docs/docs-workspace-helpers.js +195 -0
  105. package/dist/docs/docs.tokens.css +13 -0
  106. package/dist/docs/import-anki-package.d.ts +10 -0
  107. package/dist/docs/import-anki-package.js +30 -0
  108. package/dist/docs/lapis-markdown-extensions.d.ts +9 -0
  109. package/dist/docs/lapis-markdown-extensions.js +128 -0
  110. package/dist/docs/pin-reading-chrome.d.ts +4 -0
  111. package/dist/docs/pin-reading-chrome.js +32 -0
  112. package/dist/docs/sidebar/DocsFoldersSidebar.svelte +134 -0
  113. package/dist/docs/sidebar/DocsFoldersSidebar.svelte.d.ts +15 -0
  114. package/dist/docs/sidebar/DocsLinksSidebar.svelte +105 -0
  115. package/dist/docs/sidebar/DocsLinksSidebar.svelte.d.ts +11 -0
  116. package/dist/docs/sidebar/DocsSidebar.css +346 -0
  117. package/dist/docs/sidebar/DocsSidebar.svelte +430 -0
  118. package/dist/docs/sidebar/DocsSidebar.svelte.d.ts +18 -0
  119. package/dist/docs/sidebar/DocsSidebarHost.svelte +139 -0
  120. package/dist/docs/sidebar/DocsSidebarHost.svelte.d.ts +12 -0
  121. package/dist/docs/sidebar/DocsTagsSidebar.svelte +176 -0
  122. package/dist/docs/sidebar/DocsTagsSidebar.svelte.d.ts +12 -0
  123. package/dist/docs/sidebar/docs-book-note-tree.d.ts +23 -0
  124. package/dist/docs/sidebar/docs-book-note-tree.js +88 -0
  125. package/dist/docs/sidebar/docs-note-sort.d.ts +26 -0
  126. package/dist/docs/sidebar/docs-note-sort.js +54 -0
  127. package/dist/docs/sidebar/docs-sidebar-filters.d.ts +17 -0
  128. package/dist/docs/sidebar/docs-sidebar-filters.js +74 -0
  129. package/dist/docs/sidebar/docs-sidebar-ui-storage.d.ts +24 -0
  130. package/dist/docs/sidebar/docs-sidebar-ui-storage.js +87 -0
  131. package/dist/docs/sidebar/docs-source-folder-tree.d.ts +16 -0
  132. package/dist/docs/sidebar/docs-source-folder-tree.js +143 -0
  133. package/dist/docs/sidebar/docs-tag-tree.d.ts +17 -0
  134. package/dist/docs/sidebar/docs-tag-tree.js +84 -0
  135. package/dist/docs-cards-view.d.ts +18 -0
  136. package/dist/docs-cards-view.js +52 -0
  137. package/dist/docs-document-view.d.ts +24 -0
  138. package/dist/docs-document-view.js +127 -0
  139. package/dist/docs-document-workspace.svelte +215 -0
  140. package/dist/docs-document-workspace.svelte.d.ts +16 -0
  141. package/dist/docs-index.d.ts +5 -0
  142. package/dist/docs-index.js +49 -0
  143. package/dist/docs-path.d.ts +6 -0
  144. package/dist/docs-path.js +10 -0
  145. package/dist/docs-plugin.d.ts +21 -0
  146. package/dist/docs-plugin.js +140 -0
  147. package/dist/docs-repository.d.ts +15 -0
  148. package/dist/docs-repository.js +75 -0
  149. package/dist/docs-review-view.d.ts +18 -0
  150. package/dist/docs-review-view.js +52 -0
  151. package/dist/docs-settings-tab.d.ts +8 -0
  152. package/dist/docs-settings-tab.js +25 -0
  153. package/dist/docs-settings.d.ts +17 -0
  154. package/dist/docs-settings.js +38 -0
  155. package/dist/docs-view.d.ts +22 -0
  156. package/dist/docs-view.js +51 -0
  157. package/dist/docs-workspace.svelte +394 -0
  158. package/dist/docs-workspace.svelte.d.ts +15 -0
  159. package/dist/index.d.ts +12 -0
  160. package/dist/index.js +12 -0
  161. package/dist/model/book.d.ts +6 -0
  162. package/dist/model/book.js +101 -0
  163. package/dist/model/coding-runtime.d.ts +4 -0
  164. package/dist/model/coding-runtime.js +9 -0
  165. package/dist/model/docs-card-context.d.ts +32 -0
  166. package/dist/model/docs-card-context.js +89 -0
  167. package/dist/model/docs-card-preview-stack-motion.d.ts +51 -0
  168. package/dist/model/docs-card-preview-stack-motion.js +94 -0
  169. package/dist/model/docs-media-context.d.ts +9 -0
  170. package/dist/model/docs-media-context.js +18 -0
  171. package/dist/model/docs-note-list.d.ts +8 -0
  172. package/dist/model/docs-note-list.js +25 -0
  173. package/dist/model/docs-relative-media.d.ts +30 -0
  174. package/dist/model/docs-relative-media.js +116 -0
  175. package/dist/model/docs-review-constants.d.ts +4 -0
  176. package/dist/model/docs-review-constants.js +13 -0
  177. package/dist/model/fsrs.d.ts +14 -0
  178. package/dist/model/fsrs.js +153 -0
  179. package/dist/model/graph.d.ts +12 -0
  180. package/dist/model/graph.js +169 -0
  181. package/dist/model/index.d.ts +14 -0
  182. package/dist/model/index.js +14 -0
  183. package/dist/model/markdown-normalize.d.ts +11 -0
  184. package/dist/model/markdown-normalize.js +755 -0
  185. package/dist/model/markdown.d.ts +167 -0
  186. package/dist/model/markdown.js +365 -0
  187. package/dist/model/sample.d.ts +7 -0
  188. package/dist/model/sample.js +524 -0
  189. package/dist/model/types.d.ts +328 -0
  190. package/dist/model/types.js +1 -0
  191. package/dist/model/workspace.d.ts +6 -0
  192. package/dist/model/workspace.js +81 -0
  193. package/dist/storybook.css +52 -0
  194. package/dist/styles.css +1 -0
  195. package/manifest.json +11 -0
  196. package/package.json +147 -4
@@ -0,0 +1,516 @@
1
+ /// <reference types="node" />
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { basename, resolve } from 'node:path';
5
+ import { createClient } from '@libsql/client';
6
+ import { buildDocsWorkspace, createDocsNote, slugifyDocsId } from '../model';
7
+ import { unzipSync } from 'fflate';
8
+ const ANKI_FIELD_SEPARATOR = '\x1f';
9
+ const SUPPORTED_IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif']);
10
+ const CLOZE_PATTERN = /\{\{c\d+::[\s\S]*?\}\}/;
11
+ function uniqueList(values) {
12
+ return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
13
+ }
14
+ function inValueString(value) {
15
+ if (value === undefined || value === null)
16
+ return '';
17
+ if (value instanceof Uint8Array)
18
+ return new TextDecoder().decode(value);
19
+ return String(value);
20
+ }
21
+ function inValueNumber(value) {
22
+ if (typeof value === 'number')
23
+ return value;
24
+ if (typeof value === 'bigint')
25
+ return Number(value);
26
+ const parsed = Number(value ?? 0);
27
+ return Number.isFinite(parsed) ? parsed : 0;
28
+ }
29
+ function rowString(row, key) {
30
+ return inValueString(row[key]);
31
+ }
32
+ function rowNumber(row, key) {
33
+ return inValueNumber(row[key]);
34
+ }
35
+ function parseJsonRecord(value, label) {
36
+ const parsed = JSON.parse(value || '{}');
37
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
38
+ throw new Error(`Invalid Anki ${label} metadata.`);
39
+ }
40
+ return parsed;
41
+ }
42
+ function ankiObjectId(record, fallback) {
43
+ return String(record.id ?? record.deckId ?? fallback);
44
+ }
45
+ function parseDecks(value) {
46
+ const decks = new Map();
47
+ for (const [fallback, raw] of Object.entries(parseJsonRecord(value, 'deck'))) {
48
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
49
+ continue;
50
+ const record = raw;
51
+ const id = ankiObjectId(record, fallback);
52
+ const name = String(record.name ?? 'Imported Anki Deck').trim() || 'Imported Anki Deck';
53
+ decks.set(id, {
54
+ id,
55
+ name,
56
+ dyn: typeof record.dyn === 'number' ? record.dyn : undefined
57
+ });
58
+ }
59
+ return decks;
60
+ }
61
+ function parseModels(value) {
62
+ const models = new Map();
63
+ for (const [fallback, raw] of Object.entries(parseJsonRecord(value, 'model'))) {
64
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
65
+ continue;
66
+ const record = raw;
67
+ const id = ankiObjectId(record, fallback);
68
+ models.set(id, {
69
+ id,
70
+ name: String(record.name ?? 'Anki note type').trim() || 'Anki note type',
71
+ type: typeof record.type === 'number' ? record.type : undefined,
72
+ flds: Array.isArray(record.flds) ? record.flds : [],
73
+ tmpls: Array.isArray(record.tmpls) ? record.tmpls : []
74
+ });
75
+ }
76
+ return models;
77
+ }
78
+ function ankiTags(value) {
79
+ return uniqueList(value.split(/\s+/));
80
+ }
81
+ function fieldMap(model, rawFields) {
82
+ const fields = rawFields.split(ANKI_FIELD_SEPARATOR);
83
+ const names = model?.flds?.length
84
+ ? [...model.flds].sort((left, right) => (left.ord ?? 0) - (right.ord ?? 0))
85
+ : [];
86
+ const entries = fields.map((value, index) => [names[index]?.name ?? `Field ${index + 1}`, value]);
87
+ return Object.fromEntries(entries);
88
+ }
89
+ function firstNonEmptyField(fields) {
90
+ return Object.values(fields).find((value) => stripHtml(value).trim()) ?? '';
91
+ }
92
+ function summarizeFields(fields) {
93
+ return Object.entries(fields)
94
+ .map(([name, value]) => {
95
+ const text = htmlToMarkdown(value, new Map()).replace(/\s+/g, ' ').trim();
96
+ return text ? `**${name}:** ${text}` : '';
97
+ })
98
+ .filter(Boolean)
99
+ .join('\n\n');
100
+ }
101
+ function safeZipEntries(bytes) {
102
+ const entries = unzipSync(bytes);
103
+ for (const name of Object.keys(entries)) {
104
+ const normalized = name.replaceAll('\\', '/');
105
+ if (!normalized ||
106
+ normalized.startsWith('/') ||
107
+ normalized.includes('\0') ||
108
+ normalized.split('/').some((segment) => segment === '..')) {
109
+ throw new Error(`Unsupported unsafe Anki package entry: ${name}`);
110
+ }
111
+ }
112
+ return entries;
113
+ }
114
+ function collectionEntry(entries) {
115
+ if (entries['collection.anki21'])
116
+ return { name: 'collection.anki21', bytes: entries['collection.anki21'] };
117
+ if (entries['collection.anki2'])
118
+ return { name: 'collection.anki2', bytes: entries['collection.anki2'] };
119
+ if (entries['collection.anki21b']) {
120
+ throw new Error('.colpkg and collection.anki21b packages are not supported.');
121
+ }
122
+ throw new Error('Anki package is missing collection.anki21 or collection.anki2.');
123
+ }
124
+ function extensionForMedia(filename) {
125
+ const extension = filename.split('.').at(-1)?.toLowerCase() ?? '';
126
+ return SUPPORTED_IMAGE_EXTENSIONS.has(extension) ? extension : null;
127
+ }
128
+ function safeMediaFilename(filename, used) {
129
+ const rawBase = basename(filename.replaceAll('\\', '/'));
130
+ if (!rawBase || rawBase === '.' || rawBase === '..')
131
+ return null;
132
+ const extension = extensionForMedia(rawBase);
133
+ if (!extension)
134
+ return null;
135
+ const base = rawBase
136
+ .replace(/\.[a-z0-9]+$/i, '')
137
+ .toLowerCase()
138
+ .replace(/[^a-z0-9]+/g, '-')
139
+ .replace(/^-+|-+$/g, '')
140
+ .slice(0, 60) || 'image';
141
+ let candidate = `${base}.${extension === 'jpeg' ? 'jpg' : extension}`;
142
+ let suffix = 2;
143
+ while (used.has(candidate)) {
144
+ candidate = `${base}-${suffix++}.${extension === 'jpeg' ? 'jpg' : extension}`;
145
+ }
146
+ used.add(candidate);
147
+ return candidate;
148
+ }
149
+ function prepareMedia(entries, warnings) {
150
+ const rawMedia = entries.media ? new TextDecoder().decode(entries.media) : '{}';
151
+ const parsed = parseJsonRecord(rawMedia, 'media');
152
+ const used = new Set();
153
+ const media = [];
154
+ for (const [archiveName, originalValue] of Object.entries(parsed)) {
155
+ const originalName = String(originalValue ?? '').trim();
156
+ const bytes = entries[archiveName];
157
+ if (!originalName)
158
+ continue;
159
+ if (!bytes) {
160
+ warnings.push(`Media entry missing from package: ${originalName}`);
161
+ continue;
162
+ }
163
+ if (originalName.includes('\0') || originalName.split(/[\\/]+/).some((segment) => segment === '..')) {
164
+ warnings.push(`Skipped unsafe media filename: ${originalName}`);
165
+ continue;
166
+ }
167
+ if (!extensionForMedia(originalName)) {
168
+ warnings.push(`Skipped unsupported media file: ${originalName}`);
169
+ continue;
170
+ }
171
+ const filename = safeMediaFilename(originalName, used);
172
+ if (!filename) {
173
+ warnings.push(`Skipped unsafe media filename: ${originalName}`);
174
+ continue;
175
+ }
176
+ media.push({ archiveName, originalName, filename, bytes });
177
+ }
178
+ return media;
179
+ }
180
+ function mediaReferenceMap(media, noteId) {
181
+ const map = new Map();
182
+ for (const file of media) {
183
+ const relativePath = `${noteId}/media/${file.filename}`;
184
+ map.set(file.originalName, relativePath);
185
+ map.set(basename(file.originalName.replaceAll('\\', '/')), relativePath);
186
+ }
187
+ return map;
188
+ }
189
+ function decodeHtmlEntity(entity) {
190
+ const named = {
191
+ amp: '&',
192
+ lt: '<',
193
+ gt: '>',
194
+ quot: '"',
195
+ apos: "'",
196
+ nbsp: ' '
197
+ };
198
+ if (entity.startsWith('#x'))
199
+ return String.fromCodePoint(Number.parseInt(entity.slice(2), 16));
200
+ if (entity.startsWith('#'))
201
+ return String.fromCodePoint(Number.parseInt(entity.slice(1), 10));
202
+ return named[entity] ?? `&${entity};`;
203
+ }
204
+ function decodeHtml(value) {
205
+ return value.replace(/&([a-zA-Z]+|#\d+|#x[0-9a-fA-F]+);/g, (_match, entity) => {
206
+ try {
207
+ return decodeHtmlEntity(entity);
208
+ }
209
+ catch {
210
+ return _match;
211
+ }
212
+ });
213
+ }
214
+ function stripHtml(value) {
215
+ return decodeHtml(value.replace(/<[^>]*>/g, ' '));
216
+ }
217
+ function rewriteMediaSrc(src, mediaMap) {
218
+ const trimmed = decodeHtml(src.trim());
219
+ if (!trimmed || /^(https?:|data:|\/)/i.test(trimmed))
220
+ return trimmed;
221
+ const decoded = (() => {
222
+ try {
223
+ return decodeURIComponent(trimmed);
224
+ }
225
+ catch {
226
+ return trimmed;
227
+ }
228
+ })();
229
+ return mediaMap.get(decoded) ?? mediaMap.get(basename(decoded.replaceAll('\\', '/'))) ?? trimmed;
230
+ }
231
+ function htmlToMarkdown(value, mediaMap) {
232
+ let markdown = value.replace(/<img\b[^>]*\bsrc=(["']?)([^"'\s>]+)\1[^>]*>/gi, (_match, _q, src) => {
233
+ const rewritten = rewriteMediaSrc(src, mediaMap);
234
+ return rewritten ? `![Image](${rewritten})` : '';
235
+ });
236
+ markdown = markdown.replace(/<br\s*\/?>/gi, '\n');
237
+ markdown = markdown.replace(/<\/(?:p|div|h[1-6]|li|tr)>/gi, '\n');
238
+ markdown = markdown.replace(/<li[^>]*>/gi, '- ');
239
+ markdown = markdown.replace(/<hr\b[^>]*>/gi, '\n\n---\n\n');
240
+ markdown = markdown.replace(/<(?:strong|b)\b[^>]*>([\s\S]*?)<\/(?:strong|b)>/gi, '**$1**');
241
+ markdown = markdown.replace(/<(?:em|i)\b[^>]*>([\s\S]*?)<\/(?:em|i)>/gi, '*$1*');
242
+ markdown = markdown.replace(/<code\b[^>]*>([\s\S]*?)<\/code>/gi, '`$1`');
243
+ markdown = markdown.replace(/<[^>]*>/g, '');
244
+ return decodeHtml(markdown)
245
+ .replace(/\r\n?/g, '\n')
246
+ .replace(/[ \t]+\n/g, '\n')
247
+ .replace(/\n{3,}/g, '\n\n')
248
+ .trim();
249
+ }
250
+ function labeledFields(note, mediaMap) {
251
+ const rendered = Object.entries(note.fields)
252
+ .map(([name, value]) => {
253
+ const markdown = htmlToMarkdown(value, mediaMap);
254
+ return markdown ? `**${name}**\n\n${markdown}` : '';
255
+ })
256
+ .filter(Boolean)
257
+ .join('\n\n');
258
+ return rendered || note.fieldSummary || firstNonEmptyField(note.fields) || note.guid;
259
+ }
260
+ function templateForCard(model, ord) {
261
+ const templates = [...(model?.tmpls ?? [])].sort((left, right) => (left.ord ?? 0) - (right.ord ?? 0));
262
+ return templates[ord] ?? templates.find((template) => (template.ord ?? 0) === ord) ?? templates[0];
263
+ }
264
+ function fieldText(fields, name, mediaMap) {
265
+ return htmlToMarkdown(fields[name] ?? '', mediaMap);
266
+ }
267
+ function renderConditionals(template, fields) {
268
+ let rendered = template;
269
+ for (let pass = 0; pass < 4; pass += 1) {
270
+ rendered = rendered.replace(/\{\{([#^])([^}]+)\}\}([\s\S]*?)\{\{\/\2\}\}/g, (_match, mode, rawName, body) => {
271
+ const name = rawName.trim();
272
+ const filled = Boolean(stripHtml(fields[name] ?? '').trim());
273
+ return mode === '#' ? (filled ? body : '') : filled ? '' : body;
274
+ });
275
+ }
276
+ return rendered;
277
+ }
278
+ function renderTemplate(input) {
279
+ const { note, deck, mediaMap } = input;
280
+ let rendered = renderConditionals(input.template, note.fields);
281
+ rendered = rendered.replace(/\{\{FrontSide\}\}/g, input.frontSide ?? '');
282
+ rendered = rendered.replace(/\{\{Tags\}\}/g, note.tags.join(' '));
283
+ rendered = rendered.replace(/\{\{Deck\}\}/g, deck.name);
284
+ rendered = rendered.replace(/\{\{Card\}\}/g, input.templateName);
285
+ rendered = rendered.replace(/\{\{(?:type:|text:|cloze:)?([^}]+)\}\}/g, (_match, rawName) => fieldText(note.fields, rawName.trim(), mediaMap));
286
+ rendered = rendered.replace(/\{\{(?!c\d+::)[^}]+\}\}/g, '');
287
+ return htmlToMarkdown(rendered, mediaMap);
288
+ }
289
+ function ankiCardStatus(card) {
290
+ return card.queue < 0 ? 'suspended' : 'active';
291
+ }
292
+ function uniqueCardId(existing, note, card) {
293
+ const base = `card-${slugifyDocsId(`${note.guid || note.id}-${card.ord + 1}`, 'anki-card')}`;
294
+ let id = base;
295
+ let suffix = 2;
296
+ while (existing.has(id))
297
+ id = `${base}-${suffix++}`;
298
+ existing.add(id);
299
+ return id;
300
+ }
301
+ function docsCardFromAnki(input) {
302
+ const template = templateForCard(input.note.model, input.card.ord);
303
+ const prompt = (template?.qfmt
304
+ ? renderTemplate({
305
+ template: template.qfmt,
306
+ note: input.note,
307
+ deck: input.deck,
308
+ templateName: template.name,
309
+ mediaMap: input.mediaMap
310
+ })
311
+ : '') || labeledFields(input.note, input.mediaMap);
312
+ const answer = (template?.afmt
313
+ ? renderTemplate({
314
+ template: template.afmt,
315
+ note: input.note,
316
+ deck: input.deck,
317
+ templateName: template.name,
318
+ mediaMap: input.mediaMap,
319
+ frontSide: ''
320
+ })
321
+ : '') || labeledFields(input.note, input.mediaMap);
322
+ return {
323
+ id: uniqueCardId(input.existingCardIds, input.note, input.card),
324
+ type: CLOZE_PATTERN.test(prompt) ? 'cloze' : 'basic',
325
+ status: ankiCardStatus(input.card),
326
+ prompt,
327
+ answer,
328
+ tags: uniqueList(['anki', input.deckTag, ...input.importTags, ...input.note.tags]),
329
+ blockRefs: [],
330
+ reviews: []
331
+ };
332
+ }
333
+ function uniqueDocsId(existingIds, seed) {
334
+ const base = slugifyDocsId(seed, 'anki-deck');
335
+ let id = base;
336
+ let suffix = 2;
337
+ while (existingIds.has(id))
338
+ id = `${base}-${suffix++}`;
339
+ existingIds.add(id);
340
+ return id;
341
+ }
342
+ function deckTitle(deck) {
343
+ return deck.name.replace(/::/g, ' / ');
344
+ }
345
+ function modelNames(cards, notes) {
346
+ return uniqueList(cards.map((card) => notes.get(card.nid)?.model?.name ?? '').filter(Boolean));
347
+ }
348
+ function createImportedNote(input) {
349
+ const deckTag = `anki/${slugifyDocsId(input.deck.name.replace(/::/g, '-'), 'deck')}`;
350
+ const body = [
351
+ `Imported from Anki deck **${deckTitle(input.deck)}**.`,
352
+ `- Cards imported: ${input.cards.length}`,
353
+ input.modelNames.length ? `- Note types: ${input.modelNames.join(', ')}` : undefined,
354
+ `- Media copied: ${input.mediaCount}`,
355
+ '- Anki scheduling and review history were not imported.'
356
+ ]
357
+ .filter(Boolean)
358
+ .join('\n');
359
+ const noteInput = {
360
+ id: input.noteId,
361
+ title: deckTitle(input.deck),
362
+ docType: 'revision',
363
+ summary: `Imported Anki deck with ${input.cards.length} cards.`,
364
+ learningGoal: 'Review imported Anki cards in Docs.',
365
+ aliases: [input.deck.name],
366
+ tags: uniqueList(['anki', deckTag, ...input.importTags]),
367
+ body,
368
+ cards: input.cards
369
+ };
370
+ return {
371
+ ...createDocsNote(noteInput, input.now),
372
+ sourcePath: `${input.docsRoot}/anki/${input.noteId}.doc.md`
373
+ };
374
+ }
375
+ async function readCollection(dbPath) {
376
+ const client = createClient({ url: `file:${dbPath}` });
377
+ try {
378
+ const col = await client.execute('select models, decks from col limit 1');
379
+ const colRow = col.rows[0];
380
+ if (!colRow)
381
+ throw new Error('Anki collection metadata is missing.');
382
+ const decks = parseDecks(rowString(colRow, 'decks'));
383
+ const models = parseModels(rowString(colRow, 'models'));
384
+ const notesResult = await client.execute('select id, guid, mid, tags, flds from notes');
385
+ const notes = new Map();
386
+ for (const row of notesResult.rows) {
387
+ const id = rowNumber(row, 'id');
388
+ const mid = rowString(row, 'mid');
389
+ const model = models.get(mid);
390
+ const fields = fieldMap(model, rowString(row, 'flds'));
391
+ notes.set(id, {
392
+ id,
393
+ guid: rowString(row, 'guid'),
394
+ mid,
395
+ tags: ankiTags(rowString(row, 'tags')),
396
+ fields,
397
+ fieldSummary: summarizeFields(fields),
398
+ model
399
+ });
400
+ }
401
+ const cardsResult = await client.execute('select id, nid, did, ord, queue from cards order by id');
402
+ const cards = cardsResult.rows.map((row) => ({
403
+ id: rowNumber(row, 'id'),
404
+ nid: rowNumber(row, 'nid'),
405
+ did: rowString(row, 'did'),
406
+ ord: rowNumber(row, 'ord'),
407
+ queue: rowNumber(row, 'queue')
408
+ }));
409
+ return { decks, models, notes, cards };
410
+ }
411
+ finally {
412
+ client.close();
413
+ }
414
+ }
415
+ async function collectionFromPackage(bytes, filename, warnings) {
416
+ if (filename?.toLowerCase().endsWith('.colpkg')) {
417
+ throw new Error('.colpkg and collection.anki21b packages are not supported.');
418
+ }
419
+ const entries = safeZipEntries(bytes);
420
+ const collection = collectionEntry(entries);
421
+ const media = prepareMedia(entries, warnings);
422
+ const tempRoot = await mkdtemp(resolve(tmpdir(), 'cvstudio-anki-'));
423
+ const dbPath = resolve(tempRoot, collection.name);
424
+ try {
425
+ await writeFile(dbPath, collection.bytes);
426
+ return { collection: await readCollection(dbPath), media };
427
+ }
428
+ finally {
429
+ await rm(tempRoot, { recursive: true, force: true });
430
+ }
431
+ }
432
+ function planImportedNotes(input) {
433
+ const byDeck = new Map();
434
+ for (const card of input.collection.cards) {
435
+ if (!input.collection.notes.has(card.nid))
436
+ continue;
437
+ byDeck.set(card.did, [...(byDeck.get(card.did) ?? []), card]);
438
+ }
439
+ const notes = [];
440
+ for (const [deckId, cards] of [...byDeck.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
441
+ const deck = input.collection.decks.get(deckId) ?? { id: deckId, name: `Anki Deck ${deckId}` };
442
+ if (deck.dyn)
443
+ continue;
444
+ const noteId = uniqueDocsId(input.existingIds, deck.name);
445
+ const deckTag = `anki/${slugifyDocsId(deck.name.replace(/::/g, '-'), 'deck')}`;
446
+ const mediaMap = mediaReferenceMap(input.media, noteId);
447
+ const existingCardIds = new Set();
448
+ const docsCards = cards.map((card) => docsCardFromAnki({
449
+ note: input.collection.notes.get(card.nid),
450
+ card,
451
+ deck,
452
+ deckTag,
453
+ importTags: input.importTags,
454
+ mediaMap,
455
+ existingCardIds
456
+ }));
457
+ notes.push(createImportedNote({
458
+ deck,
459
+ noteId,
460
+ cards: docsCards,
461
+ importTags: input.importTags,
462
+ modelNames: modelNames(cards, input.collection.notes),
463
+ mediaCount: input.media.length,
464
+ docsRoot: input.docsRoot,
465
+ now: input.now
466
+ }));
467
+ }
468
+ return notes;
469
+ }
470
+ function importedNoteSummary(note, mediaCount) {
471
+ return {
472
+ id: note.id,
473
+ title: note.title,
474
+ sourcePath: note.sourcePath,
475
+ cardCount: note.cards.length,
476
+ mediaCount
477
+ };
478
+ }
479
+ export async function importAnkiPackage(options) {
480
+ const warnings = [];
481
+ const docsRoot = (options.docsRoot ?? 'docs').replace(/^\/+|\/+$/g, '') || 'docs';
482
+ const importTags = uniqueList(options.tags ?? []);
483
+ const { collection, media } = await collectionFromPackage(options.bytes, options.filename, warnings);
484
+ const notes = planImportedNotes({
485
+ collection,
486
+ media,
487
+ existingIds: new Set(options.existingIds ?? []),
488
+ importTags,
489
+ docsRoot,
490
+ now: options.now
491
+ });
492
+ const summary = {
493
+ deckCount: notes.length,
494
+ cardCount: notes.reduce((count, note) => count + note.cards.length, 0),
495
+ mediaCount: media.length
496
+ };
497
+ if (summary.deckCount === 0)
498
+ warnings.push('No concrete Anki decks with cards were found.');
499
+ const importedNotes = notes.map((note) => importedNoteSummary(note, media.length));
500
+ if (options.dryRun || !options.writeNote)
501
+ return { summary, warnings, importedNotes };
502
+ for (const note of notes) {
503
+ await options.writeNote(note);
504
+ if (options.writeMedia) {
505
+ for (const file of media) {
506
+ await options.writeMedia(note, { filename: file.filename, bytes: file.bytes });
507
+ }
508
+ }
509
+ }
510
+ return {
511
+ summary,
512
+ warnings,
513
+ importedNotes,
514
+ workspace: buildDocsWorkspace(notes, options.now ?? new Date())
515
+ };
516
+ }
@@ -0,0 +1,9 @@
1
+ import type { SearchableChoiceOption } from '@lapismd/design-core/forms';
2
+ import { type DocsNoteListItem } from '../model/docs-note-list';
3
+ import type { DocsBookChapterNavigation, DocsBookNavigation, DocsNote, DocsWorkspace, DocsWorkspaceIndex } from '../model';
4
+ export type DocsBookWorkspaceView = {
5
+ chapterOptions: SearchableChoiceOption[];
6
+ bookNavigation: DocsBookNavigation | null;
7
+ chapterNavigation: DocsBookChapterNavigation | null;
8
+ };
9
+ export declare function docsBookWorkspaceView(workspace: DocsWorkspace | DocsWorkspaceIndex | null, notes: DocsNoteListItem[], activeNoteId: string | null, draftNote?: DocsNote | null): DocsBookWorkspaceView;
@@ -0,0 +1,71 @@
1
+ import { buildDocsBooks, resolveDocsBookNavigation } from '../model/book';
2
+ import { docsSummariesToStubNotes } from '../model/workspace';
3
+ import { isDocsNoteSummary } from '../model/docs-note-list';
4
+ function notesWithDraftOverlay(notes, draftNote) {
5
+ if (!draftNote)
6
+ return notes;
7
+ let replaced = false;
8
+ const next = notes.map((note) => {
9
+ if (note.id !== draftNote.id)
10
+ return note;
11
+ replaced = true;
12
+ return draftNote;
13
+ });
14
+ return replaced ? next : [...next, draftNote];
15
+ }
16
+ function notesForBookBuild(notes) {
17
+ if (!notes.length)
18
+ return [];
19
+ if (notes.every((note) => !isDocsNoteSummary(note))) {
20
+ return notes;
21
+ }
22
+ return docsSummariesToStubNotes(notes.map((note) => isDocsNoteSummary(note)
23
+ ? note
24
+ : {
25
+ id: note.id,
26
+ title: note.title,
27
+ docType: note.docType,
28
+ summary: note.summary,
29
+ aliases: note.aliases,
30
+ tags: note.tags,
31
+ sourcePath: note.sourcePath,
32
+ createdAt: note.createdAt,
33
+ updatedAt: note.updatedAt,
34
+ ...(note.completedAt ? { completedAt: note.completedAt } : {}),
35
+ cardCount: note.cards.length,
36
+ blockCount: note.blocks.length,
37
+ ...(note.book ? { book: note.book } : {}),
38
+ ...(note.bookId ? { bookId: note.bookId } : {})
39
+ }));
40
+ }
41
+ export function docsBookWorkspaceView(workspace, notes, activeNoteId, draftNote = null) {
42
+ const overlayNotes = notesWithDraftOverlay(notesForBookBuild(notes), draftNote);
43
+ // Only rebuild books from the draft when the active draft owns a TOC (index note).
44
+ // Rebuilding from summaries for every chapter open can drop siblings if the
45
+ // index summary/book payload is incomplete in the client workspace.
46
+ const draftOwnsBookToc = Boolean(draftNote?.book?.chapters?.length);
47
+ const books = draftOwnsBookToc && workspace
48
+ ? buildDocsBooks(overlayNotes)
49
+ : {
50
+ books: workspace?.books ?? [],
51
+ bookChaptersByNoteId: workspace?.bookChaptersByNoteId ?? {}
52
+ };
53
+ const chapterNavigation = activeNoteId
54
+ ? (books.bookChaptersByNoteId[activeNoteId] ?? null)
55
+ : null;
56
+ const indexBook = activeNoteId
57
+ ? (books.books.find((book) => book.indexNoteId === activeNoteId) ?? null)
58
+ : null;
59
+ const membershipBook = resolveDocsBookNavigation(books.books, chapterNavigation);
60
+ return {
61
+ chapterOptions: overlayNotes
62
+ .filter((note) => note.id !== activeNoteId)
63
+ .map((note) => ({
64
+ value: note.id,
65
+ label: note.title || note.id,
66
+ keywords: [note.id, note.sourcePath, ...note.aliases, ...note.tags]
67
+ })),
68
+ bookNavigation: indexBook ?? membershipBook,
69
+ chapterNavigation
70
+ };
71
+ }
@@ -0,0 +1 @@
1
+ export declare function docsCardAnswerMarkdown(answer: string): string;
@@ -0,0 +1,63 @@
1
+ const WRITEFILE_PATTERN = /^%%writefile\s+(.+?)\s*$/i;
2
+ const LANGUAGE_BY_EXTENSION = {
3
+ py: 'python',
4
+ js: 'javascript',
5
+ jsx: 'jsx',
6
+ ts: 'typescript',
7
+ tsx: 'tsx',
8
+ java: 'java',
9
+ go: 'go',
10
+ rs: 'rust',
11
+ rb: 'ruby',
12
+ c: 'c',
13
+ h: 'c',
14
+ cpp: 'cpp',
15
+ cc: 'cpp',
16
+ cxx: 'cpp',
17
+ cs: 'csharp',
18
+ kt: 'kotlin',
19
+ swift: 'swift',
20
+ sql: 'sql',
21
+ sh: 'bash',
22
+ bash: 'bash',
23
+ zsh: 'zsh',
24
+ ps1: 'powershell',
25
+ html: 'html',
26
+ css: 'css',
27
+ scss: 'scss',
28
+ json: 'json',
29
+ yml: 'yaml',
30
+ yaml: 'yaml',
31
+ md: 'markdown'
32
+ };
33
+ function fenceLanguageForFilename(filename) {
34
+ const extension = filename.split('.').pop()?.toLowerCase() ?? '';
35
+ return LANGUAGE_BY_EXTENSION[extension] ?? '';
36
+ }
37
+ function inlineCode(value) {
38
+ return value.replace(/`/g, '');
39
+ }
40
+ export function docsCardAnswerMarkdown(answer) {
41
+ const lines = answer.replace(/\r\n?/g, '\n').split('\n');
42
+ const output = [];
43
+ let writefileBlockOpen = false;
44
+ let changed = false;
45
+ for (const line of lines) {
46
+ const match = line.trim().match(WRITEFILE_PATTERN);
47
+ if (!match) {
48
+ output.push(line);
49
+ continue;
50
+ }
51
+ changed = true;
52
+ if (writefileBlockOpen) {
53
+ output.push('```', '');
54
+ }
55
+ const filename = match[1]?.trim() ?? 'output.txt';
56
+ const language = fenceLanguageForFilename(filename);
57
+ output.push(`\`${inlineCode(filename)}\``, '', `\`\`\`${language}`);
58
+ writefileBlockOpen = true;
59
+ }
60
+ if (writefileBlockOpen)
61
+ output.push('```');
62
+ return changed ? output.join('\n') : answer;
63
+ }
@@ -0,0 +1,10 @@
1
+ import type { DocsCard } from '../model';
2
+ export type DocsCardQuestionPart = {
3
+ kind: 'text';
4
+ text: string;
5
+ } | {
6
+ kind: 'cloze';
7
+ text: string;
8
+ hint: string | null;
9
+ };
10
+ export declare function docsCardQuestionParts(card: Pick<DocsCard, 'id' | 'prompt' | 'type'>): DocsCardQuestionPart[];
@@ -0,0 +1,18 @@
1
+ const CLOZE_PATTERN = /\{\{c\d+::([\s\S]*?)(?:::(.*?))?\}\}/g;
2
+ export function docsCardQuestionParts(card) {
3
+ const prompt = card.prompt || card.id;
4
+ if (card.type !== 'cloze')
5
+ return [{ kind: 'text', text: prompt }];
6
+ const parts = [];
7
+ let lastIndex = 0;
8
+ for (const match of prompt.matchAll(CLOZE_PATTERN)) {
9
+ const index = match.index ?? 0;
10
+ if (index > lastIndex)
11
+ parts.push({ kind: 'text', text: prompt.slice(lastIndex, index) });
12
+ parts.push({ kind: 'cloze', text: match[1] ?? '', hint: match[2]?.trim() || null });
13
+ lastIndex = index + match[0].length;
14
+ }
15
+ if (lastIndex < prompt.length)
16
+ parts.push({ kind: 'text', text: prompt.slice(lastIndex) });
17
+ return parts.length > 0 ? parts : [{ kind: 'text', text: prompt }];
18
+ }