Skip to content

Commit 01236e8

Browse files
Custom card model
1 parent 99e43ac commit 01236e8

File tree

2 files changed

+279
-3
lines changed

2 files changed

+279
-3
lines changed

src/ankiConnectUtil.ts

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,23 @@ export enum DeckTypes {
88
}
99

1010
export const TARGET_DECK = "Obsidian 4"
11-
export const DEFAULT_DECK_TYPE = DeckTypes.BASIC
11+
export const ANKI_LINK_MODEL_NAME = "AnkiLink Basic";
12+
const ANKI_LINK_CARD_NAME = "Card 1";
13+
const ANKI_LINK_MODEL_FRONT_TEMPLATE = "<div class=\"anki-link\">{{Front}}</div>";
14+
const ANKI_LINK_MODEL_BACK_TEMPLATE = "<div class=\"anki-link\">{{FrontSide}}<hr id=\"answer\">{{Back}}</div>";
15+
export const DEFAULT_DECK_TYPE = ANKI_LINK_MODEL_NAME;
1216
export const ANKI_LINK_TAG = "ankiLink"
1317

1418
enum ConnAction {
1519
CREATE_DECK = "createDeck",
20+
CREATE_MODEL = "createModel",
1621
ADD_NOTE = "addNote",
1722
ADD_TAGS = "addTags",
1823
CHANGE_DECK = "changeDeck",
1924
DECK_NAMES = "deckNames",
25+
MODEL_NAMES = "modelNames",
26+
UPDATE_MODEL_TEMPLATES = "updateModelTemplates",
27+
UPDATE_MODEL_STYLING = "updateModelStyling",
2028
FIND_NOTES = "findNotes",
2129
DELETE_NOTES = "deleteNotes",
2230
NOTES_INFO = "notesInfo",
@@ -60,6 +68,56 @@ export interface CreateDeckResult extends ConnResult {
6068
result: number;
6169
}
6270

71+
export interface ModelNamesResult extends ConnResult {
72+
result: string[];
73+
}
74+
75+
interface CardTemplate {
76+
Name: string;
77+
Front: string;
78+
Back: string;
79+
}
80+
81+
export interface CreateModelRequest extends ConnRequest {
82+
params: {
83+
modelName: string;
84+
inOrderFields: string[];
85+
css: string;
86+
cardTemplates: CardTemplate[];
87+
isCloze: boolean;
88+
}
89+
}
90+
91+
export interface CreateModelResult extends ConnResult {
92+
result: null;
93+
}
94+
95+
export interface UpdateModelTemplatesRequest extends ConnRequest {
96+
params: {
97+
model: {
98+
name: string;
99+
templates: Record<string, { Front: string; Back: string }>;
100+
};
101+
}
102+
}
103+
104+
export interface UpdateModelTemplatesResult extends ConnResult {
105+
result: null;
106+
}
107+
108+
export interface UpdateModelStylingRequest extends ConnRequest {
109+
params: {
110+
model: {
111+
name: string;
112+
css: string;
113+
};
114+
}
115+
}
116+
117+
export interface UpdateModelStylingResult extends ConnResult {
118+
result: null;
119+
}
120+
63121
export interface AddNoteRequest extends ConnRequest {
64122
params: {
65123
note: Note
@@ -291,6 +349,72 @@ export async function sendCreateDeckRequest(deck: string): Promise<CreateDeckRes
291349
return res.json as CreateDeckResult
292350
}
293351

352+
export async function sendModelNamesRequest(): Promise<ModelNamesResult> {
353+
const res = await buildAndSend({
354+
action: ConnAction.MODEL_NAMES,
355+
version: ANKI_CONN_VERSION
356+
});
357+
return res.json as ModelNamesResult;
358+
}
359+
360+
export async function sendCreateModelRequest(modelName = ANKI_LINK_MODEL_NAME): Promise<CreateModelResult> {
361+
const req: CreateModelRequest = {
362+
action: ConnAction.CREATE_MODEL,
363+
version: ANKI_CONN_VERSION,
364+
params: {
365+
modelName,
366+
inOrderFields: ["Front", "Back"],
367+
css: ANKI_LINK_MODEL_CSS,
368+
cardTemplates: [
369+
{
370+
Name: ANKI_LINK_CARD_NAME,
371+
Front: ANKI_LINK_MODEL_FRONT_TEMPLATE,
372+
Back: ANKI_LINK_MODEL_BACK_TEMPLATE,
373+
},
374+
],
375+
isCloze: false,
376+
},
377+
};
378+
const res = await buildAndSend(req);
379+
return res.json as CreateModelResult;
380+
}
381+
382+
export async function sendUpdateModelTemplatesRequest(modelName = ANKI_LINK_MODEL_NAME): Promise<UpdateModelTemplatesResult> {
383+
const templates = {
384+
[ANKI_LINK_CARD_NAME]: {
385+
Front: ANKI_LINK_MODEL_FRONT_TEMPLATE,
386+
Back: ANKI_LINK_MODEL_BACK_TEMPLATE,
387+
},
388+
};
389+
const req: UpdateModelTemplatesRequest = {
390+
action: ConnAction.UPDATE_MODEL_TEMPLATES,
391+
version: ANKI_CONN_VERSION,
392+
params: {
393+
model: {
394+
name: modelName,
395+
templates,
396+
},
397+
},
398+
};
399+
const res = await buildAndSend(req);
400+
return res.json as UpdateModelTemplatesResult;
401+
}
402+
403+
export async function sendUpdateModelStylingRequest(modelName = ANKI_LINK_MODEL_NAME): Promise<UpdateModelStylingResult> {
404+
const req: UpdateModelStylingRequest = {
405+
action: ConnAction.UPDATE_MODEL_STYLING,
406+
version: ANKI_CONN_VERSION,
407+
params: {
408+
model: {
409+
name: modelName,
410+
css: ANKI_LINK_MODEL_CSS,
411+
},
412+
},
413+
};
414+
const res = await buildAndSend(req);
415+
return res.json as UpdateModelStylingResult;
416+
}
417+
294418
export async function sendAddNoteRequest(note: Note): Promise<AddNoteResult> {
295419
const req: AddNoteRequest = {
296420
action: ConnAction.ADD_NOTE,
@@ -342,5 +466,35 @@ function build(action: ConnRequest): RequestUrlParam {
342466
}
343467

344468
function escapeQueryValue(value: string): string {
345-
return value.split("\"").join("\\\"");
469+
return value.split("\"").join(String.raw`\"`);
470+
}
471+
472+
const ANKI_LINK_MODEL_CSS = `
473+
.anki-link {
474+
max-width: min(72ch, 100%);
475+
margin: 0 auto;
476+
text-align: left;
477+
}
478+
479+
.anki-link pre {
480+
background: #1e1e1e;
481+
color: #d4d4d4;
482+
padding: 0.75em 1em;
483+
border-radius: 8px;
484+
overflow-x: auto;
485+
white-space: pre;
486+
line-height: 1.4;
487+
}
488+
489+
.anki-link code {
490+
font-family: "JetBrains Mono", "Fira Code", "Menlo", monospace;
491+
font-size: 0.9em;
492+
}
493+
494+
.anki-link :not(pre) > code {
495+
background: #f3f3f3;
496+
color: #222;
497+
padding: 0.1em 0.3em;
498+
border-radius: 4px;
346499
}
500+
`.trim();

src/syncUtil.ts

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { App, TFile } from "obsidian";
22
import {
3+
ANKI_LINK_MODEL_NAME,
34
ANKI_LINK_TAG,
45
Note,
56
addTagToNotes,
@@ -12,7 +13,11 @@ import {
1213
noteIsInDeck,
1314
sendAddNoteRequest,
1415
sendCreateDeckRequest,
16+
sendCreateModelRequest,
1517
sendDeckNamesRequest,
18+
sendModelNamesRequest,
19+
sendUpdateModelStylingRequest,
20+
sendUpdateModelTemplatesRequest,
1621
updateNoteById,
1722
} from "./ankiConnectUtil";
1823
import { FC_PREAMBLE_P } from "./regexUtil";
@@ -55,6 +60,7 @@ export async function syncVaultNotes(app: App): Promise<SyncSummary> {
5560
}
5661

5762
await ensureDecksExist(decksInUse);
63+
await ensureModelIsConfigured();
5864
const taggedNoteIdsAtStart = new Set(await findNoteIdsByTag(ANKI_LINK_TAG));
5965

6066
let totalAdded = 0;
@@ -153,6 +159,19 @@ async function ensureDecksExist(deckNames: Set<string>) {
153159
}
154160
}
155161

162+
async function ensureModelIsConfigured() {
163+
const modelNamesRes = await sendModelNamesRequest();
164+
if (modelNamesRes.error) throw new Error(`AnkiConnect: ${modelNamesRes.error}`);
165+
if (!modelNamesRes.result.includes(ANKI_LINK_MODEL_NAME)) {
166+
const createModelRes = await sendCreateModelRequest(ANKI_LINK_MODEL_NAME);
167+
if (createModelRes.error) throw new Error(`AnkiConnect: ${createModelRes.error}`);
168+
}
169+
const updateTemplatesRes = await sendUpdateModelTemplatesRequest(ANKI_LINK_MODEL_NAME);
170+
if (updateTemplatesRes.error) throw new Error(`AnkiConnect: ${updateTemplatesRes.error}`);
171+
const updateStylingRes = await sendUpdateModelStylingRequest(ANKI_LINK_MODEL_NAME);
172+
if (updateStylingRes.error) throw new Error(`AnkiConnect: ${updateStylingRes.error}`);
173+
}
174+
156175
function parseDocument(lines: string[], deckName: string): ParsedNoteData[] {
157176
const output = new Array<ParsedNoteData>();
158177
let i = 0;
@@ -164,14 +183,117 @@ function parseDocument(lines: string[], deckName: string): ParsedNoteData[] {
164183
}
165184

166185
const bodyLines = parseBody(lines.slice(i + 1));
167-
const body = bodyLines.join("<br>");
186+
const body = formatBodyForAnki(bodyLines);
168187
const note = buildNote(title, body, deckName);
169188
output.push({ id: id ? Number(id) : undefined, index: i, note });
170189
i += bodyLines.length + 1;
171190
}
172191
return output;
173192
}
174193

194+
type BodyToken =
195+
| { type: "text"; raw: string }
196+
| { type: "fence"; raw: string; marker: "```" | "~~~"; info: string };
197+
198+
type BodySegment =
199+
| { type: "text"; lines: string[] }
200+
| { type: "code"; language: string; code: string };
201+
202+
function formatBodyForAnki(lines: string[]): string {
203+
const tokens = lexBody(lines);
204+
const segments = parseBodyTokens(tokens);
205+
return renderBodySegments(segments);
206+
}
207+
208+
function lexBody(lines: string[]): BodyToken[] {
209+
return lines.map((line) => lexLine(line));
210+
}
211+
212+
function lexLine(line: string): BodyToken {
213+
const trimmed = line.trim();
214+
if (trimmed.startsWith("```")) {
215+
return { type: "fence", raw: line, marker: "```", info: trimmed.slice(3).trim() };
216+
}
217+
if (trimmed.startsWith("~~~")) {
218+
return { type: "fence", raw: line, marker: "~~~", info: trimmed.slice(3).trim() };
219+
}
220+
return { type: "text", raw: line };
221+
}
222+
223+
function parseBodyTokens(tokens: BodyToken[]): BodySegment[] {
224+
const segments: BodySegment[] = [];
225+
const textBuffer: string[] = [];
226+
227+
const flushText = () => {
228+
if (textBuffer.length === 0) return;
229+
segments.push({ type: "text", lines: [...textBuffer] });
230+
textBuffer.length = 0;
231+
};
232+
233+
let i = 0;
234+
while (i < tokens.length) {
235+
const token = tokens[i]!;
236+
if (token.type !== "fence") {
237+
textBuffer.push(token.raw);
238+
i++;
239+
continue;
240+
}
241+
242+
const closingFenceIdx = findClosingFenceToken(tokens, i + 1, token.marker);
243+
if (closingFenceIdx === -1) {
244+
// Keep unmatched fences as regular text to avoid dropping content.
245+
textBuffer.push(token.raw);
246+
i++;
247+
continue;
248+
}
249+
250+
flushText();
251+
const code = tokens.slice(i + 1, closingFenceIdx).map((currentToken) => currentToken.raw).join("\n");
252+
segments.push({ type: "code", language: token.info, code });
253+
i = closingFenceIdx + 1;
254+
}
255+
256+
flushText();
257+
return segments;
258+
}
259+
260+
function findClosingFenceToken(tokens: BodyToken[], startIdx: number, marker: "```" | "~~~"): number {
261+
for (let i = startIdx; i < tokens.length; i++) {
262+
const token = tokens[i]!;
263+
if (token.type === "fence" && token.marker === marker && token.info.length === 0) {
264+
return i;
265+
}
266+
}
267+
return -1;
268+
}
269+
270+
function renderBodySegments(segments: BodySegment[]): string {
271+
return segments.map((segment) => renderSegment(segment)).join("<br>");
272+
}
273+
274+
function renderSegment(segment: BodySegment): string {
275+
if (segment.type === "text") {
276+
return segment.lines.join("<br>");
277+
}
278+
const languageClass = segment.language.length > 0 ? ` class="language-${escapeHtmlAttribute(segment.language)}"` : "";
279+
return `<pre><code${languageClass}>${escapeHtml(segment.code)}</code></pre>`;
280+
}
281+
282+
function escapeHtml(value: string): string {
283+
return value
284+
.split("&").join("&amp;")
285+
.split("<").join("&lt;")
286+
.split(">").join("&gt;");
287+
}
288+
289+
function escapeHtmlAttribute(value: string): string {
290+
return value
291+
.split("&").join("&amp;")
292+
.split("\"").join("&quot;")
293+
.split("<").join("&lt;")
294+
.split(">").join("&gt;");
295+
}
296+
175297
function parseBody(lines: string[]) {
176298
const bodyLines: string[] = [];
177299
for (const line of lines) {

0 commit comments

Comments
 (0)