@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.
- package/README.md +188 -2
- package/dist/docs/DocsBlockMarkdownEditor.svelte +37 -0
- package/dist/docs/DocsBlockMarkdownEditor.svelte.d.ts +13 -0
- package/dist/docs/DocsBlockRefPicker.svelte +75 -0
- package/dist/docs/DocsBlockRefPicker.svelte.d.ts +11 -0
- package/dist/docs/DocsBookEditor.svelte +141 -0
- package/dist/docs/DocsBookEditor.svelte.d.ts +10 -0
- package/dist/docs/DocsBookNavigation.svelte +838 -0
- package/dist/docs/DocsBookNavigation.svelte.d.ts +11 -0
- package/dist/docs/DocsBooksColumn.svelte +82 -0
- package/dist/docs/DocsBooksColumn.svelte.d.ts +10 -0
- package/dist/docs/DocsCardBackMatter.svelte +32 -0
- package/dist/docs/DocsCardBackMatter.svelte.d.ts +10 -0
- package/dist/docs/DocsCardBlockExtra.svelte +43 -0
- package/dist/docs/DocsCardBlockExtra.svelte.d.ts +9 -0
- package/dist/docs/DocsCardPreviewFlip.svelte +143 -0
- package/dist/docs/DocsCardPreviewFlip.svelte.d.ts +17 -0
- package/dist/docs/DocsCardPreviewStack.svelte +135 -0
- package/dist/docs/DocsCardPreviewStack.svelte.d.ts +13 -0
- package/dist/docs/DocsCardPrompt.svelte +33 -0
- package/dist/docs/DocsCardPrompt.svelte.d.ts +8 -0
- package/dist/docs/DocsCardReviewAnswer.svelte +45 -0
- package/dist/docs/DocsCardReviewAnswer.svelte.d.ts +8 -0
- package/dist/docs/DocsCardTagChips.svelte +19 -0
- package/dist/docs/DocsCardTagChips.svelte.d.ts +7 -0
- package/dist/docs/DocsCardsPanel.svelte +232 -0
- package/dist/docs/DocsCardsPanel.svelte.d.ts +17 -0
- package/dist/docs/DocsCardsReview.css +1050 -0
- package/dist/docs/DocsChapterFooter.svelte +248 -0
- package/dist/docs/DocsChapterFooter.svelte.d.ts +11 -0
- package/dist/docs/DocsChapterScrollRing.svelte +101 -0
- package/dist/docs/DocsChapterScrollRing.svelte.d.ts +6 -0
- package/dist/docs/DocsDisplayPanel.css +627 -0
- package/dist/docs/DocsDisplayPanel.svelte +78 -0
- package/dist/docs/DocsDisplayPanel.svelte.d.ts +16 -0
- package/dist/docs/DocsEditorPanel.css +325 -0
- package/dist/docs/DocsEditorPanel.svelte +570 -0
- package/dist/docs/DocsEditorPanel.svelte.d.ts +22 -0
- package/dist/docs/DocsFlipCard.svelte +68 -0
- package/dist/docs/DocsFlipCard.svelte.d.ts +8 -0
- package/dist/docs/DocsFolderDirectoryList.svelte +206 -0
- package/dist/docs/DocsFolderDirectoryList.svelte.d.ts +14 -0
- package/dist/docs/DocsFolderPanel.css +357 -0
- package/dist/docs/DocsFolderPanel.svelte +102 -0
- package/dist/docs/DocsFolderPanel.svelte.d.ts +17 -0
- package/dist/docs/DocsLinksPanel.css +61 -0
- package/dist/docs/DocsLinksPanel.svelte +55 -0
- package/dist/docs/DocsLinksPanel.svelte.d.ts +11 -0
- package/dist/docs/DocsMarkdownField.svelte +93 -0
- package/dist/docs/DocsMarkdownField.svelte.d.ts +11 -0
- package/dist/docs/DocsMarkdownPreview.svelte +278 -0
- package/dist/docs/DocsMarkdownPreview.svelte.d.ts +17 -0
- package/dist/docs/DocsReviewPanel.svelte +63 -0
- package/dist/docs/DocsReviewPanel.svelte.d.ts +11 -0
- package/dist/docs/DocsRichBlock.svelte +291 -0
- package/dist/docs/DocsRichBlock.svelte.d.ts +13 -0
- package/dist/docs/DocsRichDisplay.svelte +316 -0
- package/dist/docs/DocsRichDisplay.svelte.d.ts +14 -0
- package/dist/docs/DocsSlidesPreview.css +243 -0
- package/dist/docs/DocsSlidesPreview.svelte +206 -0
- package/dist/docs/DocsSlidesPreview.svelte.d.ts +10 -0
- package/dist/docs/DocsWorkspace.css +570 -0
- package/dist/docs/DocsWorkspace.svelte +831 -0
- package/dist/docs/DocsWorkspace.svelte.d.ts +48 -0
- package/dist/docs/DocsWorkspaceToolbar.svelte +211 -0
- package/dist/docs/DocsWorkspaceToolbar.svelte.d.ts +27 -0
- package/dist/docs/candidates/ChoiceMenu.svelte +114 -0
- package/dist/docs/candidates/ChoiceMenu.svelte.d.ts +10 -0
- package/dist/docs/candidates/FlipCardDeck.svelte +800 -0
- package/dist/docs/candidates/FlipCardDeck.svelte.d.ts +36 -0
- package/dist/docs/candidates/MarkdownFloatingToc.svelte +313 -0
- package/dist/docs/candidates/MarkdownFloatingToc.svelte.d.ts +14 -0
- package/dist/docs/candidates/SearchableChoicePicker.css +161 -0
- package/dist/docs/candidates/SearchableChoicePicker.svelte +208 -0
- package/dist/docs/candidates/SearchableChoicePicker.svelte.d.ts +19 -0
- package/dist/docs/candidates/StructuredTable.svelte +98 -0
- package/dist/docs/candidates/StructuredTable.svelte.d.ts +8 -0
- package/dist/docs/candidates/StructuredTableEditor.svelte +374 -0
- package/dist/docs/candidates/StructuredTableEditor.svelte.d.ts +12 -0
- package/dist/docs/candidates/TagEditor.svelte +31 -0
- package/dist/docs/candidates/TagEditor.svelte.d.ts +14 -0
- package/dist/docs/candidates/WorkspaceHistoryControls.svelte +112 -0
- package/dist/docs/candidates/WorkspaceHistoryControls.svelte.d.ts +8 -0
- package/dist/docs/candidates/markdown-floating-toc.css +151 -0
- package/dist/docs/candidates/markdown-floating-toc.d.ts +1 -0
- package/dist/docs/candidates/markdown-floating-toc.js +1 -0
- package/dist/docs/docs-anki-import.d.ts +34 -0
- package/dist/docs/docs-anki-import.js +516 -0
- package/dist/docs/docs-book-view-model.d.ts +9 -0
- package/dist/docs/docs-book-view-model.js +71 -0
- package/dist/docs/docs-card-answer-markdown.d.ts +1 -0
- package/dist/docs/docs-card-answer-markdown.js +63 -0
- package/dist/docs/docs-card-display.d.ts +10 -0
- package/dist/docs/docs-card-display.js +18 -0
- package/dist/docs/docs-display-focus.d.ts +7 -0
- package/dist/docs/docs-display-focus.js +42 -0
- package/dist/docs/docs-document-surface.d.ts +7 -0
- package/dist/docs/docs-document-surface.js +25 -0
- package/dist/docs/docs-folder-directory.d.ts +21 -0
- package/dist/docs/docs-folder-directory.js +56 -0
- package/dist/docs/docs-link-mode.d.ts +16 -0
- package/dist/docs/docs-link-mode.js +94 -0
- package/dist/docs/docs-workspace-helpers.d.ts +25 -0
- package/dist/docs/docs-workspace-helpers.js +195 -0
- package/dist/docs/docs.tokens.css +13 -0
- package/dist/docs/import-anki-package.d.ts +10 -0
- package/dist/docs/import-anki-package.js +30 -0
- package/dist/docs/lapis-markdown-extensions.d.ts +9 -0
- package/dist/docs/lapis-markdown-extensions.js +128 -0
- package/dist/docs/pin-reading-chrome.d.ts +4 -0
- package/dist/docs/pin-reading-chrome.js +32 -0
- package/dist/docs/sidebar/DocsFoldersSidebar.svelte +134 -0
- package/dist/docs/sidebar/DocsFoldersSidebar.svelte.d.ts +15 -0
- package/dist/docs/sidebar/DocsLinksSidebar.svelte +105 -0
- package/dist/docs/sidebar/DocsLinksSidebar.svelte.d.ts +11 -0
- package/dist/docs/sidebar/DocsSidebar.css +346 -0
- package/dist/docs/sidebar/DocsSidebar.svelte +430 -0
- package/dist/docs/sidebar/DocsSidebar.svelte.d.ts +18 -0
- package/dist/docs/sidebar/DocsSidebarHost.svelte +139 -0
- package/dist/docs/sidebar/DocsSidebarHost.svelte.d.ts +12 -0
- package/dist/docs/sidebar/DocsTagsSidebar.svelte +176 -0
- package/dist/docs/sidebar/DocsTagsSidebar.svelte.d.ts +12 -0
- package/dist/docs/sidebar/docs-book-note-tree.d.ts +23 -0
- package/dist/docs/sidebar/docs-book-note-tree.js +88 -0
- package/dist/docs/sidebar/docs-note-sort.d.ts +26 -0
- package/dist/docs/sidebar/docs-note-sort.js +54 -0
- package/dist/docs/sidebar/docs-sidebar-filters.d.ts +17 -0
- package/dist/docs/sidebar/docs-sidebar-filters.js +74 -0
- package/dist/docs/sidebar/docs-sidebar-ui-storage.d.ts +24 -0
- package/dist/docs/sidebar/docs-sidebar-ui-storage.js +87 -0
- package/dist/docs/sidebar/docs-source-folder-tree.d.ts +16 -0
- package/dist/docs/sidebar/docs-source-folder-tree.js +143 -0
- package/dist/docs/sidebar/docs-tag-tree.d.ts +17 -0
- package/dist/docs/sidebar/docs-tag-tree.js +84 -0
- package/dist/docs-cards-view.d.ts +18 -0
- package/dist/docs-cards-view.js +52 -0
- package/dist/docs-document-view.d.ts +24 -0
- package/dist/docs-document-view.js +127 -0
- package/dist/docs-document-workspace.svelte +215 -0
- package/dist/docs-document-workspace.svelte.d.ts +16 -0
- package/dist/docs-index.d.ts +5 -0
- package/dist/docs-index.js +49 -0
- package/dist/docs-path.d.ts +6 -0
- package/dist/docs-path.js +10 -0
- package/dist/docs-plugin.d.ts +21 -0
- package/dist/docs-plugin.js +140 -0
- package/dist/docs-repository.d.ts +15 -0
- package/dist/docs-repository.js +75 -0
- package/dist/docs-review-view.d.ts +18 -0
- package/dist/docs-review-view.js +52 -0
- package/dist/docs-settings-tab.d.ts +8 -0
- package/dist/docs-settings-tab.js +25 -0
- package/dist/docs-settings.d.ts +17 -0
- package/dist/docs-settings.js +38 -0
- package/dist/docs-view.d.ts +22 -0
- package/dist/docs-view.js +51 -0
- package/dist/docs-workspace.svelte +394 -0
- package/dist/docs-workspace.svelte.d.ts +15 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/model/book.d.ts +6 -0
- package/dist/model/book.js +101 -0
- package/dist/model/coding-runtime.d.ts +4 -0
- package/dist/model/coding-runtime.js +9 -0
- package/dist/model/docs-card-context.d.ts +32 -0
- package/dist/model/docs-card-context.js +89 -0
- package/dist/model/docs-card-preview-stack-motion.d.ts +51 -0
- package/dist/model/docs-card-preview-stack-motion.js +94 -0
- package/dist/model/docs-media-context.d.ts +9 -0
- package/dist/model/docs-media-context.js +18 -0
- package/dist/model/docs-note-list.d.ts +8 -0
- package/dist/model/docs-note-list.js +25 -0
- package/dist/model/docs-relative-media.d.ts +30 -0
- package/dist/model/docs-relative-media.js +116 -0
- package/dist/model/docs-review-constants.d.ts +4 -0
- package/dist/model/docs-review-constants.js +13 -0
- package/dist/model/fsrs.d.ts +14 -0
- package/dist/model/fsrs.js +153 -0
- package/dist/model/graph.d.ts +12 -0
- package/dist/model/graph.js +169 -0
- package/dist/model/index.d.ts +14 -0
- package/dist/model/index.js +14 -0
- package/dist/model/markdown-normalize.d.ts +11 -0
- package/dist/model/markdown-normalize.js +755 -0
- package/dist/model/markdown.d.ts +167 -0
- package/dist/model/markdown.js +365 -0
- package/dist/model/sample.d.ts +7 -0
- package/dist/model/sample.js +524 -0
- package/dist/model/types.d.ts +328 -0
- package/dist/model/types.js +1 -0
- package/dist/model/workspace.d.ts +6 -0
- package/dist/model/workspace.js +81 -0
- package/dist/storybook.css +52 -0
- package/dist/styles.css +1 -0
- package/manifest.json +11 -0
- 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 ? `` : '';
|
|
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
|
+
}
|