mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
Fix Obsidian review issues
This commit is contained in:
parent
b4e6e53c61
commit
6fb8583feb
28 changed files with 3939 additions and 623 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
|
|
@ -8,6 +9,7 @@ export default tseslint.config(
|
|||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...obsidianmd.configs.recommendedWithLocalesEn,
|
||||
{
|
||||
files: ["**/*.ts"],
|
||||
languageOptions: {
|
||||
|
|
@ -24,7 +26,9 @@ export default tseslint.config(
|
|||
"error",
|
||||
{ "prefer": "type-imports" }
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "error"
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@typescript-eslint/require-await": "error",
|
||||
"require-await": "off"
|
||||
},
|
||||
},
|
||||
);
|
||||
);
|
||||
|
|
|
|||
52
main.js
52
main.js
File diff suppressed because one or more lines are too long
2970
package-lock.json
generated
2970
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -29,6 +29,7 @@
|
|||
"builtin-modules": "^4.0.0",
|
||||
"esbuild": "^0.25.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-obsidianmd": "^0.2.8",
|
||||
"globals": "^16.4.0",
|
||||
"obsidian": "1.12.3",
|
||||
"typescript": "^5.8.3",
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import * as obsidian from "obsidian";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PluginUserError, renderPluginFileFailure, renderPluginFileFailuresInline, renderUnknownUserFacingError, renderUserFacingMessage, renderUserMessage } from "./PluginUserError";
|
||||
|
||||
function setNavigatorLanguage(language: string): void {
|
||||
vi.stubGlobal("navigator", { language });
|
||||
}
|
||||
|
||||
describe("PluginUserError", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders plugin-owned errors in English", () => {
|
||||
vi.spyOn(obsidian, "getLanguage").mockReturnValue("en");
|
||||
setNavigatorLanguage("en");
|
||||
|
||||
const error = new PluginUserError("errors.currentFileOutOfScope", { filePath: "notes/example.md" });
|
||||
|
||||
|
|
@ -17,7 +21,7 @@ describe("PluginUserError", () => {
|
|||
});
|
||||
|
||||
it("renders plugin-owned errors in Simplified Chinese", () => {
|
||||
vi.spyOn(obsidian, "getLanguage").mockReturnValue("zh");
|
||||
setNavigatorLanguage("zh");
|
||||
|
||||
const error = new PluginUserError("errors.currentFileOutOfScope", { filePath: "notes/example.md" });
|
||||
|
||||
|
|
@ -25,19 +29,19 @@ describe("PluginUserError", () => {
|
|||
});
|
||||
|
||||
it("renders scope-not-configured errors in English and Chinese", () => {
|
||||
vi.spyOn(obsidian, "getLanguage").mockReturnValue("en");
|
||||
setNavigatorLanguage("en");
|
||||
expect(renderUserMessage(new PluginUserError("errors.runScopeNotConfigured"))).toBe(
|
||||
"Run scope is not configured. In include mode, select at least one folder before syncing.",
|
||||
);
|
||||
|
||||
vi.spyOn(obsidian, "getLanguage").mockReturnValue("zh");
|
||||
setNavigatorLanguage("zh");
|
||||
expect(renderUserMessage(new PluginUserError("errors.runScopeNotConfigured"))).toBe(
|
||||
"运行范围尚未配置。当前是 include 模式,请至少选择一个文件夹后再同步。",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders write-back failure summaries with localized detail lines", () => {
|
||||
vi.spyOn(obsidian, "getLanguage").mockReturnValue("zh");
|
||||
setNavigatorLanguage("zh");
|
||||
|
||||
const error = new PluginUserError(
|
||||
"errors.writeBack.summary",
|
||||
|
|
@ -58,7 +62,7 @@ describe("PluginUserError", () => {
|
|||
});
|
||||
|
||||
it("renders raw and keyed user-facing messages plus inline failure lists", () => {
|
||||
vi.spyOn(obsidian, "getLanguage").mockReturnValue("en");
|
||||
setNavigatorLanguage("en");
|
||||
|
||||
expect(renderUserFacingMessage({ key: "notice.failedSavePluginSettings" })).toBe("Failed to save plugin settings.");
|
||||
expect(renderUserFacingMessage({ rawMessage: "raw failure" })).toBe("raw failure");
|
||||
|
|
@ -70,13 +74,13 @@ describe("PluginUserError", () => {
|
|||
});
|
||||
|
||||
it("falls back to raw error messages for unknown errors", () => {
|
||||
vi.spyOn(obsidian, "getLanguage").mockReturnValue("zh");
|
||||
setNavigatorLanguage("zh");
|
||||
|
||||
expect(renderUnknownUserFacingError(new Error("socket closed"), "notice.vaultSyncFailed")).toBe("socket closed");
|
||||
});
|
||||
|
||||
it("falls back to a translated default when the value is not an Error", () => {
|
||||
vi.spyOn(obsidian, "getLanguage").mockReturnValue("en");
|
||||
setNavigatorLanguage("en");
|
||||
|
||||
expect(renderUnknownUserFacingError(null, "notice.vaultSyncFailed")).toBe("Vault sync failed.");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { AnkiBatchExecutor } from "./AnkiBatchExecutor";
|
|||
class CountingAnkiGateway extends FakeManualSyncAnkiGateway {
|
||||
public getModelDetailsCalls: string[] = [];
|
||||
|
||||
override async getModelDetails(modelName: string): Promise<NoteModelDetails> {
|
||||
override getModelDetails(modelName: string): Promise<NoteModelDetails> {
|
||||
this.getModelDetailsCalls.push(modelName);
|
||||
return super.getModelDetails(modelName);
|
||||
}
|
||||
|
|
@ -50,7 +50,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
const result = await executor.execute(
|
||||
plan,
|
||||
renderedCards,
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
createModule3Settings().noteFieldMappings,
|
||||
);
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
warnings: [],
|
||||
},
|
||||
renderedCards,
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
createModule3Settings().noteFieldMappings,
|
||||
);
|
||||
|
||||
|
|
@ -120,7 +120,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
warnings: [],
|
||||
},
|
||||
new Map([[createCard.card.syncKey, createRenderedSyncCard(createCard)]]),
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
createModule3Settings().noteFieldMappings,
|
||||
);
|
||||
|
||||
|
|
@ -153,7 +153,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
warnings: [],
|
||||
},
|
||||
new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]),
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
createModule3Settings().noteFieldMappings,
|
||||
);
|
||||
|
||||
|
|
@ -192,7 +192,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
warnings: [],
|
||||
},
|
||||
new Map([[rebuildCard.card.syncKey, createRenderedSyncCard(rebuildCard)]]),
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
createModule3Settings().noteFieldMappings,
|
||||
);
|
||||
|
||||
|
|
@ -228,7 +228,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
warnings: [],
|
||||
},
|
||||
new Map([[rebuildCard.card.syncKey, createRenderedSyncCard(rebuildCard)]]),
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
createModule3Settings().noteFieldMappings,
|
||||
);
|
||||
|
||||
|
|
@ -270,7 +270,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
warnings: [],
|
||||
},
|
||||
new Map([[rebuildCard.card.syncKey, createRenderedSyncCard(rebuildCard)]]),
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
createModule3Settings().noteFieldMappings,
|
||||
)).rejects.toThrow("delete old failed");
|
||||
|
||||
|
|
@ -304,7 +304,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
warnings: [],
|
||||
},
|
||||
new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]),
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
createModule3Settings().noteFieldMappings,
|
||||
);
|
||||
|
||||
|
|
@ -336,7 +336,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
warnings: [],
|
||||
},
|
||||
new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]),
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
{
|
||||
...createModule3Settings().noteFieldMappings,
|
||||
"basic:New Basic": {
|
||||
|
|
@ -390,7 +390,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
warnings: [],
|
||||
},
|
||||
new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]),
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
{
|
||||
...createModule3Settings().noteFieldMappings,
|
||||
"cloze:New Cloze": {
|
||||
|
|
@ -439,7 +439,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
warnings: [],
|
||||
},
|
||||
new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]),
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
createModule3Settings().noteFieldMappings,
|
||||
);
|
||||
|
||||
|
|
@ -473,7 +473,7 @@ describe("AnkiBatchExecutor", () => {
|
|||
warnings: [],
|
||||
},
|
||||
new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]),
|
||||
async (plannedCard) => createRenderedSyncCard(plannedCard),
|
||||
renderPlannedCard,
|
||||
{
|
||||
...createModule3Settings().noteFieldMappings,
|
||||
"basic:New Basic": {
|
||||
|
|
@ -525,6 +525,10 @@ function createRenderedSyncCard(plannedCard: PlannedCard): RenderedSyncCard {
|
|||
};
|
||||
}
|
||||
|
||||
function renderPlannedCard(plannedCard: PlannedCard): Promise<RenderedSyncCard> {
|
||||
return Promise.resolve(createRenderedSyncCard(plannedCard));
|
||||
}
|
||||
|
||||
function createIndexedCard(syncKey: string, noteId?: number, cardType: IndexedCard["cardType"] = "basic"): IndexedCard {
|
||||
return {
|
||||
noteId,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export class BatchScheduler {
|
|||
}
|
||||
|
||||
const batches = chunk(items, batchSize);
|
||||
const results: TResult[][] = new Array(batches.length);
|
||||
const results: TResult[][] = [];
|
||||
let nextIndex = 0;
|
||||
|
||||
await Promise.all(
|
||||
|
|
|
|||
|
|
@ -69,17 +69,28 @@ describe("ManualSyncService", () => {
|
|||
|
||||
expect(result.created).toBe(1);
|
||||
expect(result.rewrittenMarkers).toBe(1);
|
||||
expect(ankiGateway.addedNotes[0]?.modelName).toBe(QA_GROUP_USER_NOTE_TYPE);
|
||||
expect(ankiGateway.addedNotes[0]?.fields).toMatchObject({
|
||||
题目: "Concepts",
|
||||
问题01: "Alpha",
|
||||
答案01: expect.stringContaining("First answer"),
|
||||
问题02: "Beta",
|
||||
答案02: expect.stringContaining("Second answer"),
|
||||
});
|
||||
const addedNote = ankiGateway.addedNotes[0];
|
||||
expect(addedNote?.modelName).toBe(QA_GROUP_USER_NOTE_TYPE);
|
||||
const addedNoteFields = addedNote?.fields;
|
||||
expect(addedNoteFields).toBeDefined();
|
||||
if (!addedNoteFields) {
|
||||
throw new Error("Expected the created note fields to be recorded.");
|
||||
}
|
||||
|
||||
expect(addedNoteFields["题目"]).toBe("Concepts");
|
||||
expect(addedNoteFields["问题01"]).toBe("Alpha");
|
||||
expect(addedNoteFields["答案01"]).toContain("First answer");
|
||||
expect(addedNoteFields["问题02"]).toBe("Beta");
|
||||
expect(addedNoteFields["答案02"]).toContain("Second answer");
|
||||
expect(vaultGateway.getFileContent("notes/example.md")).toMatch(/<!--GI:n=9001;i=[^;]+;f=3-->/);
|
||||
expect(Object.values(stateRepository.savedState?.groupBlocks ?? {})).toHaveLength(1);
|
||||
expect(stateRepository.savedState?.files["notes/example.md"]?.groupIds).toHaveLength(1);
|
||||
const savedState = stateRepository.savedState;
|
||||
expect(savedState).not.toBeNull();
|
||||
if (!savedState) {
|
||||
throw new Error("Expected plugin state to be saved.");
|
||||
}
|
||||
|
||||
expect(Object.keys(savedState.groupBlocks ?? {})).toHaveLength(1);
|
||||
expect(savedState.files["notes/example.md"]?.groupIds).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("restores the original marker without creating a new Anki note when marker was deleted but content is unchanged", async () => {
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ export class ManualSyncService {
|
|||
const executionResult = await this.ankiBatchExecutor.execute(
|
||||
plan,
|
||||
renderedCards,
|
||||
async (plannedCard) => this.renderer.render(plannedCard, renderContext),
|
||||
(plannedCard) => Promise.resolve(this.renderer.render(plannedCard, renderContext)),
|
||||
settings.noteFieldMappings,
|
||||
);
|
||||
const qaGroupExecution = await this.qaGroupSyncService.sync(indexResult.groupBlocks, state, settings);
|
||||
|
|
|
|||
|
|
@ -538,19 +538,20 @@ describe("QaGroupSyncService", () => {
|
|||
expect(result.resolvedNoteIds.get("notes/example.md\u0000group\u00001\u0000hash-1")).toBe(42);
|
||||
expect(ankiGateway.addedNotes).toEqual([]);
|
||||
expect(ankiGateway.updatedNotes).toEqual([]);
|
||||
expect(ankiGateway.updatedNoteModels).toEqual([
|
||||
{
|
||||
noteId: 42,
|
||||
modelName: QA_GROUP_USER_NOTE_TYPE,
|
||||
fields: expect.objectContaining({
|
||||
题目: "Concepts",
|
||||
问题01: "Alpha",
|
||||
答案01: expect.stringContaining("First answer"),
|
||||
问题02: "Beta",
|
||||
答案02: expect.stringContaining("Second answer"),
|
||||
}),
|
||||
},
|
||||
]);
|
||||
expect(ankiGateway.updatedNoteModels).toHaveLength(1);
|
||||
const updatedQaGroupNoteModel = ankiGateway.updatedNoteModels[0];
|
||||
expect(updatedQaGroupNoteModel).toBeDefined();
|
||||
if (!updatedQaGroupNoteModel) {
|
||||
throw new Error("Expected a QA Group note model update.");
|
||||
}
|
||||
|
||||
expect(updatedQaGroupNoteModel.noteId).toBe(42);
|
||||
expect(updatedQaGroupNoteModel.modelName).toBe(QA_GROUP_USER_NOTE_TYPE);
|
||||
expect(updatedQaGroupNoteModel.fields["题目"]).toBe("Concepts");
|
||||
expect(updatedQaGroupNoteModel.fields["问题01"]).toBe("Alpha");
|
||||
expect(updatedQaGroupNoteModel.fields["答案01"]).toContain("First answer");
|
||||
expect(updatedQaGroupNoteModel.fields["问题02"]).toBe("Beta");
|
||||
expect(updatedQaGroupNoteModel.fields["答案02"]).toContain("Second answer");
|
||||
expect(ankiGateway.syncedNoteTags).toEqual([
|
||||
{
|
||||
noteId: 42,
|
||||
|
|
@ -642,16 +643,20 @@ describe("QaGroupSyncService", () => {
|
|||
}));
|
||||
|
||||
expect(result.migratedNoteTypes).toBe(1);
|
||||
expect(ankiGateway.updatedNoteModels[0]?.modelName).toBe("问答题(6组)");
|
||||
expect(ankiGateway.updatedNoteModels[0]?.fields).toMatchObject({
|
||||
题目: "Concepts",
|
||||
问题01: "A",
|
||||
答案01: expect.stringContaining("1"),
|
||||
问题05: "E",
|
||||
答案05: expect.stringContaining("5"),
|
||||
问题06: "",
|
||||
答案06: "",
|
||||
});
|
||||
const updatedNoteModel = ankiGateway.updatedNoteModels[0];
|
||||
expect(updatedNoteModel).toBeDefined();
|
||||
if (!updatedNoteModel) {
|
||||
throw new Error("Expected a migrated QA Group note model update.");
|
||||
}
|
||||
|
||||
expect(updatedNoteModel.modelName).toBe("问答题(6组)");
|
||||
expect(updatedNoteModel.fields["题目"]).toBe("Concepts");
|
||||
expect(updatedNoteModel.fields["问题01"]).toBe("A");
|
||||
expect(updatedNoteModel.fields["答案01"]).toContain("1");
|
||||
expect(updatedNoteModel.fields["问题05"]).toBe("E");
|
||||
expect(updatedNoteModel.fields["答案05"]).toContain("5");
|
||||
expect(updatedNoteModel.fields["问题06"]).toBe("");
|
||||
expect(updatedNoteModel.fields["答案06"]).toBe("");
|
||||
});
|
||||
|
||||
it("surfaces a user-facing error when QA Group note type migration fails", async () => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,26 @@ vi.mock("obsidian", () => ({
|
|||
|
||||
import { AnkiConnectGateway } from "./AnkiConnectGateway";
|
||||
|
||||
function getRequestBody(callIndex: number): unknown {
|
||||
const maybeCall = requestUrlMock.mock.calls[callIndex] as unknown;
|
||||
if (!Array.isArray(maybeCall)) {
|
||||
throw new Error(`Missing request call for index ${callIndex}.`);
|
||||
}
|
||||
|
||||
const [request] = maybeCall as unknown[];
|
||||
if (!request || typeof request !== "object" || !("body" in request)) {
|
||||
throw new Error(`Missing request body for call ${callIndex}.`);
|
||||
}
|
||||
|
||||
const requestRecord = request as Record<string, unknown>;
|
||||
const body = requestRecord["body"];
|
||||
if (typeof body !== "string") {
|
||||
throw new Error(`Request body for call ${callIndex} is not a string.`);
|
||||
}
|
||||
|
||||
return JSON.parse(body) as unknown;
|
||||
}
|
||||
|
||||
describe("AnkiConnectGateway", () => {
|
||||
beforeEach(() => {
|
||||
requestUrlMock.mockReset();
|
||||
|
|
@ -27,7 +47,7 @@ describe("AnkiConnectGateway", () => {
|
|||
const models = await gateway.listNoteModels();
|
||||
|
||||
expect(models).toEqual(["Basic", "Cloze", "Custom Basic"]);
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
expect(getRequestBody(0)).toEqual({
|
||||
action: "modelNames",
|
||||
version: 6,
|
||||
params: {},
|
||||
|
|
@ -59,7 +79,7 @@ describe("AnkiConnectGateway", () => {
|
|||
Basic: ["Front", "Back"],
|
||||
Cloze: ["Text", "Extra"],
|
||||
});
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
expect(getRequestBody(0)).toEqual({
|
||||
action: "multi",
|
||||
version: 6,
|
||||
params: {
|
||||
|
|
@ -118,7 +138,7 @@ describe("AnkiConnectGateway", () => {
|
|||
const deckNames = await gateway.listDeckNames();
|
||||
|
||||
expect(deckNames).toEqual(["Default", "Scoped::Deck", "Scoped::Deck::Leaf"]);
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
expect(getRequestBody(0)).toEqual({
|
||||
action: "deckNamesAndIds",
|
||||
version: 6,
|
||||
params: {},
|
||||
|
|
@ -170,14 +190,14 @@ describe("AnkiConnectGateway", () => {
|
|||
{ noteId: 100, modelName: "Basic", cardIds: [1], deckNames: ["Deck::One"], tags: ["tag-a"] },
|
||||
{ noteId: 102, modelName: "Cloze", cardIds: [2], deckNames: ["Deck::Two"], tags: ["tag-b", "tag-c"] },
|
||||
]);
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
expect(getRequestBody(0)).toEqual({
|
||||
action: "notesInfo",
|
||||
version: 6,
|
||||
params: {
|
||||
notes: [100, 101, 102],
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[1][0].body)).toEqual({
|
||||
expect(getRequestBody(1)).toEqual({
|
||||
action: "cardsInfo",
|
||||
version: 6,
|
||||
params: {
|
||||
|
|
@ -205,7 +225,7 @@ describe("AnkiConnectGateway", () => {
|
|||
},
|
||||
});
|
||||
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
expect(getRequestBody(0)).toEqual({
|
||||
action: "updateNoteModel",
|
||||
version: 6,
|
||||
params: {
|
||||
|
|
@ -263,7 +283,7 @@ describe("AnkiConnectGateway", () => {
|
|||
},
|
||||
]);
|
||||
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
expect(getRequestBody(0)).toEqual({
|
||||
action: "multi",
|
||||
version: 6,
|
||||
params: {
|
||||
|
|
@ -330,12 +350,12 @@ describe("AnkiConnectGateway", () => {
|
|||
{ deckName: "Empty", noteCount: 0 },
|
||||
{ deckName: "Busy", noteCount: 3 },
|
||||
]);
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
expect(getRequestBody(0)).toEqual({
|
||||
action: "deckNamesAndIds",
|
||||
version: 6,
|
||||
params: {},
|
||||
});
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[1][0].body)).toEqual({
|
||||
expect(getRequestBody(1)).toEqual({
|
||||
action: "getDeckStats",
|
||||
version: 6,
|
||||
params: {
|
||||
|
|
@ -572,7 +592,7 @@ describe("AnkiConnectGateway", () => {
|
|||
]);
|
||||
|
||||
expect(noteIds).toEqual([9001, 9002]);
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
expect(getRequestBody(0)).toEqual({
|
||||
action: "multi",
|
||||
version: 6,
|
||||
params: {
|
||||
|
|
@ -642,7 +662,7 @@ describe("AnkiConnectGateway", () => {
|
|||
},
|
||||
]);
|
||||
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
expect(getRequestBody(0)).toEqual({
|
||||
action: "multi",
|
||||
version: 6,
|
||||
params: {
|
||||
|
|
@ -659,7 +679,7 @@ describe("AnkiConnectGateway", () => {
|
|||
],
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[1][0].body)).toEqual({
|
||||
expect(getRequestBody(1)).toEqual({
|
||||
action: "multi",
|
||||
version: 6,
|
||||
params: {
|
||||
|
|
@ -674,7 +694,7 @@ describe("AnkiConnectGateway", () => {
|
|||
],
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[2][0].body)).toEqual({
|
||||
expect(getRequestBody(2)).toEqual({
|
||||
action: "multi",
|
||||
version: 6,
|
||||
params: {
|
||||
|
|
@ -707,7 +727,7 @@ describe("AnkiConnectGateway", () => {
|
|||
});
|
||||
|
||||
expect(requestUrlMock).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
expect(getRequestBody(0)).toEqual({
|
||||
action: "updateNoteFields",
|
||||
version: 6,
|
||||
params: {
|
||||
|
|
@ -738,14 +758,14 @@ describe("AnkiConnectGateway", () => {
|
|||
await gateway.deleteNotes([1, 2]);
|
||||
await gateway.deleteDecks(["Empty", "Empty", "Scoped::Deck"]);
|
||||
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
expect(getRequestBody(0)).toEqual({
|
||||
action: "deleteNotes",
|
||||
version: 6,
|
||||
params: {
|
||||
notes: [1, 2],
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[1][0].body)).toEqual({
|
||||
expect(getRequestBody(1)).toEqual({
|
||||
action: "deleteDecks",
|
||||
version: 6,
|
||||
params: {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ export class ObsidianPluginDataStore<TData extends object> implements PluginData
|
|||
constructor(private readonly plugin: Plugin) {}
|
||||
|
||||
async load(): Promise<TData | null> {
|
||||
const data = await this.plugin.loadData();
|
||||
return (data as TData | null) ?? null;
|
||||
const data: unknown = await this.plugin.loadData();
|
||||
return data !== null && typeof data === "object" ? data as TData : null;
|
||||
}
|
||||
|
||||
async save(data: TData): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ describe("ObsidianVaultGateway", () => {
|
|||
const gateway = new ObsidianVaultGateway({
|
||||
vault: {
|
||||
getAbstractFileByPath: () => file,
|
||||
cachedRead: async () => "# Title\nBody",
|
||||
cachedRead: () => Promise.resolve("# Title\nBody"),
|
||||
},
|
||||
metadataCache: {
|
||||
getFileCache: () => ({
|
||||
|
|
@ -43,7 +43,7 @@ describe("ObsidianVaultGateway", () => {
|
|||
const gateway = new ObsidianVaultGateway({
|
||||
vault: {
|
||||
getAbstractFileByPath: () => file,
|
||||
cachedRead: async () => "# Title\nBody",
|
||||
cachedRead: () => Promise.resolve("# Title\nBody"),
|
||||
},
|
||||
metadataCache: {
|
||||
getFileCache: () => null,
|
||||
|
|
@ -97,10 +97,10 @@ describe("ObsidianVaultGateway", () => {
|
|||
const gateway = new ObsidianVaultGateway({
|
||||
vault: {
|
||||
getAbstractFileByPath: () => file,
|
||||
process: async (targetFile: TFile, updater: (data: string) => string) => {
|
||||
process: (targetFile: TFile, updater: (data: string) => string) => {
|
||||
processedFile = targetFile;
|
||||
writtenContent = updater("# Title\nBody");
|
||||
return writtenContent;
|
||||
return Promise.resolve(writtenContent);
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
|
@ -117,7 +117,7 @@ describe("ObsidianVaultGateway", () => {
|
|||
const gateway = new ObsidianVaultGateway({
|
||||
vault: {
|
||||
getAbstractFileByPath: () => file,
|
||||
process: async (_targetFile: TFile, updater: (data: string) => string) => updater("# Title\nChanged"),
|
||||
process: (_targetFile: TFile, updater: (data: string) => string) => Promise.resolve(updater("# Title\nChanged")),
|
||||
},
|
||||
} as never);
|
||||
|
||||
|
|
|
|||
|
|
@ -15,20 +15,20 @@ const AUDIO_EXTENSIONS = new Set(["wav", "m4a", "flac", "mp3", "wma", "aac", "we
|
|||
export class ObsidianVaultGateway implements VaultGateway, ManualSyncVaultGateway {
|
||||
constructor(private readonly app: App) {}
|
||||
|
||||
async listFolderTree(): Promise<FolderTreeNode[]> {
|
||||
return this.app.vault
|
||||
listFolderTree(): Promise<FolderTreeNode[]> {
|
||||
return Promise.resolve(this.app.vault
|
||||
.getRoot()
|
||||
.children.filter((child): child is TFolder => child instanceof TFolder)
|
||||
.map((folder) => this.toFolderTreeNode(folder));
|
||||
.map((folder) => this.toFolderTreeNode(folder)));
|
||||
}
|
||||
|
||||
async listMarkdownFileRefs(): Promise<MarkdownFileReference[]> {
|
||||
return this.app.vault.getMarkdownFiles().map((file) => ({
|
||||
listMarkdownFileRefs(): Promise<MarkdownFileReference[]> {
|
||||
return Promise.resolve(this.app.vault.getMarkdownFiles().map((file) => ({
|
||||
path: file.path,
|
||||
basename: file.basename,
|
||||
mtime: file.stat.mtime,
|
||||
size: file.stat.size,
|
||||
}));
|
||||
})));
|
||||
}
|
||||
|
||||
async listMarkdownFiles() {
|
||||
|
|
|
|||
|
|
@ -8,12 +8,13 @@ import { DataJsonPluginConfigRepository, type PluginDataSnapshot } from "./DataJ
|
|||
class InMemoryPluginDataStore implements PluginDataStore<PluginDataSnapshot> {
|
||||
constructor(private snapshot: PluginDataSnapshot | null = null) {}
|
||||
|
||||
async load(): Promise<PluginDataSnapshot | null> {
|
||||
return this.snapshot;
|
||||
load(): Promise<PluginDataSnapshot | null> {
|
||||
return Promise.resolve(this.snapshot);
|
||||
}
|
||||
|
||||
async save(data: PluginDataSnapshot): Promise<void> {
|
||||
save(data: PluginDataSnapshot): Promise<void> {
|
||||
this.snapshot = data;
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,12 +10,13 @@ import type { PluginDataSnapshot } from "./DataJsonPluginConfigRepository";
|
|||
class InMemoryPluginDataStore implements PluginDataStore<PluginDataSnapshot> {
|
||||
constructor(private snapshot: PluginDataSnapshot | null = null) {}
|
||||
|
||||
async load(): Promise<PluginDataSnapshot | null> {
|
||||
return this.snapshot;
|
||||
load(): Promise<PluginDataSnapshot | null> {
|
||||
return Promise.resolve(this.snapshot);
|
||||
}
|
||||
|
||||
async save(data: PluginDataSnapshot): Promise<void> {
|
||||
save(data: PluginDataSnapshot): Promise<void> {
|
||||
this.snapshot = data;
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,9 @@ export function migratePluginState(pluginState?: PluginState | LegacyPluginState
|
|||
return createEmptyPluginState();
|
||||
}
|
||||
|
||||
const rawCards = pluginState.cards ?? {};
|
||||
const rawCards = toLegacyCardRecord(pluginState.cards);
|
||||
const rawFiles = toLegacyFileRecord(pluginState.files);
|
||||
const rawGroupBlocks = toLegacyGroupBlockRecord(pluginState.groupBlocks);
|
||||
const cards: Record<string, CardState> = {};
|
||||
|
||||
for (const rawCard of Object.values(rawCards)) {
|
||||
|
|
@ -75,7 +77,7 @@ export function migratePluginState(pluginState?: PluginState | LegacyPluginState
|
|||
}
|
||||
|
||||
const files: Record<string, FileState> = {};
|
||||
for (const [filePath, rawFile] of Object.entries(pluginState.files ?? {})) {
|
||||
for (const [filePath, rawFile] of Object.entries(rawFiles)) {
|
||||
files[filePath] = {
|
||||
filePath,
|
||||
fileHash: typeof rawFile.fileHash === "string" ? rawFile.fileHash : "",
|
||||
|
|
@ -83,12 +85,12 @@ export function migratePluginState(pluginState?: PluginState | LegacyPluginState
|
|||
deckRulesFingerprint: typeof rawFile.deckRulesFingerprint === "string" ? rawFile.deckRulesFingerprint : undefined,
|
||||
lastIndexedAt: typeof rawFile.lastIndexedAt === "number" ? rawFile.lastIndexedAt : 0,
|
||||
noteIds: collectMigratedFileNoteIds(rawFile, rawCards, cards),
|
||||
groupIds: collectMigratedFileGroupIds(rawFile, pluginState.groupBlocks ?? {}),
|
||||
groupIds: collectMigratedFileGroupIds(rawFile, rawGroupBlocks),
|
||||
};
|
||||
}
|
||||
|
||||
const groupBlocks: Record<string, GroupBlockState> = {};
|
||||
for (const [groupId, rawGroupBlock] of Object.entries(pluginState.groupBlocks ?? {})) {
|
||||
for (const [groupId, rawGroupBlock] of Object.entries(rawGroupBlocks)) {
|
||||
const nextGroupBlock = migrateGroupBlockState(rawGroupBlock, groupId);
|
||||
if (!nextGroupBlock) {
|
||||
continue;
|
||||
|
|
@ -199,7 +201,7 @@ function migrateGroupBlockState(rawGroupBlock: LegacyGroupBlockState, groupId: s
|
|||
return [];
|
||||
}
|
||||
|
||||
const nextItem = item as GroupBlockState["items"][number];
|
||||
const nextItem = item as unknown as Record<string, unknown>;
|
||||
if (typeof nextItem.title !== "string" || typeof nextItem.answer !== "string" || typeof nextItem.ordinalInMarkdown !== "number") {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -307,6 +309,44 @@ function collectMigratedFileGroupIds(
|
|||
return rawFile.groupIds.filter((groupId): groupId is string => typeof groupId === "string" && Boolean(rawGroupBlocks[groupId]));
|
||||
}
|
||||
|
||||
function toLegacyCardRecord(cards: PluginState["cards"] | LegacyPluginState["cards"] | undefined): Record<string, LegacyCardState> {
|
||||
const next: Record<string, LegacyCardState> = {};
|
||||
const source = cards ?? {};
|
||||
|
||||
for (const cardId in source) {
|
||||
const rawCard = source[cardId];
|
||||
next[cardId] = rawCard;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function toLegacyFileRecord(files: PluginState["files"] | LegacyPluginState["files"] | undefined): Record<string, LegacyFileState> {
|
||||
const next: Record<string, LegacyFileState> = {};
|
||||
const source = files ?? {};
|
||||
|
||||
for (const filePath in source) {
|
||||
const rawFile = source[filePath];
|
||||
next[filePath] = rawFile;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function toLegacyGroupBlockRecord(
|
||||
groupBlocks: PluginState["groupBlocks"] | LegacyPluginState["groupBlocks"] | undefined,
|
||||
): Record<string, LegacyGroupBlockState> {
|
||||
const next: Record<string, LegacyGroupBlockState> = {};
|
||||
const source = groupBlocks ?? {};
|
||||
|
||||
for (const groupId in source) {
|
||||
const rawGroupBlock = source[groupId];
|
||||
next[groupId] = rawGroupBlock;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function sanitizeNoteId(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,17 @@
|
|||
import * as obsidian from "obsidian";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { formatList, resolvePluginLocale } from "./locale";
|
||||
|
||||
describe("locale", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
it("returns zh for zh-based language codes", () => {
|
||||
expect(resolvePluginLocale("zh")).toBe("zh");
|
||||
expect(resolvePluginLocale("zh-CN")).toBe("zh");
|
||||
expect(resolvePluginLocale("zh-TW")).toBe("zh");
|
||||
});
|
||||
|
||||
it("returns zh only when Obsidian language is exactly zh", () => {
|
||||
vi.spyOn(obsidian, "getLanguage").mockReturnValue("zh");
|
||||
|
||||
expect(resolvePluginLocale()).toBe("zh");
|
||||
});
|
||||
|
||||
it("falls back to en for every other Obsidian language code", () => {
|
||||
const getLanguage = vi.spyOn(obsidian, "getLanguage");
|
||||
|
||||
for (const language of ["en", "en-GB", "ja", "zh-TW"]) {
|
||||
getLanguage.mockReturnValue(language);
|
||||
expect(resolvePluginLocale()).toBe("en");
|
||||
it("falls back to en for every other language code", () => {
|
||||
for (const language of ["en", "en-GB", "ja"]) {
|
||||
expect(resolvePluginLocale(language)).toBe("en");
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
import { getLanguage } from "obsidian";
|
||||
|
||||
export type PluginLocale = "en" | "zh";
|
||||
|
||||
export function resolvePluginLocale(): PluginLocale {
|
||||
return getLanguage() === "zh" ? "zh" : "en";
|
||||
export function resolvePluginLocale(languageCode = getPreferredLanguage()): PluginLocale {
|
||||
const normalizedLanguage = languageCode.toLowerCase();
|
||||
return normalizedLanguage === "zh" || normalizedLanguage.startsWith("zh-") ? "zh" : "en";
|
||||
}
|
||||
|
||||
export function formatList(locale: PluginLocale, items: string[]): string {
|
||||
return items.join(locale === "zh" ? "、" : ", ");
|
||||
}
|
||||
|
||||
function getPreferredLanguage(): string {
|
||||
if (typeof navigator !== "undefined" && typeof navigator.language === "string") {
|
||||
return navigator.language;
|
||||
}
|
||||
|
||||
return "en";
|
||||
}
|
||||
|
|
@ -10,34 +10,34 @@ export const en = {
|
|||
cleanupEmptyDecks: "Clean up empty decks",
|
||||
},
|
||||
settings: {
|
||||
pluginTitle: "Anki Heading Sync",
|
||||
pluginTitle: "Anki heading sync",
|
||||
ankiConnectUrl: {
|
||||
name: "AnkiConnect URL",
|
||||
desc: "Default is http://127.0.0.1:8765",
|
||||
placeholder: "http://127.0.0.1:8765",
|
||||
desc: "Default: http://127.0.0.1:8765",
|
||||
placeholder: "Example: http://127.0.0.1:8765",
|
||||
},
|
||||
qaHeadingLevel: {
|
||||
name: "QA heading level",
|
||||
desc: "Default is H4",
|
||||
name: "Heading level for question-and-answer cards",
|
||||
desc: "Default: H4",
|
||||
},
|
||||
clozeHeadingLevel: {
|
||||
name: "Cloze heading level",
|
||||
desc: "Default is H5",
|
||||
desc: "Default: H5",
|
||||
},
|
||||
cardAnswerCutoffMode: {
|
||||
name: "Card answer cutoff mode",
|
||||
desc: "A valid sync marker always wins. Without a valid marker, either keep the whole heading block or stop before the first 2+ consecutive blank lines.",
|
||||
options: {
|
||||
headingBlock: "heading-block: whole heading block",
|
||||
doubleBlankLines: "double-blank-lines: stop before the first 2+ blank lines",
|
||||
headingBlock: "Heading-block: whole heading block",
|
||||
doubleBlankLines: "Double-blank-lines: stop before the first 2+ blank lines",
|
||||
},
|
||||
},
|
||||
qaGroup: {
|
||||
title: "QA Group list notes",
|
||||
title: "List notes for question-and-answer groups",
|
||||
marker: {
|
||||
name: "QA Group marker",
|
||||
desc: "When a QA heading ends with this hashtag marker, the whole heading block syncs as one QA Group list note.",
|
||||
placeholder: "#anki-list",
|
||||
name: "Marker for question-and-answer groups",
|
||||
desc: "When a question-and-answer heading ends with this hashtag marker, the whole heading block syncs as one list note for that group.",
|
||||
placeholder: "Hashtag marker",
|
||||
},
|
||||
managedNoteType: "Current note type: {{modelName}}. This route syncs QA Group list notes using the field mapping shown below.",
|
||||
managedModelContract: "Current note type summary: {{fieldCount}} fields and {{templateCount}} templates are available for this route.",
|
||||
|
|
@ -49,7 +49,7 @@ export const en = {
|
|||
},
|
||||
obsidianBacklinkLabel: {
|
||||
name: "Obsidian backlink label",
|
||||
desc: "The text shown for the backlink. Blank input falls back to Open in Obsidian.",
|
||||
desc: "The text shown for the backlink. Blank input uses the default backlink label.",
|
||||
placeholder: "Open in Obsidian",
|
||||
},
|
||||
obsidianBacklinkPlacement: {
|
||||
|
|
@ -62,7 +62,7 @@ export const en = {
|
|||
},
|
||||
},
|
||||
highlightsToCloze: {
|
||||
name: "Highlights to Cloze",
|
||||
name: "Highlights to cloze",
|
||||
desc: "Convert ==highlight== segments into cloze deletions for cloze cards.",
|
||||
},
|
||||
syncObsidianTagsToAnki: {
|
||||
|
|
@ -71,7 +71,7 @@ export const en = {
|
|||
},
|
||||
keepPureTagLinesInCardBody: {
|
||||
name: "Keep pure tag lines in card body",
|
||||
desc: "When turned off, remove every line in the card body that contains only tags, such as \"#ProjectA #重点/案例\". Inline tags like \"This is a #tag example\" and lines with ordinary text like \"标签:#项目A\" are kept. Extra blank lines created by removal are cleaned up automatically.",
|
||||
desc: "When turned off, remove every line in the card body that contains only tags, such as \"#example #tag/case\". Inline tags like \"this is a #tag example\" and lines with ordinary text like \"tag label: #project\" are kept. Extra blank lines created by removal are cleaned up automatically.",
|
||||
},
|
||||
},
|
||||
mapping: {
|
||||
|
|
@ -97,8 +97,8 @@ export const en = {
|
|||
},
|
||||
section: {
|
||||
basic: {
|
||||
title: "QA / Basic",
|
||||
description: "Choose the QA note type, read its fields from Anki, then confirm the title/body mapping.",
|
||||
title: "Basic and question-and-answer",
|
||||
description: "Choose the note type for question-and-answer cards, read its fields from Anki, then confirm the title and body mapping.",
|
||||
},
|
||||
cloze: {
|
||||
title: "Cloze",
|
||||
|
|
@ -123,7 +123,7 @@ export const en = {
|
|||
name: "Cloze main field",
|
||||
desc: "The selected field receives title + <br><hr> + body during sync.",
|
||||
},
|
||||
selectFieldPlaceholder: "-- Select field --",
|
||||
selectFieldPlaceholder: "-- select field --",
|
||||
},
|
||||
deck: {
|
||||
defaultSectionTitle: "Default deck",
|
||||
|
|
@ -138,7 +138,7 @@ export const en = {
|
|||
},
|
||||
marker: {
|
||||
name: "Deck marker name",
|
||||
desc: "YAML key and body marker share the same identifier. The default value is TARGET DECK.",
|
||||
desc: "YAML key and body marker share the same identifier. By default, use the built-in target deck marker.",
|
||||
},
|
||||
template: {
|
||||
name: "Default deck template",
|
||||
|
|
@ -203,7 +203,7 @@ export const en = {
|
|||
desc: "Edit recognition rules directly. Changes save automatically.",
|
||||
advancedHint: "Advanced: keep AnkiConnect URL here without promoting it to a separate status card.",
|
||||
markerPlaceholder: "/",
|
||||
fieldsUnavailable: "-- Read fields first --",
|
||||
fieldsUnavailable: "-- read fields first --",
|
||||
autoManagedField: "Written automatically",
|
||||
autoComposedAnswer: "Auto composed",
|
||||
qaGroupManagedNoteType: "{{modelName}} (for QA Group list notes)",
|
||||
|
|
@ -241,7 +241,7 @@ export const en = {
|
|||
qaGroupFirstQuestionField: "First question field:",
|
||||
qaGroupFirstAnswerField: "First answer field:",
|
||||
qaGroupConfiguredSlots: "Configured pairs:",
|
||||
qaGroupSlotFields: "Q/A fields:",
|
||||
qaGroupSlotFields: "Question/answer fields:",
|
||||
qaGroupWarnings: "Warnings:",
|
||||
},
|
||||
qaGroup: {
|
||||
|
|
@ -258,8 +258,8 @@ export const en = {
|
|||
},
|
||||
sharedClozeMappingHint: "Reuse the current Anki note type and main field from the sequential cloze row. No separate setup is needed.",
|
||||
rows: {
|
||||
basic: "Q&A (regular paragraph)",
|
||||
qaGroup: "Q&A (nested list)",
|
||||
basic: "Question and answer (regular paragraph)",
|
||||
qaGroup: "Question and answer (nested list)",
|
||||
cloze: "Cloze (sequential deletions)",
|
||||
clozeAll: "Cloze (all deletions)",
|
||||
},
|
||||
|
|
@ -353,15 +353,15 @@ export const en = {
|
|||
},
|
||||
settings: {
|
||||
headingLevelsRange: "Heading levels must be integers between 1 and 6.",
|
||||
headingLevelsDifferent: "QA and Cloze heading levels must be different.",
|
||||
headingLevelsDifferent: "The question-and-answer and cloze heading levels must be different.",
|
||||
cardTypeConfigsObject: "Card type configs must be an object.",
|
||||
cardTypeEnabledBoolean: "Each card type enabled flag must be a boolean.",
|
||||
cardTypeExtraMarkerString: "Each card type extra marker must be a string.",
|
||||
cardTypeDefaultConflict: "Only one enabled default card type is allowed at heading H{{headingLevel}}.",
|
||||
cardAnswerCutoffModeInvalid: "Card answer cutoff mode must be heading-block or double-blank-lines.",
|
||||
qaNoteTypeRequired: "QA note type is required.",
|
||||
qaGroupMarkerRequired: "QA Group marker is required.",
|
||||
qaGroupMarkerInvalid: "QA Group marker must be a hashtag-style token like #anki-list.",
|
||||
qaNoteTypeRequired: "A question-and-answer note type is required.",
|
||||
qaGroupMarkerRequired: "A question-and-answer group marker is required.",
|
||||
qaGroupMarkerInvalid: "The question-and-answer group marker must be a hashtag-style token.",
|
||||
clozeNoteTypeRequired: "Cloze note type is required.",
|
||||
defaultDeckRequired: "Default deck is required.",
|
||||
fileDeckEnabledBoolean: "File deck enabled must be a boolean.",
|
||||
|
|
@ -369,7 +369,7 @@ export const en = {
|
|||
keepPureTagLinesInCardBodyBoolean: "Keep pure tag lines in card body must be a boolean.",
|
||||
fileDeckMarkerString: "File deck marker must be a string.",
|
||||
fileDeckTemplateString: "File deck template must be a string.",
|
||||
fileDeckInsertLocationInvalid: "File deck insert location must be yaml or body.",
|
||||
fileDeckInsertLocationInvalid: "File deck insert location must be YAML or body.",
|
||||
folderDeckModeInvalid: "Folder deck mode must be off, folder, or folder-and-file.",
|
||||
scopeModeInvalid: "Scope mode must be one of all, include, or exclude.",
|
||||
obsidianBacklinkPlacementInvalid: "Obsidian backlink placement must be question-last-line, answer-first-line, or answer-last-line.",
|
||||
|
|
@ -390,25 +390,25 @@ export const en = {
|
|||
noteFieldMappingsModelName: "Note field mappings must include a model name.",
|
||||
noteFieldMappingsLoadedFieldNames: "Note field mappings must include loaded field names.",
|
||||
noteFieldMappingsLoadedAt: "Note field mappings must include a loaded timestamp.",
|
||||
noteFieldMappingsQaGroupSlotsArray: "QA Group field mapping slots must be an array.",
|
||||
noteFieldMappingsQaGroupSlotObject: "Each QA Group field mapping slot must be an object.",
|
||||
noteFieldMappingsQaGroupSlotIndex: "Each QA Group field mapping slot.index must be an integer greater than 0.",
|
||||
noteFieldMappingsQaGroupSlotQuestionField: "Each QA Group field mapping slot.questionField must be a string.",
|
||||
noteFieldMappingsQaGroupSlotAnswerField: "Each QA Group field mapping slot.answerField must be a string.",
|
||||
noteFieldMappingsQaGroupWarningsArray: "QA Group field mapping warnings must be an array.",
|
||||
noteFieldMappingsQaGroupWarningsStrings: "QA Group field mapping warnings can only contain strings.",
|
||||
noteFieldMappingsQaGroupAcceptedWarningsArray: "QA Group field mapping acceptedWarnings must be an array.",
|
||||
noteFieldMappingsQaGroupAcceptedWarningsStrings: "QA Group field mapping acceptedWarnings can only contain strings.",
|
||||
noteFieldMappingsQaGroupDerivationObject: "QA Group field mapping derivation must be an object.",
|
||||
noteFieldMappingsQaGroupSlotsArray: "Question-and-answer group field mapping slots must be an array.",
|
||||
noteFieldMappingsQaGroupSlotObject: "Each question-and-answer group field mapping slot must be an object.",
|
||||
noteFieldMappingsQaGroupSlotIndex: "Each question-and-answer group field mapping slot.index must be an integer greater than 0.",
|
||||
noteFieldMappingsQaGroupSlotQuestionField: "Each question-and-answer group field mapping slot.questionField must be a string.",
|
||||
noteFieldMappingsQaGroupSlotAnswerField: "Each question-and-answer group field mapping slot.answerField must be a string.",
|
||||
noteFieldMappingsQaGroupWarningsArray: "Question-and-answer group field mapping warnings must be an array.",
|
||||
noteFieldMappingsQaGroupWarningsStrings: "Question-and-answer group field mapping warnings can only contain strings.",
|
||||
noteFieldMappingsQaGroupAcceptedWarningsArray: "Question-and-answer group field mapping acceptedWarnings must be an array.",
|
||||
noteFieldMappingsQaGroupAcceptedWarningsStrings: "Question-and-answer group field mapping acceptedWarnings can only contain strings.",
|
||||
noteFieldMappingsQaGroupDerivationObject: "Question-and-answer group field mapping derivation must be an object.",
|
||||
noteFieldMappingsQaGroupDerivationMode: "QA Group field mapping derivation.mode must be first-pair.",
|
||||
noteFieldMappingsQaGroupDerivationFirstQuestionField: "QA Group field mapping derivation.firstQuestionField must be a string.",
|
||||
noteFieldMappingsQaGroupDerivationFirstAnswerField: "QA Group field mapping derivation.firstAnswerField must be a string.",
|
||||
noteFieldMappingsQaGroupDerivationFirstQuestionField: "Question-and-answer group field mapping derivation.firstQuestionField must be a string.",
|
||||
noteFieldMappingsQaGroupDerivationFirstAnswerField: "Question-and-answer group field mapping derivation.firstAnswerField must be a string.",
|
||||
},
|
||||
noteFieldMapping: {
|
||||
noteTypeNotSelected: {
|
||||
basic: "No Anki note type is selected for basic cards. Choose one in settings before syncing.",
|
||||
cloze: "No Anki note type is selected for cloze cards. Choose one in settings before syncing.",
|
||||
qaGroup: "No Anki note type is selected for QA Group cards. Choose one in settings before syncing.",
|
||||
qaGroup: "No Anki note type is selected for question-and-answer group cards. Choose one in settings before syncing.",
|
||||
},
|
||||
missingSavedMapping: {
|
||||
basic: "No saved field mapping found for basic note type \"{{modelName}}\". Open plugin settings and read fields from Anki first.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
FakeButtonComponent,
|
||||
|
|
@ -21,6 +21,12 @@ const {
|
|||
return child;
|
||||
}
|
||||
|
||||
createSpan(options?: { text?: string }): HoistedFakeElement {
|
||||
const child = new HoistedFakeElement("span", options?.text ?? "");
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
addClass(className: string): void {
|
||||
this.classes.push(className);
|
||||
}
|
||||
|
|
@ -167,6 +173,15 @@ type FakeButtonComponentInstance = InstanceType<typeof FakeButtonComponent>;
|
|||
type FakeContainerElInstance = InstanceType<typeof FakeModal>["contentEl"];
|
||||
type FakeToggleComponentInstance = InstanceType<typeof FakeToggleComponent>;
|
||||
|
||||
function setNavigatorLanguage(language: string): void {
|
||||
vi.stubGlobal("navigator", { language });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function getFakeContentEl(modal: EmptyDeckSelectionModal): FakeContainerElInstance {
|
||||
return modal.contentEl as unknown as FakeContainerElInstance;
|
||||
}
|
||||
|
|
@ -188,8 +203,8 @@ function getFooterCountText(contentEl: FakeContainerElInstance): string | undefi
|
|||
}
|
||||
|
||||
describe("EmptyDeckSelectionModal", () => {
|
||||
it("renders the modal text in English when Obsidian language is not zh", async () => {
|
||||
getLanguageMock.mockReturnValue("en");
|
||||
it("renders the modal text in English when Obsidian language is not zh", () => {
|
||||
setNavigatorLanguage("en");
|
||||
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B", "Deck C"]);
|
||||
|
||||
void modal.openAndGetSelection();
|
||||
|
|
@ -208,8 +223,8 @@ describe("EmptyDeckSelectionModal", () => {
|
|||
expect(getFooterCountText(contentEl)).toBe("Selected 0 / 3 empty decks");
|
||||
});
|
||||
|
||||
it("starts with zero selected count and a disabled delete button", async () => {
|
||||
getLanguageMock.mockReturnValue("en");
|
||||
it("starts with zero selected count and a disabled delete button", () => {
|
||||
setNavigatorLanguage("en");
|
||||
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B", "Deck C"]);
|
||||
|
||||
void modal.openAndGetSelection();
|
||||
|
|
@ -221,7 +236,7 @@ describe("EmptyDeckSelectionModal", () => {
|
|||
});
|
||||
|
||||
it("selects every candidate when the select-all button is clicked", async () => {
|
||||
getLanguageMock.mockReturnValue("en");
|
||||
setNavigatorLanguage("en");
|
||||
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B", "Deck C"]);
|
||||
|
||||
const selectionPromise = modal.openAndGetSelection();
|
||||
|
|
@ -244,7 +259,7 @@ describe("EmptyDeckSelectionModal", () => {
|
|||
});
|
||||
|
||||
it("clears every candidate when the clear-all button is clicked", async () => {
|
||||
getLanguageMock.mockReturnValue("en");
|
||||
setNavigatorLanguage("en");
|
||||
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B", "Deck C"]);
|
||||
|
||||
void modal.openAndGetSelection();
|
||||
|
|
@ -262,7 +277,7 @@ describe("EmptyDeckSelectionModal", () => {
|
|||
});
|
||||
|
||||
it("inverts selected candidates when the invert button is clicked", async () => {
|
||||
getLanguageMock.mockReturnValue("en");
|
||||
setNavigatorLanguage("en");
|
||||
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B", "Deck C"]);
|
||||
|
||||
void modal.openAndGetSelection();
|
||||
|
|
@ -281,7 +296,7 @@ describe("EmptyDeckSelectionModal", () => {
|
|||
});
|
||||
|
||||
it("enables and disables the delete button as manual selection changes", async () => {
|
||||
getLanguageMock.mockReturnValue("en");
|
||||
setNavigatorLanguage("en");
|
||||
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A"]);
|
||||
|
||||
void modal.openAndGetSelection();
|
||||
|
|
@ -299,7 +314,7 @@ describe("EmptyDeckSelectionModal", () => {
|
|||
});
|
||||
|
||||
it("returns null when cancel is clicked", async () => {
|
||||
getLanguageMock.mockReturnValue("zh");
|
||||
setNavigatorLanguage("zh");
|
||||
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B"]);
|
||||
|
||||
const selectionPromise = modal.openAndGetSelection();
|
||||
|
|
@ -311,7 +326,7 @@ describe("EmptyDeckSelectionModal", () => {
|
|||
});
|
||||
|
||||
it("renders selection text in Simplified Chinese when Obsidian language is zh", async () => {
|
||||
getLanguageMock.mockReturnValue("zh");
|
||||
setNavigatorLanguage("zh");
|
||||
const modal = new EmptyDeckSelectionModal({} as never, ["Deck A", "Deck B"]);
|
||||
|
||||
void modal.openAndGetSelection();
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export class EmptyDeckSelectionModal extends Modal {
|
|||
});
|
||||
});
|
||||
|
||||
this.selectionCountEl = footerSetting.settingEl.createEl("span", {
|
||||
this.selectionCountEl = footerSetting.settingEl.createSpan({
|
||||
text: this.getSelectionCountText(),
|
||||
});
|
||||
this.selectionCountEl.addClass("anki-helper-empty-deck-selection-count");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { getLanguageMock, noticeRecords } = vi.hoisted(() => {
|
||||
const hoistedGetLanguage = vi.fn(() => "en");
|
||||
|
|
@ -31,6 +31,10 @@ import type { ManualSyncResult } from "@/application/use-cases/manualSyncTypes";
|
|||
|
||||
import { NoticeService } from "./NoticeService";
|
||||
|
||||
function setNavigatorLanguage(language: string): void {
|
||||
vi.stubGlobal("navigator", { language });
|
||||
}
|
||||
|
||||
function createManualSyncResult(overrides: Partial<ManualSyncResult> = {}): ManualSyncResult {
|
||||
return {
|
||||
scannedFiles: 3,
|
||||
|
|
@ -81,6 +85,11 @@ describe("NoticeService", () => {
|
|||
noticeRecords.length = 0;
|
||||
getLanguageMock.mockReset();
|
||||
getLanguageMock.mockReturnValue("en");
|
||||
setNavigatorLanguage("en");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders sync summary and deck warnings in English", () => {
|
||||
|
|
@ -102,7 +111,7 @@ describe("NoticeService", () => {
|
|||
});
|
||||
|
||||
it("renders clear-current-file summaries with localized failures", () => {
|
||||
getLanguageMock.mockReturnValue("zh");
|
||||
setNavigatorLanguage("zh");
|
||||
const service = new NoticeService();
|
||||
|
||||
service.showClearCurrentFileSummary(createClearResult({
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ const {
|
|||
public readonly parent: HoistedFakeElement | null = null,
|
||||
) {}
|
||||
|
||||
createEl(tag: string, options?: { text?: string }): HoistedFakeElement {
|
||||
private createChild(tag: string, options?: { text?: string }): HoistedFakeElement {
|
||||
const child = new HoistedFakeElement(this.root, tag, this);
|
||||
if (options?.text) {
|
||||
child.text = options.text;
|
||||
|
|
@ -98,8 +98,16 @@ const {
|
|||
return child;
|
||||
}
|
||||
|
||||
createEl(tag: string, options?: { text?: string }): HoistedFakeElement {
|
||||
return this.createChild(tag, options);
|
||||
}
|
||||
|
||||
createDiv(options?: { text?: string }): HoistedFakeElement {
|
||||
return this.createEl("div", options);
|
||||
return this.createChild("div", options);
|
||||
}
|
||||
|
||||
createSpan(options?: { text?: string }): HoistedFakeElement {
|
||||
return this.createChild("span", options);
|
||||
}
|
||||
|
||||
addEventListener(eventName: string, callback: () => void | Promise<void>): void {
|
||||
|
|
@ -286,27 +294,57 @@ const {
|
|||
class HoistedFakeSetting {
|
||||
public name = "";
|
||||
public desc: unknown = "";
|
||||
public readonly settingEl: HoistedFakeElement;
|
||||
public readonly infoEl: HoistedFakeElement;
|
||||
public readonly nameEl: HoistedFakeElement;
|
||||
public readonly descEl: HoistedFakeElement;
|
||||
public readonly controlEl: HoistedFakeElement;
|
||||
public controls: Array<
|
||||
HoistedFakeButtonComponent | HoistedFakeDropdownComponent | HoistedFakeTextComponent | HoistedFakeToggleComponent
|
||||
> = [];
|
||||
|
||||
constructor(containerEl: HoistedFakeElement) {
|
||||
this.settingEl = new HoistedFakeElement(containerEl.root, "div", containerEl);
|
||||
this.infoEl = new HoistedFakeElement(containerEl.root, "div", this.settingEl);
|
||||
this.nameEl = new HoistedFakeElement(containerEl.root, "div", this.infoEl);
|
||||
this.descEl = new HoistedFakeElement(containerEl.root, "div", this.infoEl);
|
||||
this.controlEl = new HoistedFakeElement(containerEl.root, "div", this.settingEl);
|
||||
|
||||
this.settingEl.children.push(this.infoEl, this.controlEl);
|
||||
this.infoEl.children.push(this.nameEl, this.descEl);
|
||||
containerEl.children.push(this.settingEl);
|
||||
containerEl.root.settings.push(this);
|
||||
containerEl.ownedSettings.push(this);
|
||||
}
|
||||
|
||||
setName(name: string): this {
|
||||
this.name = name;
|
||||
this.nameEl.text = name;
|
||||
this.nameEl.textContent = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
setDesc(desc: unknown): this {
|
||||
this.desc = desc;
|
||||
if (typeof desc === "string") {
|
||||
this.descEl.text = desc;
|
||||
this.descEl.textContent = desc;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
setHeading(): this {
|
||||
this.settingEl.classList.add("is-heading");
|
||||
return this;
|
||||
}
|
||||
|
||||
setClass(className: string): this {
|
||||
this.settingEl.classList.add(className);
|
||||
return this;
|
||||
}
|
||||
|
||||
addButton(callback: (button: HoistedFakeButtonComponent) => void): this {
|
||||
const button = new HoistedFakeButtonComponent();
|
||||
const button = new HoistedFakeButtonComponent(this.controlEl);
|
||||
this.controls.push(button);
|
||||
callback(button);
|
||||
return this;
|
||||
|
|
@ -405,7 +443,11 @@ type FakeElementInstance = InstanceType<typeof FakeElement>;
|
|||
type QueryRoot = FakeContainer | FakeElementInstance | HTMLElement;
|
||||
|
||||
class FakePlugin {
|
||||
public readonly app = {};
|
||||
public readonly app = {
|
||||
workspace: {
|
||||
activeWindow: typeof window === "undefined" ? undefined : window,
|
||||
},
|
||||
};
|
||||
public readonly updateCalls: Array<Record<string, unknown>> = [];
|
||||
public readonly fieldNamesByModelName: Record<string, string[]> = {};
|
||||
public listNoteModelsCalls = 0;
|
||||
|
|
@ -451,7 +493,7 @@ class FakePlugin {
|
|||
},
|
||||
];
|
||||
|
||||
async updateSettings(partialSettings: Record<string, unknown>): Promise<void> {
|
||||
updateSettings(partialSettings: Record<string, unknown>): Promise<void> {
|
||||
this.updateCalls.push(partialSettings);
|
||||
this.settings = normalizePluginSettings({
|
||||
...this.settings,
|
||||
|
|
@ -459,40 +501,45 @@ class FakePlugin {
|
|||
cardTypeConfigs: (partialSettings.cardTypeConfigs as typeof this.settings.cardTypeConfigs | undefined) ?? this.settings.cardTypeConfigs,
|
||||
noteFieldMappings: (partialSettings.noteFieldMappings as typeof this.settings.noteFieldMappings | undefined) ?? this.settings.noteFieldMappings,
|
||||
});
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async listNoteModels(): Promise<string[]> {
|
||||
listNoteModels(): Promise<string[]> {
|
||||
this.listNoteModelsCalls += 1;
|
||||
if (this.listNoteModelsError) {
|
||||
throw this.listNoteModelsError;
|
||||
return Promise.reject(this.listNoteModelsError);
|
||||
}
|
||||
|
||||
return [...this.noteModels];
|
||||
return Promise.resolve([...this.noteModels]);
|
||||
}
|
||||
|
||||
async getModelFieldNamesByModelNames(modelNames: string[]): Promise<Record<string, string[]>> {
|
||||
getModelFieldNamesByModelNames(modelNames: string[]): Promise<Record<string, string[]>> {
|
||||
this.getModelFieldNamesByModelNamesCalls += 1;
|
||||
|
||||
return modelNames.reduce<Record<string, string[]>>((fieldNamesByModelName, modelName) => {
|
||||
fieldNamesByModelName[modelName] = this.getFieldNamesForModel(modelName);
|
||||
return fieldNamesByModelName;
|
||||
const fieldNamesByModelName = modelNames.reduce<Record<string, string[]>>((resolvedFieldNamesByModelName, modelName) => {
|
||||
resolvedFieldNamesByModelName[modelName] = this.getFieldNamesForModel(modelName);
|
||||
return resolvedFieldNamesByModelName;
|
||||
}, {});
|
||||
|
||||
return Promise.resolve(fieldNamesByModelName);
|
||||
}
|
||||
|
||||
async getNoteModelDetails(modelName: string): Promise<{ fieldNames: string[] }> {
|
||||
getNoteModelDetails(modelName: string): Promise<{ fieldNames: string[] }> {
|
||||
this.getNoteModelDetailsCalls += 1;
|
||||
return {
|
||||
return Promise.resolve({
|
||||
fieldNames: this.getFieldNamesForModel(modelName),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async listFolderTree(): Promise<typeof this.folderTree> {
|
||||
listFolderTree(): Promise<typeof this.folderTree> {
|
||||
this.listFolderTreeCalls += 1;
|
||||
return this.folderTree;
|
||||
return Promise.resolve(this.folderTree);
|
||||
}
|
||||
|
||||
async insertDeckTemplateToCurrentFile(): Promise<void> {
|
||||
insertDeckTemplateToCurrentFile(): Promise<void> {
|
||||
this.insertDeckTemplateCalls += 1;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
private getFieldNamesForModel(modelName: string): string[] {
|
||||
|
|
@ -641,31 +688,54 @@ async function flushPromises(): Promise<void> {
|
|||
}
|
||||
|
||||
async function expandCard(tab: AnkiHeadingSyncSettingTab, cardId: string): Promise<void> {
|
||||
if (queryByDataset(tab.containerEl, "settingsCardBody", cardId).style.display === "block") {
|
||||
if (!queryByDataset(tab.containerEl, "settingsCardBody", cardId).classList.contains("ahs-is-hidden")) {
|
||||
return;
|
||||
}
|
||||
|
||||
await queryByDataset(tab.containerEl, "settingsCardToggle", cardId).trigger("click");
|
||||
}
|
||||
|
||||
describe("PluginSettingTab", () => {
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
function isCardExpanded(tab: AnkiHeadingSyncSettingTab, cardId: string): boolean {
|
||||
return !queryByDataset(tab.containerEl, "settingsCardBody", cardId).classList.contains("ahs-is-hidden");
|
||||
}
|
||||
|
||||
type TestActiveWindow = {
|
||||
setTimeout: (callback: () => void, delay?: number) => ReturnType<typeof setTimeout>;
|
||||
clearTimeout: (timer: ReturnType<typeof setTimeout>) => void;
|
||||
};
|
||||
|
||||
type TestWindow = {
|
||||
activeWindow: TestActiveWindow;
|
||||
ResizeObserver: typeof ResizeObserver;
|
||||
};
|
||||
|
||||
function createTestWindow(): TestWindow {
|
||||
const activeWindow: TestActiveWindow = {
|
||||
// eslint-disable-next-line obsidianmd/prefer-active-window-timers
|
||||
setTimeout: (callback, delay) => setTimeout(callback, delay),
|
||||
// eslint-disable-next-line obsidianmd/prefer-active-window-timers
|
||||
clearTimeout: (timer) => clearTimeout(timer),
|
||||
};
|
||||
|
||||
return {
|
||||
activeWindow,
|
||||
ResizeObserver: FakeResizeObserver as unknown as typeof ResizeObserver,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PluginSettingTab", () => {
|
||||
beforeEach(() => {
|
||||
getLanguageMock.mockReturnValue("zh");
|
||||
vi.useRealTimers();
|
||||
resetResizeObserverInstances();
|
||||
Reflect.set(globalThis, "ResizeObserver", FakeResizeObserver);
|
||||
vi.stubGlobal("navigator", { language: "zh" });
|
||||
vi.stubGlobal("ResizeObserver", FakeResizeObserver);
|
||||
vi.stubGlobal("window", createTestWindow());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
if (typeof originalResizeObserver === "undefined") {
|
||||
Reflect.deleteProperty(globalThis, "ResizeObserver");
|
||||
return;
|
||||
}
|
||||
|
||||
Reflect.set(globalThis, "ResizeObserver", originalResizeObserver);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders five cards collapsed by default", () => {
|
||||
|
|
@ -678,11 +748,11 @@ describe("PluginSettingTab", () => {
|
|||
expect(queryByDataset(tab.containerEl, "settingsPageHeader", "true")).toBeDefined();
|
||||
const cards = queryAllByDataset(tab.containerEl, "settingsCard");
|
||||
expect(cards).toHaveLength(5);
|
||||
expect(queryByDataset(tab.containerEl, "settingsCardBody", "sync-content").style.display).toBe("none");
|
||||
expect(queryByDataset(tab.containerEl, "settingsCardBody", "card-types").style.display).toBe("none");
|
||||
expect(queryByDataset(tab.containerEl, "settingsCardBody", "commands").style.display).toBe("none");
|
||||
expect(queryByDataset(tab.containerEl, "settingsCardBody", "scope").style.display).toBe("none");
|
||||
expect(queryByDataset(tab.containerEl, "settingsCardBody", "deck").style.display).toBe("none");
|
||||
expect(isCardExpanded(tab, "sync-content")).toBe(false);
|
||||
expect(isCardExpanded(tab, "card-types")).toBe(false);
|
||||
expect(isCardExpanded(tab, "commands")).toBe(false);
|
||||
expect(isCardExpanded(tab, "scope")).toBe(false);
|
||||
expect(isCardExpanded(tab, "deck")).toBe(false);
|
||||
|
||||
expect(cards.map((card) => card.dataset.settingsCard)).toEqual(["card-types", "sync-content", "deck", "scope", "commands"]);
|
||||
|
||||
|
|
@ -699,15 +769,15 @@ describe("PluginSettingTab", () => {
|
|||
|
||||
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
|
||||
|
||||
expect(queryByDataset(tab.containerEl, "settingsCardBody", "scope").style.display).toBe("block");
|
||||
expect(queryByDataset(tab.containerEl, "settingsCardBody", "card-types").style.display).toBe("none");
|
||||
expect(queryByDataset(tab.containerEl, "settingsCardBody", "sync-content").style.display).toBe("none");
|
||||
expect(queryByDataset(tab.containerEl, "settingsCardBody", "deck").style.display).toBe("none");
|
||||
expect(queryByDataset(tab.containerEl, "settingsCardBody", "commands").style.display).toBe("none");
|
||||
expect(isCardExpanded(tab, "scope")).toBe(true);
|
||||
expect(isCardExpanded(tab, "card-types")).toBe(false);
|
||||
expect(isCardExpanded(tab, "sync-content")).toBe(false);
|
||||
expect(isCardExpanded(tab, "deck")).toBe(false);
|
||||
expect(isCardExpanded(tab, "commands")).toBe(false);
|
||||
|
||||
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
|
||||
|
||||
expect(queryByDataset(tab.containerEl, "settingsCardBody", "scope").style.display).toBe("none");
|
||||
expect(isCardExpanded(tab, "scope")).toBe(false);
|
||||
});
|
||||
|
||||
it("applies sticky styles to the opaque page header gap, mask, and card headers", () => {
|
||||
|
|
@ -718,39 +788,17 @@ describe("PluginSettingTab", () => {
|
|||
|
||||
const pageHeader = queryByDataset(tab.containerEl, "settingsPageHeader", "true");
|
||||
const pageHeaderMask = queryByDataset(pageHeader, "settingsPageHeaderMask", "true");
|
||||
expect(pageHeader.style.position).toBe("sticky");
|
||||
expect(pageHeader.style.top).toBe("0px");
|
||||
expect(pageHeader.style.zIndex).toBe("300");
|
||||
expect(pageHeader.style.width).toBe("100%");
|
||||
expect(pageHeader.style.boxSizing).toBe("border-box");
|
||||
expect(pageHeader.style.marginBottom).toBe("0");
|
||||
expect(pageHeader.style.paddingBottom).toBe("calc(12px + var(--ahs-settings-sticky-card-gap, 8px))");
|
||||
expect(pageHeader.style.backgroundColor).toBe("var(--modal-background, var(--background-primary))");
|
||||
expect(pageHeader.style.boxShadow).toBe("none");
|
||||
expect(pageHeader.classList.contains("ahs-settings-page-header")).toBe(true);
|
||||
expect(collectTexts(pageHeader)).toContain("Anki Heading Sync");
|
||||
|
||||
expect(pageHeaderMask.style.position).toBe("absolute");
|
||||
expect(pageHeaderMask.style.top).toBe("-128px");
|
||||
expect(pageHeaderMask.style.left).toBe("-24px");
|
||||
expect(pageHeaderMask.style.right).toBe("-24px");
|
||||
expect(pageHeaderMask.style.bottom).toBe("0px");
|
||||
expect(pageHeaderMask.style.backgroundColor).toBe("var(--modal-background, var(--background-primary))");
|
||||
expect(pageHeaderMask.classList.contains("ahs-settings-page-header-mask")).toBe(true);
|
||||
|
||||
expect(getStyleProperty(tab.containerEl, "--ahs-settings-page-header-height")).toBe("64px");
|
||||
expect(getStyleProperty(tab.containerEl, "--ahs-settings-sticky-card-gap")).toBe("8px");
|
||||
|
||||
for (const cardId of ["card-types", "sync-content", "deck", "scope", "commands"] as const) {
|
||||
const header = queryByDataset(tab.containerEl, "settingsCardToggle", cardId);
|
||||
expect(header.style.position).toBe("sticky");
|
||||
expect(header.style.top).toBe("var(--ahs-settings-page-header-height, 64px)");
|
||||
expect(header.style.zIndex).toBe("200");
|
||||
expect(header.style.display).toBe("flex");
|
||||
expect(header.style.width).toBe("100%");
|
||||
expect(header.style.backgroundColor).toBe("var(--modal-background, var(--background-primary))");
|
||||
expect(header.style.fontSize).toBe("1.5em");
|
||||
expect(header.style.fontWeight).toBe("600");
|
||||
expect(header.style.border).toBe("2px solid var(--background-modifier-border)");
|
||||
expect(header.style.boxShadow).toBe("none");
|
||||
expect(header.classList.contains("ahs-settings-card-toggle")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -819,7 +867,9 @@ describe("PluginSettingTab", () => {
|
|||
expect(queryByDataset(tab.containerEl, "settingsCardToggle", "sync-content").textContent).toContain("卡片正文同步内容");
|
||||
expect(collectTexts(tab.containerEl)).not.toContain("这些选项只影响卡片渲染内容,修改后仅刷新这一张卡片。");
|
||||
|
||||
const sectionTitles = queryAllByDataset(tab.containerEl, "syncContentSectionTitle").map((element) => element.textContent || element.text);
|
||||
const sectionTitles = queryAllByDataset(tab.containerEl, "syncContentSectionTitle")
|
||||
.map((element) => element.textContent || element.text)
|
||||
.filter((text): text is string => text.length > 0);
|
||||
expect(sectionTitles).toEqual([
|
||||
"1. 确定「卡片正文」范围",
|
||||
"2. 确定是否增加回链,方便从 Anki「卡片级跳转」回 Obsidian",
|
||||
|
|
@ -827,7 +877,17 @@ describe("PluginSettingTab", () => {
|
|||
"4. 「填空题」专项",
|
||||
]);
|
||||
|
||||
expect(collectSettingNames(tab.containerEl)).toEqual([
|
||||
const syncContentSettingNames = collectSettingNames(tab.containerEl).filter((name) => [
|
||||
"卡片正文截止模式",
|
||||
"添加 Obsidian 回链",
|
||||
"Obsidian 回链显示名称",
|
||||
"Obsidian 回链放置位置",
|
||||
"同步 Obsidian 标签到 Anki",
|
||||
"在卡片正文中保留纯标签行",
|
||||
"高亮转填空题",
|
||||
].includes(name));
|
||||
|
||||
expect(syncContentSettingNames).toEqual([
|
||||
"卡片正文截止模式",
|
||||
"添加 Obsidian 回链",
|
||||
"Obsidian 回链显示名称",
|
||||
|
|
@ -1066,11 +1126,11 @@ describe("PluginSettingTab", () => {
|
|||
const initialEmptyCount = getEmptyCallCount(tab.containerEl);
|
||||
|
||||
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
|
||||
expect(queryByDataset(tab.containerEl, "settingsCardBody", "scope").style.display).toBe("block");
|
||||
expect(isCardExpanded(tab, "scope")).toBe(true);
|
||||
expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount);
|
||||
|
||||
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
|
||||
expect(queryByDataset(tab.containerEl, "settingsCardBody", "scope").style.display).toBe("none");
|
||||
expect(isCardExpanded(tab, "scope")).toBe(false);
|
||||
expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount);
|
||||
});
|
||||
|
||||
|
|
@ -1108,22 +1168,18 @@ describe("PluginSettingTab", () => {
|
|||
"Custom Cloze",
|
||||
QA_GROUP_USER_MODEL_NAME,
|
||||
].sort((left, right) => left.localeCompare(right)));
|
||||
expect(plugin.updateCalls.some((call) => Array.isArray(call.ankiNoteTypeCache))).toBe(true);
|
||||
expect(plugin.settings.ankiModelFieldCache).toEqual(expect.objectContaining({
|
||||
Basic: {
|
||||
fieldNames: ["Front", "Back", "Extra"],
|
||||
loadedAt: expect.any(Number),
|
||||
},
|
||||
"Custom Basic": {
|
||||
fieldNames: ["Title", "Body", "Hint"],
|
||||
loadedAt: expect.any(Number),
|
||||
},
|
||||
Cloze: {
|
||||
fieldNames: ["Text", "Extra", "Hint"],
|
||||
loadedAt: expect.any(Number),
|
||||
},
|
||||
}));
|
||||
expect(plugin.updateCalls.some((call) => typeof call.ankiModelFieldCache === "object" && call.ankiModelFieldCache !== null)).toBe(true);
|
||||
expect(plugin.updateCalls.some((call) => Array.isArray(call["ankiNoteTypeCache"]))).toBe(true);
|
||||
const { ankiModelFieldCache } = plugin.settings;
|
||||
expect(ankiModelFieldCache.Basic?.fieldNames).toEqual(["Front", "Back", "Extra"]);
|
||||
expect(typeof ankiModelFieldCache.Basic?.loadedAt).toBe("number");
|
||||
expect(ankiModelFieldCache["Custom Basic"]?.fieldNames).toEqual(["Title", "Body", "Hint"]);
|
||||
expect(typeof ankiModelFieldCache["Custom Basic"]?.loadedAt).toBe("number");
|
||||
expect(ankiModelFieldCache.Cloze?.fieldNames).toEqual(["Text", "Extra", "Hint"]);
|
||||
expect(typeof ankiModelFieldCache.Cloze?.loadedAt).toBe("number");
|
||||
expect(plugin.updateCalls.some((call) => {
|
||||
const ankiModelFieldCache = call["ankiModelFieldCache"];
|
||||
return typeof ankiModelFieldCache === "object" && ankiModelFieldCache !== null;
|
||||
})).toBe(true);
|
||||
|
||||
const cachedPlugin = new FakePlugin();
|
||||
cachedPlugin.settings = normalizePluginSettings({
|
||||
|
|
@ -1564,17 +1620,13 @@ describe("PluginSettingTab", () => {
|
|||
const childLabel = queryByDataset(tab.containerEl, "folderPathLabel", "notes/sub");
|
||||
|
||||
expect(parentToggle.textContent).toBe("▾");
|
||||
expect(parentRow.style.display).toBe("grid");
|
||||
expect(parentRow.style.gridTemplateColumns).toBe("24px 24px minmax(0, 1fr)");
|
||||
expect(parentRow.style.alignItems).toBe("center");
|
||||
expect(childRow.style.marginLeft).toBe("18px");
|
||||
expect(childToggle.style.width).toBe("24px");
|
||||
expect(childToggle.style.height).toBe("24px");
|
||||
expect(childToggle.style.fontSize).toBe("2em");
|
||||
expect(parentRow.classList.contains("ahs-settings-folder-row")).toBe(true);
|
||||
expect(getStyleProperty(childRow, "--ahs-folder-depth-indent")).toBe("18px");
|
||||
expect(childToggle.classList.contains("ahs-settings-folder-toggle")).toBe(true);
|
||||
expect(childToggle.classList.contains("ahs-settings-folder-toggle-spacer")).toBe(true);
|
||||
expect(childCheckbox).toBeDefined();
|
||||
expect(childLabel.style.whiteSpace).toBe("nowrap");
|
||||
expect(childLabel.style.overflow).toBe("hidden");
|
||||
expect(childLabel.style.textOverflow).toBe("ellipsis");
|
||||
expect(childLabel.classList.contains("ahs-settings-folder-label")).toBe(true);
|
||||
expect(childLabel.classList.contains("ahs-settings-fluid-ellipsis")).toBe(true);
|
||||
});
|
||||
|
||||
it("folder tree expand and check refresh only the scope card", async () => {
|
||||
|
|
@ -1648,7 +1700,9 @@ describe("PluginSettingTab", () => {
|
|||
await queryByDataset(tab.containerEl, "settingsCardToggle", "deck").trigger("click");
|
||||
|
||||
const deckBody = queryByDataset(tab.containerEl, "settingsCardBody", "deck");
|
||||
const sectionTitles = queryAllByDataset(deckBody, "deckSectionTitle").map((element) => element.textContent || element.text);
|
||||
const sectionTitles = queryAllByDataset(deckBody, "deckSectionTitle")
|
||||
.map((element) => element.textContent || element.text)
|
||||
.filter((text): text is string => text.length > 0);
|
||||
expect(sectionTitles).toEqual([
|
||||
"1,推荐用「文件夹及文件名」作为「Anki牌组」",
|
||||
"2,可打开「文件级自定义牌组」,作为个性化定制",
|
||||
|
|
@ -1667,18 +1721,13 @@ describe("PluginSettingTab", () => {
|
|||
const exampleRows = queryAllByDataset(exampleBlock, "deckExampleRow");
|
||||
expect(collectTexts(exampleBlock)).toContain("示例:");
|
||||
expect(exampleRows).toHaveLength(2);
|
||||
expect(exampleBlock.style.marginLeft).toBe("32px");
|
||||
expect(exampleRows[0]?.style.color).toBe("var(--text-muted)");
|
||||
expect(exampleRows[0]?.style.fontSize).toBe("var(--font-ui-small)");
|
||||
expect(exampleBlock.classList.contains("ahs-settings-deck-example-block")).toBe(true);
|
||||
expect(exampleRows[0]?.classList.contains("ahs-settings-helper-text")).toBe(true);
|
||||
|
||||
const fallbackHelper = queryByDataset(section3, "deckHelperText", "fallback");
|
||||
const priorityFooter = queryByDataset(deckBody, "deckPriorityFooter", "true");
|
||||
expect(fallbackHelper.style.marginLeft).toBe("32px");
|
||||
expect(fallbackHelper.style.color).toBe("var(--text-muted)");
|
||||
expect(fallbackHelper.style.fontSize).toBe("var(--font-ui-small)");
|
||||
expect(priorityFooter.style.marginLeft).toBe("32px");
|
||||
expect(priorityFooter.style.color).toBe("var(--text-muted)");
|
||||
expect(priorityFooter.style.fontSize).toBe("var(--font-ui-small)");
|
||||
expect(fallbackHelper.classList.contains("ahs-settings-helper-text")).toBe(true);
|
||||
expect(priorityFooter.classList.contains("ahs-settings-helper-text")).toBe(true);
|
||||
expect(collectTexts(section3)).not.toContain("最终牌组优先级:文件级自定义牌组 > 文件夹映射牌组 > 默认牌组。");
|
||||
|
||||
expect(collectOwnedSettingNames(section1)).toEqual(["文件夹映射模式"]);
|
||||
|
|
|
|||
|
|
@ -39,18 +39,12 @@ const SETTINGS_CARD_ORDER = ["card-types", "sync-content", "deck", "scope", "com
|
|||
const VISIBLE_CARD_TYPE_CONFIG_IDS = ["basic", "qa-group", "cloze", "cloze-all"] as const;
|
||||
const SETTINGS_STICKY_CARD_GAP_PX = 8;
|
||||
const SETTINGS_PAGE_HEADER_FALLBACK_HEIGHT_PX = 64;
|
||||
const SETTINGS_PAGE_HEADER_BACKGROUND = "var(--modal-background, var(--background-primary))";
|
||||
const SETTINGS_PAGE_HEADER_HEIGHT_VARIABLE = "--ahs-settings-page-header-height";
|
||||
const SETTINGS_STICKY_CARD_GAP_VARIABLE = "--ahs-settings-sticky-card-gap";
|
||||
const SETTINGS_PAGE_HEADER_HEIGHT_FALLBACK = `${SETTINGS_PAGE_HEADER_FALLBACK_HEIGHT_PX}px`;
|
||||
const SETTINGS_STICKY_CARD_GAP = `${SETTINGS_STICKY_CARD_GAP_PX}px`;
|
||||
const SETTINGS_PAGE_HEADER_HEIGHT_VALUE = `var(${SETTINGS_PAGE_HEADER_HEIGHT_VARIABLE}, ${SETTINGS_PAGE_HEADER_HEIGHT_FALLBACK})`;
|
||||
const SETTINGS_STICKY_CARD_GAP_VALUE = `var(${SETTINGS_STICKY_CARD_GAP_VARIABLE}, ${SETTINGS_STICKY_CARD_GAP})`;
|
||||
const SETTINGS_PAGE_HEADER_PADDING_TOP = "12px";
|
||||
const SETTINGS_PAGE_HEADER_MASK_TOP = "-128px";
|
||||
const SETTINGS_PAGE_HEADER_MASK_SIDE = "-24px";
|
||||
const DECK_HELPER_TEXT_INDENT = "32px";
|
||||
const SETTINGS_ROOT_CLASS = "anki-heading-sync-settings";
|
||||
const SETTINGS_CARD_BODY_HIDDEN_CLASS = "ahs-is-hidden";
|
||||
|
||||
type SettingsCardId = (typeof SETTINGS_CARD_ORDER)[number];
|
||||
type NoteTypeCacheCheckStatus = "idle" | "checking" | "same" | "changed" | "failed";
|
||||
|
|
@ -62,10 +56,23 @@ interface SettingsCardShell {
|
|||
}
|
||||
|
||||
interface PendingTextSave {
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
timer: ReturnType<Window["setTimeout"]>;
|
||||
saveAction: (draftValue: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function addClasses(element: HTMLElement, ...classNames: string[]): void {
|
||||
element.classList.add(...classNames);
|
||||
}
|
||||
|
||||
function setClassEnabled(element: HTMLElement, className: string, enabled: boolean): void {
|
||||
if (enabled) {
|
||||
element.classList.add(className);
|
||||
return;
|
||||
}
|
||||
|
||||
element.classList.remove(className);
|
||||
}
|
||||
|
||||
export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
||||
private readonly noteFieldMappingService = new NoteFieldMappingService();
|
||||
private readonly qaGroupFieldMappingService = new QaGroupFieldMappingService();
|
||||
|
|
@ -126,35 +133,15 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
|
||||
const pageHeaderEl = containerEl.createDiv();
|
||||
pageHeaderEl.dataset.settingsPageHeader = "true";
|
||||
pageHeaderEl.style.position = "sticky";
|
||||
pageHeaderEl.style.top = "0px";
|
||||
pageHeaderEl.style.zIndex = "300";
|
||||
pageHeaderEl.style.width = "100%";
|
||||
pageHeaderEl.style.boxSizing = "border-box";
|
||||
pageHeaderEl.style.paddingTop = SETTINGS_PAGE_HEADER_PADDING_TOP;
|
||||
pageHeaderEl.style.paddingBottom = `calc(${SETTINGS_PAGE_HEADER_PADDING_TOP} + ${SETTINGS_STICKY_CARD_GAP_VALUE})`;
|
||||
pageHeaderEl.style.marginBottom = "0";
|
||||
pageHeaderEl.style.overflow = "visible";
|
||||
pageHeaderEl.style.background = SETTINGS_PAGE_HEADER_BACKGROUND;
|
||||
pageHeaderEl.style.backgroundColor = SETTINGS_PAGE_HEADER_BACKGROUND;
|
||||
pageHeaderEl.style.boxShadow = "none";
|
||||
addClasses(pageHeaderEl, "ahs-settings-page-header");
|
||||
|
||||
const pageHeaderMaskEl = pageHeaderEl.createDiv();
|
||||
pageHeaderMaskEl.dataset.settingsPageHeaderMask = "true";
|
||||
pageHeaderMaskEl.style.position = "absolute";
|
||||
pageHeaderMaskEl.style.top = SETTINGS_PAGE_HEADER_MASK_TOP;
|
||||
pageHeaderMaskEl.style.left = SETTINGS_PAGE_HEADER_MASK_SIDE;
|
||||
pageHeaderMaskEl.style.right = SETTINGS_PAGE_HEADER_MASK_SIDE;
|
||||
pageHeaderMaskEl.style.bottom = "0px";
|
||||
pageHeaderMaskEl.style.zIndex = "0";
|
||||
pageHeaderMaskEl.style.pointerEvents = "none";
|
||||
pageHeaderMaskEl.style.background = SETTINGS_PAGE_HEADER_BACKGROUND;
|
||||
pageHeaderMaskEl.style.backgroundColor = SETTINGS_PAGE_HEADER_BACKGROUND;
|
||||
addClasses(pageHeaderMaskEl, "ahs-settings-page-header-mask");
|
||||
|
||||
const titleEl = pageHeaderEl.createEl("h2", { text: t("settings.pluginTitle") });
|
||||
titleEl.style.margin = "0";
|
||||
titleEl.style.position = "relative";
|
||||
titleEl.style.zIndex = "1";
|
||||
const titleSetting = new Setting(pageHeaderEl).setName(t("settings.pluginTitle")).setHeading();
|
||||
addClasses(titleSetting.settingEl, "ahs-settings-page-title-setting");
|
||||
addClasses(titleSetting.nameEl, "ahs-settings-page-title");
|
||||
|
||||
this.observePageHeaderHeight(pageHeaderEl);
|
||||
|
||||
|
|
@ -182,34 +169,17 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
const cardEl = containerEl.createDiv();
|
||||
cardEl.dataset.settingsCard = cardId;
|
||||
|
||||
const headerEl = cardEl.createEl("button") as HTMLButtonElement;
|
||||
const headerEl = cardEl.createEl("button");
|
||||
headerEl.type = "button";
|
||||
headerEl.dataset.settingsCardToggle = cardId;
|
||||
headerEl.style.display = "flex";
|
||||
headerEl.style.alignItems = "center";
|
||||
headerEl.style.justifyContent = "flex-start";
|
||||
headerEl.style.position = "sticky";
|
||||
headerEl.style.top = SETTINGS_PAGE_HEADER_HEIGHT_VALUE;
|
||||
headerEl.style.zIndex = "200";
|
||||
headerEl.style.width = "100%";
|
||||
headerEl.style.maxWidth = "100%";
|
||||
headerEl.style.boxSizing = "border-box";
|
||||
headerEl.style.padding = "10px 14px";
|
||||
headerEl.style.fontSize = "1.5em";
|
||||
headerEl.style.fontWeight = "600";
|
||||
headerEl.style.background = SETTINGS_PAGE_HEADER_BACKGROUND;
|
||||
headerEl.style.backgroundColor = SETTINGS_PAGE_HEADER_BACKGROUND;
|
||||
headerEl.style.border = "2px solid var(--background-modifier-border)";
|
||||
headerEl.style.borderRadius = "0";
|
||||
headerEl.style.marginBottom = "8px";
|
||||
headerEl.style.textAlign = "left";
|
||||
headerEl.style.boxShadow = "none";
|
||||
addClasses(headerEl, "ahs-settings-card-toggle");
|
||||
headerEl.addEventListener("click", () => {
|
||||
this.toggleCard(cardId);
|
||||
});
|
||||
|
||||
const bodyEl = cardEl.createDiv();
|
||||
bodyEl.dataset.settingsCardBody = cardId;
|
||||
addClasses(bodyEl, "ahs-settings-card-body", SETTINGS_CARD_BODY_HIDDEN_CLASS);
|
||||
|
||||
this.cardShells.set(cardId, {
|
||||
cardEl,
|
||||
|
|
@ -238,7 +208,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
const expanded = this.expandedCardIds.has(cardId);
|
||||
shell.headerEl.textContent = `${expanded ? "▾" : "▸"} ${this.getCardTitle(cardId)}`;
|
||||
shell.headerEl.setAttr("aria-expanded", String(expanded));
|
||||
shell.bodyEl.style.display = expanded ? "block" : "none";
|
||||
setClassEnabled(shell.bodyEl, SETTINGS_CARD_BODY_HIDDEN_CLASS, !expanded);
|
||||
shell.bodyEl.empty();
|
||||
|
||||
if (!expanded) {
|
||||
|
|
@ -301,10 +271,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
this.maybeStartNoteTypeCacheCheck();
|
||||
|
||||
const actionRow = containerEl.createDiv();
|
||||
actionRow.style.display = "flex";
|
||||
actionRow.style.flexDirection = "column";
|
||||
actionRow.style.alignItems = "flex-start";
|
||||
actionRow.style.gap = "8px";
|
||||
addClasses(actionRow, "ahs-settings-action-row");
|
||||
const loadButton = new ButtonComponent(actionRow)
|
||||
.setButtonText(this.ankiConfigLoading
|
||||
? t("settings.cards.cardTypes.loadAnki.loading")
|
||||
|
|
@ -313,15 +280,13 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
.setDisabled(this.ankiConfigLoading)
|
||||
.onClick(() => this.loadAnkiCardTypeConfig());
|
||||
loadButton.buttonEl.dataset.cardTypesRefresh = "true";
|
||||
actionRow.createEl("span", {
|
||||
actionRow.createSpan({
|
||||
text: `${t("settings.cards.cardTypes.statusLabel")}${renderUserFacingMessage(this.getCardTypeStatusMessage())}`,
|
||||
});
|
||||
|
||||
const cardTypeList = containerEl.createDiv();
|
||||
cardTypeList.dataset.cardTypeList = "true";
|
||||
cardTypeList.style.display = "flex";
|
||||
cardTypeList.style.flexDirection = "column";
|
||||
cardTypeList.style.gap = "14px";
|
||||
addClasses(cardTypeList, "ahs-settings-card-list");
|
||||
|
||||
for (const configId of VISIBLE_CARD_TYPE_CONFIG_IDS) {
|
||||
this.renderCardTypeBlock(cardTypeList, configId);
|
||||
|
|
@ -332,49 +297,43 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
const config = this.plugin.settings.cardTypeConfigs[configId];
|
||||
const blockEl = containerEl.createDiv();
|
||||
blockEl.dataset.cardTypeConfig = configId;
|
||||
blockEl.style.display = "flex";
|
||||
blockEl.style.flexDirection = "column";
|
||||
blockEl.style.gap = "8px";
|
||||
blockEl.style.padding = "12px";
|
||||
blockEl.style.border = "1px solid var(--background-modifier-border)";
|
||||
blockEl.style.borderRadius = "8px";
|
||||
addClasses(blockEl, "ahs-settings-card-type-block");
|
||||
|
||||
const headerRow = blockEl.createDiv();
|
||||
headerRow.style.display = "flex";
|
||||
headerRow.style.alignItems = "center";
|
||||
headerRow.style.gap = "10px";
|
||||
addClasses(headerRow, "ahs-settings-card-type-header");
|
||||
|
||||
const enabledCheckbox = headerRow.createEl("input") as HTMLInputElement;
|
||||
const enabledCheckbox = headerRow.createEl("input");
|
||||
enabledCheckbox.type = "checkbox";
|
||||
enabledCheckbox.checked = config.enabled;
|
||||
enabledCheckbox.dataset.cardTypeEnabled = configId;
|
||||
enabledCheckbox.addEventListener("change", () => this.saveCardTypeConfig(configId, { enabled: enabledCheckbox.checked }));
|
||||
enabledCheckbox.addEventListener("change", () => {
|
||||
void this.saveCardTypeConfig(configId, { enabled: enabledCheckbox.checked });
|
||||
});
|
||||
|
||||
const titleEl = headerRow.createEl("strong", { text: this.getCardTypeLabel(configId) });
|
||||
titleEl.style.fontSize = "var(--font-ui-medium)";
|
||||
addClasses(titleEl, "ahs-settings-card-type-title");
|
||||
|
||||
const recognitionRow = blockEl.createDiv();
|
||||
recognitionRow.style.display = "flex";
|
||||
recognitionRow.style.flexWrap = "wrap";
|
||||
recognitionRow.style.alignItems = "center";
|
||||
recognitionRow.style.gap = "10px 14px";
|
||||
addClasses(recognitionRow, "ahs-settings-recognition-row");
|
||||
|
||||
const headingGroup = this.createInlineControlGroup(recognitionRow, t("settings.cards.cardTypes.labels.headingLevel"));
|
||||
const headingSelect = this.createSelect(headingGroup, `card-type-heading:${configId}`);
|
||||
headingSelect.dataset.cardTypeHeading = configId;
|
||||
headingSelect.style.width = "88px";
|
||||
addClasses(headingSelect, "ahs-settings-heading-select");
|
||||
for (let level = 1; level <= 6; level += 1) {
|
||||
this.appendOption(headingSelect, String(level), `H${level}`);
|
||||
}
|
||||
headingSelect.value = String(config.headingLevel);
|
||||
headingSelect.addEventListener("change", () => this.saveCardTypeConfig(configId, { headingLevel: Number(headingSelect.value) }));
|
||||
headingSelect.addEventListener("change", () => {
|
||||
void this.saveCardTypeConfig(configId, { headingLevel: Number(headingSelect.value) });
|
||||
});
|
||||
|
||||
const markerGroup = this.createInlineControlGroup(recognitionRow, t("settings.cards.cardTypes.labels.extraMarker"));
|
||||
const markerInput = markerGroup.createEl("input") as HTMLInputElement;
|
||||
const markerInput = markerGroup.createEl("input");
|
||||
markerInput.type = "text";
|
||||
markerInput.dataset.cardTypeMarker = configId;
|
||||
markerInput.placeholder = t("settings.cards.cardTypes.markerPlaceholder");
|
||||
markerInput.style.width = "220px";
|
||||
addClasses(markerInput, "ahs-settings-marker-input");
|
||||
markerInput.value = this.getDraftValue(`card-type-marker:${configId}`, config.extraMarker);
|
||||
markerInput.addEventListener("input", () => {
|
||||
this.scheduleDebouncedTextSave(`card-type-marker:${configId}`, markerInput.value, async (draftValue) => {
|
||||
|
|
@ -390,19 +349,12 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
text: t("settings.cards.cardTypes.sharedClozeMappingHint"),
|
||||
});
|
||||
sharedHintEl.dataset.cardTypeSharedHint = configId;
|
||||
sharedHintEl.style.margin = "0";
|
||||
sharedHintEl.style.color = "var(--text-muted)";
|
||||
addClasses(sharedHintEl, "ahs-settings-muted-text");
|
||||
return;
|
||||
}
|
||||
|
||||
const ankiRow = blockEl.createDiv();
|
||||
ankiRow.style.display = "grid";
|
||||
ankiRow.style.gridTemplateColumns = "minmax(0, 1.1fr) minmax(0, 0.75fr) minmax(220px, 0.95fr)";
|
||||
ankiRow.style.alignItems = "center";
|
||||
ankiRow.style.columnGap = "16px";
|
||||
ankiRow.style.rowGap = "8px";
|
||||
ankiRow.style.overflow = "visible";
|
||||
ankiRow.style.width = "100%";
|
||||
addClasses(ankiRow, "ahs-settings-grid-row");
|
||||
|
||||
const noteTypeGroup = this.createGridControlSlot(ankiRow, t("settings.cards.cardTypes.labels.noteType"));
|
||||
const noteTypeSelect = this.createSelect(noteTypeGroup, `card-type-note-type:${configId}`);
|
||||
|
|
@ -413,7 +365,9 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
}
|
||||
noteTypeSelect.value = config.noteType;
|
||||
noteTypeSelect.disabled = this.ankiConfigLoading;
|
||||
noteTypeSelect.addEventListener("change", () => this.saveCardTypeConfig(configId, { noteType: noteTypeSelect.value }));
|
||||
noteTypeSelect.addEventListener("change", () => {
|
||||
void this.saveCardTypeConfig(configId, { noteType: noteTypeSelect.value });
|
||||
});
|
||||
|
||||
this.renderCardTypeFieldControls(ankiRow, configId);
|
||||
}
|
||||
|
|
@ -432,7 +386,9 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
mainFieldSelect.dataset.cardTypeQuestionField = configId;
|
||||
this.applyFluidEllipsis(mainFieldSelect);
|
||||
this.populateFieldSelect(mainFieldSelect, clozeMapping?.loadedFieldNames ?? [], clozeMapping?.mainField);
|
||||
mainFieldSelect.addEventListener("change", () => this.saveFieldMapping(configId, { mainField: mainFieldSelect.value || undefined }));
|
||||
mainFieldSelect.addEventListener("change", () => {
|
||||
void this.saveFieldMapping(configId, { mainField: mainFieldSelect.value || undefined });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -442,14 +398,18 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
titleFieldSelect.dataset.cardTypeQuestionField = configId;
|
||||
this.applyFluidEllipsis(titleFieldSelect);
|
||||
this.populateFieldSelect(titleFieldSelect, basicLikeMapping?.loadedFieldNames ?? [], basicLikeMapping?.titleField);
|
||||
titleFieldSelect.addEventListener("change", () => this.saveFieldMapping(configId, { titleField: titleFieldSelect.value || undefined }));
|
||||
titleFieldSelect.addEventListener("change", () => {
|
||||
void this.saveFieldMapping(configId, { titleField: titleFieldSelect.value || undefined });
|
||||
});
|
||||
|
||||
const bodyFieldGroup = this.createGridControlSlot(containerEl, t("settings.cards.cardTypes.labels.answerField"));
|
||||
const bodyFieldSelect = this.createFieldSelect(bodyFieldGroup, `card-type-answer-field:${configId}`);
|
||||
bodyFieldSelect.dataset.cardTypeAnswerField = configId;
|
||||
this.applyFluidEllipsis(bodyFieldSelect);
|
||||
this.populateFieldSelect(bodyFieldSelect, basicLikeMapping?.loadedFieldNames ?? [], basicLikeMapping?.bodyField);
|
||||
bodyFieldSelect.addEventListener("change", () => this.saveFieldMapping(configId, { bodyField: bodyFieldSelect.value || undefined }));
|
||||
bodyFieldSelect.addEventListener("change", () => {
|
||||
void this.saveFieldMapping(configId, { bodyField: bodyFieldSelect.value || undefined });
|
||||
});
|
||||
}
|
||||
|
||||
private renderQaGroupFieldControls(containerEl: HTMLElement, configId: Extract<CardTypeConfigId, "qa-group">): void {
|
||||
|
|
@ -465,13 +425,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
this.populateFieldSelect(titleFieldSelect, qaGroupState.mapping.loadedFieldNames, qaGroupState.mapping.titleField);
|
||||
const qaGroupPairRow = containerEl.createDiv();
|
||||
qaGroupPairRow.dataset.qaGroupPairRow = configId;
|
||||
qaGroupPairRow.style.gridColumn = "1 / -1";
|
||||
qaGroupPairRow.style.display = "grid";
|
||||
qaGroupPairRow.style.gridTemplateColumns = "minmax(0, 1fr) minmax(0, 1fr) max-content";
|
||||
qaGroupPairRow.style.alignItems = "center";
|
||||
qaGroupPairRow.style.columnGap = "16px";
|
||||
qaGroupPairRow.style.rowGap = "8px";
|
||||
qaGroupPairRow.style.width = "100%";
|
||||
addClasses(qaGroupPairRow, "ahs-settings-qa-group-pair-row");
|
||||
|
||||
const firstQuestionFieldGroup = this.createGridControlSlot(qaGroupPairRow, t("settings.cards.cardTypes.labels.qaGroupFirstQuestionField"));
|
||||
const firstQuestionFieldSelect = this.createFieldSelect(firstQuestionFieldGroup, `card-type-qa-group-first-question-field:${configId}`);
|
||||
|
|
@ -485,56 +439,50 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
this.applyFluidEllipsis(firstAnswerFieldSelect);
|
||||
this.populateFieldSelect(firstAnswerFieldSelect, qaGroupState.mapping.loadedFieldNames, selectedFields?.firstAnswerField);
|
||||
|
||||
const saveSelection = () => this.saveQaGroupFieldSelection(configId, {
|
||||
titleField: titleFieldSelect.value || undefined,
|
||||
firstQuestionField: firstQuestionFieldSelect.value || undefined,
|
||||
firstAnswerField: firstAnswerFieldSelect.value || undefined,
|
||||
});
|
||||
const saveSelection = () => {
|
||||
void this.saveQaGroupFieldSelection(configId, {
|
||||
titleField: titleFieldSelect.value || undefined,
|
||||
firstQuestionField: firstQuestionFieldSelect.value || undefined,
|
||||
firstAnswerField: firstAnswerFieldSelect.value || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
titleFieldSelect.addEventListener("change", saveSelection);
|
||||
firstQuestionFieldSelect.addEventListener("change", saveSelection);
|
||||
firstAnswerFieldSelect.addEventListener("change", saveSelection);
|
||||
|
||||
const slotSummaryEl = qaGroupPairRow.createEl("span", {
|
||||
const slotSummaryEl = qaGroupPairRow.createSpan({
|
||||
text: t("settings.cards.cardTypes.qaGroup.configuredSlots", {
|
||||
count: qaGroupState.mapping.slots.length,
|
||||
}),
|
||||
});
|
||||
slotSummaryEl.dataset.qaGroupSlotSummary = configId;
|
||||
slotSummaryEl.style.whiteSpace = "nowrap";
|
||||
slotSummaryEl.style.justifySelf = "start";
|
||||
addClasses(slotSummaryEl, "ahs-settings-nowrap-start");
|
||||
this.applyFluidEllipsis(slotSummaryEl);
|
||||
} else {
|
||||
titleFieldGroup.createEl("span", {
|
||||
titleFieldGroup.createSpan({
|
||||
text: selectedModelName.length > 0 ? t("settings.cards.cardTypes.qaGroup.unavailable") : t("settings.cards.cardTypes.qaGroup.noModel"),
|
||||
}).dataset.qaGroupTitleField = configId;
|
||||
|
||||
const qaGroupPairRow = containerEl.createDiv();
|
||||
qaGroupPairRow.dataset.qaGroupPairRow = configId;
|
||||
qaGroupPairRow.style.gridColumn = "1 / -1";
|
||||
qaGroupPairRow.style.display = "grid";
|
||||
qaGroupPairRow.style.gridTemplateColumns = "minmax(0, 1fr) minmax(0, 1fr) max-content";
|
||||
qaGroupPairRow.style.alignItems = "center";
|
||||
qaGroupPairRow.style.columnGap = "16px";
|
||||
qaGroupPairRow.style.rowGap = "8px";
|
||||
qaGroupPairRow.style.width = "100%";
|
||||
addClasses(qaGroupPairRow, "ahs-settings-qa-group-pair-row");
|
||||
|
||||
const firstQuestionFieldGroup = this.createGridControlSlot(qaGroupPairRow, t("settings.cards.cardTypes.labels.qaGroupFirstQuestionField"));
|
||||
firstQuestionFieldGroup.createEl("span", {
|
||||
firstQuestionFieldGroup.createSpan({
|
||||
text: selectedModelName.length > 0 ? t("settings.cards.cardTypes.qaGroup.unavailable") : t("settings.cards.cardTypes.qaGroup.noModel"),
|
||||
}).dataset.qaGroupFirstQuestionField = configId;
|
||||
|
||||
const firstAnswerFieldGroup = this.createGridControlSlot(qaGroupPairRow, t("settings.cards.cardTypes.labels.qaGroupFirstAnswerField"));
|
||||
firstAnswerFieldGroup.createEl("span", {
|
||||
firstAnswerFieldGroup.createSpan({
|
||||
text: selectedModelName.length > 0 ? t("settings.cards.cardTypes.qaGroup.unavailable") : t("settings.cards.cardTypes.qaGroup.noModel"),
|
||||
}).dataset.qaGroupFirstAnswerField = configId;
|
||||
|
||||
const slotSummaryEl = qaGroupPairRow.createEl("span", {
|
||||
const slotSummaryEl = qaGroupPairRow.createSpan({
|
||||
text: selectedModelName.length > 0 ? t("settings.cards.cardTypes.qaGroup.unavailable") : t("settings.cards.cardTypes.qaGroup.noModel"),
|
||||
});
|
||||
slotSummaryEl.dataset.qaGroupSlotSummary = configId;
|
||||
slotSummaryEl.style.whiteSpace = "nowrap";
|
||||
slotSummaryEl.style.justifySelf = "start";
|
||||
addClasses(slotSummaryEl, "ahs-settings-nowrap-start");
|
||||
this.applyFluidEllipsis(slotSummaryEl);
|
||||
}
|
||||
|
||||
|
|
@ -543,7 +491,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
text: renderUserFacingMessage(qaGroupState.error),
|
||||
});
|
||||
errorEl.dataset.qaGroupError = configId;
|
||||
errorEl.style.gridColumn = "1 / -1";
|
||||
addClasses(errorEl, "ahs-settings-grid-full");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -553,28 +501,25 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
|
||||
const warningsContainer = containerEl.createDiv();
|
||||
warningsContainer.dataset.qaGroupWarnings = configId;
|
||||
warningsContainer.style.gridColumn = "1 / -1";
|
||||
warningsContainer.style.display = "flex";
|
||||
warningsContainer.style.flexDirection = "column";
|
||||
warningsContainer.style.gap = "6px";
|
||||
addClasses(warningsContainer, "ahs-settings-grid-full", "ahs-settings-warnings");
|
||||
warningsContainer.createEl("strong", { text: t("settings.cards.cardTypes.labels.qaGroupWarnings") });
|
||||
|
||||
for (const warning of qaGroupState.mapping.warnings) {
|
||||
warningsContainer.createEl("div", {
|
||||
warningsContainer.createDiv({
|
||||
text: this.renderQaGroupWarning(warning),
|
||||
}).dataset.qaGroupWarning = configId;
|
||||
}
|
||||
|
||||
const acceptLabel = warningsContainer.createEl("label");
|
||||
acceptLabel.style.display = "inline-flex";
|
||||
acceptLabel.style.alignItems = "center";
|
||||
acceptLabel.style.gap = "8px";
|
||||
const acceptCheckbox = acceptLabel.createEl("input") as HTMLInputElement;
|
||||
addClasses(acceptLabel, "ahs-settings-inline-check");
|
||||
const acceptCheckbox = acceptLabel.createEl("input");
|
||||
acceptCheckbox.type = "checkbox";
|
||||
acceptCheckbox.checked = areQaGroupWarningsAccepted(qaGroupState.mapping.warnings, qaGroupState.mapping.acceptedWarnings);
|
||||
acceptCheckbox.dataset.qaGroupWarningAccept = configId;
|
||||
acceptCheckbox.addEventListener("change", () => this.saveQaGroupWarningAcceptance(configId, acceptCheckbox.checked));
|
||||
acceptLabel.createEl("span", { text: t("settings.cards.cardTypes.qaGroup.acceptWarnings") });
|
||||
acceptCheckbox.addEventListener("change", () => {
|
||||
void this.saveQaGroupWarningAcceptance(configId, acceptCheckbox.checked);
|
||||
});
|
||||
acceptLabel.createSpan({ text: t("settings.cards.cardTypes.qaGroup.acceptWarnings") });
|
||||
}
|
||||
|
||||
private renderSyncContentCard(containerEl: HTMLElement): void {
|
||||
|
|
@ -663,14 +608,8 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
private createSyncContentSection(containerEl: HTMLElement, sectionId: string, title: string): HTMLElement {
|
||||
const sectionEl = containerEl.createDiv();
|
||||
sectionEl.dataset.syncContentSection = sectionId;
|
||||
sectionEl.style.display = "flex";
|
||||
sectionEl.style.flexDirection = "column";
|
||||
sectionEl.style.gap = "8px";
|
||||
sectionEl.style.marginTop = "16px";
|
||||
|
||||
const titleEl = sectionEl.createEl("h4", { text: title });
|
||||
titleEl.dataset.syncContentSectionTitle = sectionId;
|
||||
titleEl.style.margin = "0";
|
||||
addClasses(sectionEl, "ahs-settings-section");
|
||||
this.createSectionHeading(sectionEl, "syncContentSectionTitle", sectionId, title);
|
||||
|
||||
return sectionEl;
|
||||
}
|
||||
|
|
@ -697,8 +636,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
if (!isRunScopeConfigured(this.plugin.settings.scopeMode, this.plugin.settings.includeFolders)) {
|
||||
const warningEl = containerEl.createEl("p", { text: t("settings.scope.unconfiguredWarning") });
|
||||
warningEl.dataset.scopeWarning = "unconfigured";
|
||||
warningEl.style.color = "var(--text-warning)";
|
||||
warningEl.style.fontWeight = "600";
|
||||
addClasses(warningEl, "ahs-settings-warning-text");
|
||||
}
|
||||
|
||||
if (this.plugin.settings.scopeMode === "all") {
|
||||
|
|
@ -729,9 +667,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
const selectionTree = buildFolderTreeSelection(this.folderTree, selectedFolders);
|
||||
const treeContainer = containerEl.createDiv();
|
||||
treeContainer.dataset.scopeTree = "true";
|
||||
treeContainer.style.display = "flex";
|
||||
treeContainer.style.flexDirection = "column";
|
||||
treeContainer.style.gap = "2px";
|
||||
addClasses(treeContainer, "ahs-settings-folder-tree");
|
||||
|
||||
for (const node of selectionTree) {
|
||||
this.renderFolderNode(treeContainer, node, this.plugin.settings.scopeMode, 0);
|
||||
|
|
@ -858,14 +794,10 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
private renderDeckExampleBlock(containerEl: HTMLElement): void {
|
||||
const exampleBlockEl = containerEl.createDiv();
|
||||
exampleBlockEl.dataset.deckExampleBlock = "true";
|
||||
exampleBlockEl.style.display = "flex";
|
||||
exampleBlockEl.style.flexDirection = "column";
|
||||
exampleBlockEl.style.gap = "4px";
|
||||
exampleBlockEl.style.marginTop = "2px";
|
||||
exampleBlockEl.style.marginLeft = DECK_HELPER_TEXT_INDENT;
|
||||
addClasses(exampleBlockEl, "ahs-settings-deck-example-block");
|
||||
|
||||
const titleEl = exampleBlockEl.createEl("strong", { text: t("settings.deck.examplesTitle") });
|
||||
titleEl.style.fontSize = "var(--font-ui-small)";
|
||||
addClasses(titleEl, "ahs-settings-small-title");
|
||||
|
||||
for (const exampleText of [
|
||||
t("settings.deck.folderExample"),
|
||||
|
|
@ -873,40 +805,28 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
]) {
|
||||
const rowEl = exampleBlockEl.createDiv({ text: exampleText });
|
||||
rowEl.dataset.deckExampleRow = "true";
|
||||
rowEl.style.color = "var(--text-muted)";
|
||||
rowEl.style.fontSize = "var(--font-ui-small)";
|
||||
rowEl.style.lineHeight = "1.4";
|
||||
addClasses(rowEl, "ahs-settings-helper-text");
|
||||
}
|
||||
}
|
||||
|
||||
private createDeckHelperText(containerEl: HTMLElement, text: string, helperId: string): HTMLElement {
|
||||
const helperEl = containerEl.createDiv({ text });
|
||||
helperEl.dataset.deckHelperText = helperId;
|
||||
helperEl.style.color = "var(--text-muted)";
|
||||
helperEl.style.fontSize = "var(--font-ui-small)";
|
||||
helperEl.style.lineHeight = "1.4";
|
||||
helperEl.style.marginTop = "2px";
|
||||
helperEl.style.marginLeft = DECK_HELPER_TEXT_INDENT;
|
||||
addClasses(helperEl, "ahs-settings-helper-text");
|
||||
return helperEl;
|
||||
}
|
||||
|
||||
private createDeckPriorityFooter(containerEl: HTMLElement): void {
|
||||
const footerEl = this.createDeckHelperText(containerEl, t("settings.deck.priorityDesc"), "priority");
|
||||
footerEl.dataset.deckPriorityFooter = "true";
|
||||
footerEl.style.marginTop = "12px";
|
||||
addClasses(footerEl, "ahs-settings-helper-text-priority");
|
||||
}
|
||||
|
||||
private createDeckSection(containerEl: HTMLElement, sectionId: string, title: string): HTMLElement {
|
||||
const sectionEl = containerEl.createDiv();
|
||||
sectionEl.dataset.deckSection = sectionId;
|
||||
sectionEl.style.display = "flex";
|
||||
sectionEl.style.flexDirection = "column";
|
||||
sectionEl.style.gap = "8px";
|
||||
sectionEl.style.marginTop = "16px";
|
||||
|
||||
const titleEl = sectionEl.createEl("h4", { text: title });
|
||||
titleEl.dataset.deckSectionTitle = sectionId;
|
||||
titleEl.style.margin = "0";
|
||||
addClasses(sectionEl, "ahs-settings-section");
|
||||
this.createSectionHeading(sectionEl, "deckSectionTitle", sectionId, title);
|
||||
|
||||
return sectionEl;
|
||||
}
|
||||
|
|
@ -1688,7 +1608,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
});
|
||||
}
|
||||
|
||||
private async refreshFolderTree(): Promise<void> {
|
||||
private refreshFolderTree(): void {
|
||||
this.expandedFolderPaths.clear();
|
||||
this.ensureFolderTreeLoaded(true);
|
||||
this.renderCard("scope");
|
||||
|
|
@ -1714,37 +1634,19 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
private renderFolderNode(containerEl: HTMLElement, node: FolderTreeSelectionNode, scopeMode: ScopeMode, depth: number): void {
|
||||
const row = containerEl.createDiv();
|
||||
row.dataset.folderRow = node.path;
|
||||
row.style.display = "grid";
|
||||
row.style.gridTemplateColumns = "24px 24px minmax(0, 1fr)";
|
||||
row.style.alignItems = "center";
|
||||
row.style.columnGap = "0px";
|
||||
row.style.minHeight = "28px";
|
||||
row.style.marginLeft = `${depth * 18}px`;
|
||||
row.style.width = "100%";
|
||||
row.style.boxSizing = "border-box";
|
||||
addClasses(row, "ahs-settings-folder-row");
|
||||
row.style.setProperty("--ahs-folder-depth-indent", `${depth * 18}px`);
|
||||
|
||||
const hasChildren = node.children.length > 0;
|
||||
const expanded = hasChildren && this.expandedFolderPaths.has(node.path);
|
||||
const toggleControl = row.createEl(hasChildren ? "button" : "span");
|
||||
toggleControl.dataset.folderToggle = node.path;
|
||||
toggleControl.textContent = hasChildren ? (expanded ? "▾" : "▸") : "";
|
||||
toggleControl.style.width = "24px";
|
||||
toggleControl.style.height = "24px";
|
||||
toggleControl.style.display = "inline-flex";
|
||||
toggleControl.style.alignItems = "center";
|
||||
toggleControl.style.justifyContent = "center";
|
||||
toggleControl.style.padding = "0";
|
||||
toggleControl.style.margin = "0";
|
||||
toggleControl.style.border = "0";
|
||||
toggleControl.style.background = "transparent";
|
||||
toggleControl.style.boxShadow = "none";
|
||||
toggleControl.style.fontSize = "2em";
|
||||
toggleControl.style.lineHeight = "1";
|
||||
toggleControl.style.color = "var(--text-muted)";
|
||||
addClasses(toggleControl, "ahs-settings-folder-toggle");
|
||||
if (hasChildren) {
|
||||
addClasses(toggleControl, "ahs-settings-folder-toggle-button");
|
||||
toggleControl.setAttr("aria-label", expanded ? t("settings.scope.collapseFolder", { name: node.name }) : t("settings.scope.expandFolder", { name: node.name }));
|
||||
toggleControl.setAttr("title", expanded ? t("settings.scope.collapseFolder", { name: node.name }) : t("settings.scope.expandFolder", { name: node.name }));
|
||||
toggleControl.style.cursor = "pointer";
|
||||
toggleControl.addEventListener("click", () => {
|
||||
if (this.expandedFolderPaths.has(node.path)) {
|
||||
this.expandedFolderPaths.delete(node.path);
|
||||
|
|
@ -1754,25 +1656,24 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
|
||||
this.renderCard("scope");
|
||||
});
|
||||
} else {
|
||||
addClasses(toggleControl, "ahs-settings-folder-toggle-spacer");
|
||||
}
|
||||
|
||||
const checkbox = row.createEl("input") as HTMLInputElement;
|
||||
const checkbox = row.createEl("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.checked = node.checked;
|
||||
checkbox.indeterminate = node.indeterminate;
|
||||
checkbox.dataset.folderPath = node.path;
|
||||
checkbox.style.margin = "0";
|
||||
checkbox.style.justifySelf = "center";
|
||||
addClasses(checkbox, "ahs-settings-folder-checkbox");
|
||||
checkbox.addEventListener("change", () => {
|
||||
void this.updateFolderSelection(scopeMode, node.path, checkbox.checked);
|
||||
});
|
||||
|
||||
const labelEl = row.createEl("span", { text: node.name });
|
||||
const labelEl = row.createSpan({ text: node.name });
|
||||
labelEl.dataset.folderPathLabel = node.path;
|
||||
labelEl.style.minWidth = "0";
|
||||
labelEl.style.overflow = "hidden";
|
||||
labelEl.style.textOverflow = "ellipsis";
|
||||
labelEl.style.whiteSpace = "nowrap";
|
||||
addClasses(labelEl, "ahs-settings-folder-label");
|
||||
this.applyFluidEllipsis(labelEl);
|
||||
|
||||
if (!hasChildren || !expanded) {
|
||||
return;
|
||||
|
|
@ -1780,9 +1681,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
|
||||
const childrenContainer = containerEl.createDiv();
|
||||
childrenContainer.dataset.folderChildren = node.path;
|
||||
childrenContainer.style.display = "flex";
|
||||
childrenContainer.style.flexDirection = "column";
|
||||
childrenContainer.style.gap = "2px";
|
||||
addClasses(childrenContainer, "ahs-settings-folder-children");
|
||||
for (const child of node.children) {
|
||||
this.renderFolderNode(childrenContainer, child, scopeMode, depth + 1);
|
||||
}
|
||||
|
|
@ -1811,33 +1710,23 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
|
||||
private createInlineControlGroup(containerEl: HTMLElement, label: string): HTMLElement {
|
||||
const groupEl = containerEl.createEl("label");
|
||||
groupEl.style.display = "inline-flex";
|
||||
groupEl.style.alignItems = "center";
|
||||
groupEl.style.gap = "6px";
|
||||
groupEl.style.flexWrap = "nowrap";
|
||||
addClasses(groupEl, "ahs-settings-inline-control-group");
|
||||
|
||||
const labelEl = groupEl.createEl("span", { text: label });
|
||||
labelEl.style.whiteSpace = "nowrap";
|
||||
const labelEl = groupEl.createSpan({ text: label });
|
||||
addClasses(labelEl, "ahs-settings-inline-control-label");
|
||||
|
||||
return groupEl;
|
||||
}
|
||||
|
||||
private createGridControlSlot(containerEl: HTMLElement, label: string): HTMLElement {
|
||||
const groupEl = containerEl.createEl("label");
|
||||
groupEl.style.display = "flex";
|
||||
groupEl.style.alignItems = "center";
|
||||
groupEl.style.gap = "8px";
|
||||
groupEl.style.minWidth = "0";
|
||||
groupEl.style.overflow = "visible";
|
||||
addClasses(groupEl, "ahs-settings-grid-control-slot");
|
||||
|
||||
const labelEl = groupEl.createEl("span", { text: label });
|
||||
labelEl.style.whiteSpace = "nowrap";
|
||||
labelEl.style.flex = "0 0 auto";
|
||||
const labelEl = groupEl.createSpan({ text: label });
|
||||
addClasses(labelEl, "ahs-settings-grid-control-label");
|
||||
|
||||
const slotEl = groupEl.createDiv();
|
||||
slotEl.style.flex = "1 1 0";
|
||||
slotEl.style.minWidth = "0";
|
||||
slotEl.style.overflow = "visible";
|
||||
addClasses(slotEl, "ahs-settings-grid-control-content");
|
||||
|
||||
return slotEl;
|
||||
}
|
||||
|
|
@ -1845,25 +1734,30 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
private applyFluidEllipsis(element: HTMLElement): void {
|
||||
const tagName = (element.tagName || (element as unknown as { tag?: string }).tag || "").toLowerCase();
|
||||
const isFieldControl = tagName === "select" || tagName === "input";
|
||||
element.style.boxSizing = "border-box";
|
||||
element.style.width = isFieldControl ? "calc(100% - 2px)" : "100%";
|
||||
element.style.maxWidth = isFieldControl ? "calc(100% - 2px)" : "100%";
|
||||
element.style.minWidth = "0";
|
||||
element.style.margin = isFieldControl ? "1px" : "0";
|
||||
element.style.overflow = isFieldControl ? "visible" : "hidden";
|
||||
element.style.textOverflow = "ellipsis";
|
||||
element.style.whiteSpace = "nowrap";
|
||||
addClasses(element, "ahs-settings-fluid-ellipsis");
|
||||
if (isFieldControl) {
|
||||
addClasses(element, "ahs-settings-fluid-control");
|
||||
}
|
||||
}
|
||||
|
||||
private createSectionHeading(containerEl: HTMLElement, datasetKey: string, sectionId: string, title: string): void {
|
||||
const headingContainer = containerEl.createDiv();
|
||||
const headingSetting = new Setting(headingContainer).setName(title).setHeading();
|
||||
headingSetting.settingEl.dataset[datasetKey] = sectionId;
|
||||
headingSetting.nameEl.dataset[datasetKey] = sectionId;
|
||||
addClasses(headingSetting.settingEl, "ahs-settings-section-heading");
|
||||
}
|
||||
|
||||
private scheduleDebouncedTextSave(key: string, value: string, saveAction: (draftValue: string) => Promise<void>): void {
|
||||
const activeWindow = window.activeWindow;
|
||||
this.textDraftValues.set(key, value);
|
||||
|
||||
const pendingTimer = this.debouncedTextSaves.get(key);
|
||||
if (pendingTimer) {
|
||||
globalThis.clearTimeout(pendingTimer.timer);
|
||||
activeWindow.clearTimeout(pendingTimer.timer);
|
||||
}
|
||||
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
const timer = activeWindow.setTimeout(() => {
|
||||
void this.flushDebouncedTextSave(key);
|
||||
}, TEXT_SAVE_DEBOUNCE_MS);
|
||||
|
||||
|
|
@ -1871,12 +1765,13 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
}
|
||||
|
||||
private async flushDebouncedTextSave(key: string): Promise<void> {
|
||||
const activeWindow = window.activeWindow;
|
||||
const pendingSave = this.debouncedTextSaves.get(key);
|
||||
if (!pendingSave) {
|
||||
return;
|
||||
}
|
||||
|
||||
globalThis.clearTimeout(pendingSave.timer);
|
||||
activeWindow.clearTimeout(pendingSave.timer);
|
||||
this.debouncedTextSaves.delete(key);
|
||||
await pendingSave.saveAction(this.textDraftValues.get(key) ?? "");
|
||||
}
|
||||
|
|
@ -1891,7 +1786,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
}
|
||||
|
||||
private createSelect(containerEl: HTMLElement, datasetKey: string): HTMLSelectElement {
|
||||
const selectEl = containerEl.createEl("select") as HTMLSelectElement;
|
||||
const selectEl = containerEl.createEl("select");
|
||||
selectEl.dataset.selectKey = datasetKey;
|
||||
return selectEl;
|
||||
}
|
||||
|
|
@ -1901,7 +1796,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
}
|
||||
|
||||
private appendOption(selectEl: HTMLSelectElement, value: string, label: string): void {
|
||||
const optionEl = selectEl.createEl("option", { text: label }) as HTMLOptionElement;
|
||||
const optionEl = selectEl.createEl("option", { text: label });
|
||||
optionEl.value = value;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,13 +17,14 @@ export class InMemoryPluginStateRepository implements PluginStateRepository {
|
|||
|
||||
constructor(private state: PluginState = createEmptyPluginState()) {}
|
||||
|
||||
async load(): Promise<PluginState> {
|
||||
return this.state;
|
||||
load(): Promise<PluginState> {
|
||||
return Promise.resolve(this.state);
|
||||
}
|
||||
|
||||
async save(state: PluginState): Promise<void> {
|
||||
save(state: PluginState): Promise<void> {
|
||||
this.state = state;
|
||||
this.savedState = state;
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -43,74 +44,49 @@ export class FakeManualSyncVaultGateway implements ManualSyncVaultGateway {
|
|||
}
|
||||
}
|
||||
|
||||
async listMarkdownFileRefs() {
|
||||
return Array.from(this.files.entries()).map(([path, file]) => ({
|
||||
listMarkdownFileRefs(): Promise<Array<{ path: string; basename: string; mtime: number; size: number }>> {
|
||||
return Promise.resolve(Array.from(this.files.entries()).map(([path, file]) => ({
|
||||
path,
|
||||
basename: path.split("/").pop()?.replace(/\.md$/i, "") ?? path,
|
||||
mtime: file.mtime,
|
||||
size: file.size,
|
||||
}));
|
||||
})));
|
||||
}
|
||||
|
||||
async listFolderTree(): Promise<FolderTreeNode[]> {
|
||||
const rootMap = new Map<string, FolderTreeNode>();
|
||||
|
||||
for (const path of this.files.keys()) {
|
||||
const segments = path.split("/");
|
||||
segments.pop();
|
||||
|
||||
let currentPath = "";
|
||||
let siblings = rootMap;
|
||||
for (const segment of segments) {
|
||||
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
||||
const existing = siblings.get(currentPath);
|
||||
if (existing) {
|
||||
siblings = new Map(existing.children.map((child) => [child.path, child]));
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextNode: FolderTreeNode = {
|
||||
path: currentPath,
|
||||
name: segment,
|
||||
children: [],
|
||||
};
|
||||
siblings.set(currentPath, nextNode);
|
||||
siblings = new Map();
|
||||
}
|
||||
}
|
||||
|
||||
return buildFolderTree(Array.from(this.files.keys()));
|
||||
listFolderTree(): Promise<FolderTreeNode[]> {
|
||||
return Promise.resolve(buildFolderTree(Array.from(this.files.keys())));
|
||||
}
|
||||
|
||||
async readMarkdownFile(path: string): Promise<SourceFile | null> {
|
||||
readMarkdownFile(path: string): Promise<SourceFile | null> {
|
||||
this.readCalls.push(path);
|
||||
const file = this.files.get(path);
|
||||
if (!file) {
|
||||
return null;
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return {
|
||||
return Promise.resolve({
|
||||
path,
|
||||
basename: path.split("/").pop()?.replace(/\.md$/i, "") ?? path,
|
||||
content: file.content,
|
||||
tags: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async replaceMarkdownFile(path: string, expectedContent: string, nextContent: string): Promise<void> {
|
||||
replaceMarkdownFile(path: string, expectedContent: string, nextContent: string): Promise<void> {
|
||||
this.replaceCalls.push({ path, expectedContent, nextContent });
|
||||
|
||||
if (this.errorPaths.has(path)) {
|
||||
throw this.errorPaths.get(path);
|
||||
const pathError = this.errorPaths.get(path);
|
||||
if (pathError) {
|
||||
return Promise.reject(pathError);
|
||||
}
|
||||
|
||||
if (this.conflictPaths.has(path)) {
|
||||
throw new MarkdownWriteConflictError(path);
|
||||
return Promise.reject(new MarkdownWriteConflictError(path));
|
||||
}
|
||||
|
||||
const current = this.files.get(path);
|
||||
if (!current || current.content !== expectedContent) {
|
||||
throw new MarkdownWriteConflictError(path);
|
||||
return Promise.reject(new MarkdownWriteConflictError(path));
|
||||
}
|
||||
|
||||
this.files.set(path, {
|
||||
|
|
@ -118,6 +94,8 @@ export class FakeManualSyncVaultGateway implements ManualSyncVaultGateway {
|
|||
mtime: current.mtime + 1,
|
||||
size: nextContent.length,
|
||||
});
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
resolveWikiLink(rawTarget: string) {
|
||||
|
|
@ -170,40 +148,44 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
|
|||
await this.ensureDecks([deckName]);
|
||||
}
|
||||
|
||||
async ensureDecks(deckNames: string[]): Promise<void> {
|
||||
ensureDecks(deckNames: string[]): Promise<void> {
|
||||
this.operationLog.push("ensureDecks");
|
||||
this.ensuredDecks.push(deckNames);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async listNoteModels(): Promise<string[]> {
|
||||
return Object.keys(this.modelDetailsByName);
|
||||
listNoteModels(): Promise<string[]> {
|
||||
return Promise.resolve(Object.keys(this.modelDetailsByName));
|
||||
}
|
||||
|
||||
async listDeckNames(): Promise<string[]> {
|
||||
return this.listedDeckNames ? [...this.listedDeckNames] : Array.from(this.deckStatsByName.keys());
|
||||
listDeckNames(): Promise<string[]> {
|
||||
return Promise.resolve(this.listedDeckNames ? [...this.listedDeckNames] : Array.from(this.deckStatsByName.keys()));
|
||||
}
|
||||
|
||||
async getModelDetails(modelName: string): Promise<NoteModelDetails> {
|
||||
return this.modelDetailsByName[modelName] ?? { fieldNames: ["Front", "Back"] };
|
||||
getModelDetails(modelName: string): Promise<NoteModelDetails> {
|
||||
return Promise.resolve(this.modelDetailsByName[modelName] ?? { fieldNames: ["Front", "Back"] });
|
||||
}
|
||||
|
||||
async getModelFieldNames(modelName: string): Promise<string[]> {
|
||||
return this.modelDetailsByName[modelName]?.fieldNames ?? [];
|
||||
getModelFieldNames(modelName: string): Promise<string[]> {
|
||||
return Promise.resolve(this.modelDetailsByName[modelName]?.fieldNames ?? []);
|
||||
}
|
||||
|
||||
async getModelFieldNamesByModelNames(modelNames: string[]): Promise<Record<string, string[]>> {
|
||||
return Object.fromEntries(await Promise.all(modelNames.map(async (modelName) => [
|
||||
modelName,
|
||||
await this.getModelFieldNames(modelName),
|
||||
])));
|
||||
getModelFieldNamesByModelNames(modelNames: string[]): Promise<Record<string, string[]>> {
|
||||
const fieldNamesByModelName: Record<string, string[]> = {};
|
||||
|
||||
for (const modelName of modelNames) {
|
||||
fieldNamesByModelName[modelName] = this.modelDetailsByName[modelName]?.fieldNames ?? [];
|
||||
}
|
||||
|
||||
return Promise.resolve(fieldNamesByModelName);
|
||||
}
|
||||
|
||||
async findNoteIds(query: string): Promise<number[]> {
|
||||
return [...(this.foundNoteIds.get(query) ?? [])];
|
||||
findNoteIds(query: string): Promise<number[]> {
|
||||
return Promise.resolve([...(this.foundNoteIds.get(query) ?? [])]);
|
||||
}
|
||||
|
||||
async getNoteDetails(noteIds: number[]): Promise<AnkiNoteDetails[]> {
|
||||
return noteIds.flatMap((noteId) => {
|
||||
getNoteDetails(noteIds: number[]): Promise<AnkiNoteDetails[]> {
|
||||
return Promise.resolve(noteIds.flatMap((noteId) => {
|
||||
const detail = this.noteDetailsById.get(noteId);
|
||||
if (detail) {
|
||||
return [{ ...detail, cardIds: [...detail.cardIds], deckNames: detail.deckNames ? [...detail.deckNames] : undefined, tags: detail.tags ? [...detail.tags] : undefined, fields: { ...detail.fields } }];
|
||||
|
|
@ -211,18 +193,18 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
|
|||
|
||||
const summary = this.noteSummariesById.get(noteId);
|
||||
return summary ? [{ ...summary, deckNames: summary.deckNames ? [...summary.deckNames] : undefined, tags: summary.tags ? [...summary.tags] : undefined, fields: {} }] : [];
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
async getDeckStats(deckNames: string[]): Promise<DeckStat[]> {
|
||||
return deckNames.map((deckName) => this.deckStatsByName.get(deckName) ?? { deckName });
|
||||
getDeckStats(deckNames: string[]): Promise<DeckStat[]> {
|
||||
return Promise.resolve(deckNames.map((deckName) => this.deckStatsByName.get(deckName) ?? { deckName }));
|
||||
}
|
||||
|
||||
async getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]> {
|
||||
return noteIds.flatMap((noteId) => {
|
||||
getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]> {
|
||||
return Promise.resolve(noteIds.flatMap((noteId) => {
|
||||
const summary = this.noteSummariesById.get(noteId);
|
||||
return summary ? [{ ...summary, deckNames: summary.deckNames ? [...summary.deckNames] : undefined, tags: summary.tags ? [...summary.tags] : undefined }] : [];
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
async addNote(input: AddAnkiNoteInput): Promise<number> {
|
||||
|
|
@ -230,12 +212,14 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
|
|||
return noteId;
|
||||
}
|
||||
|
||||
async addNotes(inputs: AddAnkiNoteInput[]): Promise<number[]> {
|
||||
return inputs.map((input) => {
|
||||
addNotes(inputs: AddAnkiNoteInput[]): Promise<number[]> {
|
||||
const noteIds: number[] = [];
|
||||
|
||||
for (const input of inputs) {
|
||||
this.operationLog.push("addNotes");
|
||||
const pendingError = this.addNotesErrorQueue.shift();
|
||||
if (pendingError) {
|
||||
throw pendingError;
|
||||
return Promise.reject(pendingError);
|
||||
}
|
||||
|
||||
this.addedNotes.push(input);
|
||||
|
|
@ -256,11 +240,13 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
|
|||
tags: [...input.tags],
|
||||
fields: { ...input.fields },
|
||||
});
|
||||
return noteId;
|
||||
});
|
||||
noteIds.push(noteId);
|
||||
}
|
||||
|
||||
return Promise.resolve(noteIds);
|
||||
}
|
||||
|
||||
async deleteNotes(noteIds: number[]): Promise<void> {
|
||||
deleteNotes(noteIds: number[]): Promise<void> {
|
||||
this.operationLog.push("deleteNotes");
|
||||
this.deletedNotes.push(noteIds);
|
||||
|
||||
|
|
@ -271,17 +257,19 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
|
|||
|
||||
const pendingError = this.deleteNotesErrorQueue.shift();
|
||||
if (pendingError) {
|
||||
throw pendingError;
|
||||
return Promise.reject(pendingError);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async updateNote(input: UpdateAnkiNoteInput): Promise<void> {
|
||||
await this.updateNotes([input]);
|
||||
}
|
||||
|
||||
async updateNoteModel(input: UpdateAnkiNoteModelInput): Promise<void> {
|
||||
updateNoteModel(input: UpdateAnkiNoteModelInput): Promise<void> {
|
||||
if (this.updateNoteModelError) {
|
||||
throw this.updateNoteModelError;
|
||||
return Promise.reject(this.updateNoteModelError);
|
||||
}
|
||||
|
||||
this.updatedNoteModels.push(input);
|
||||
|
|
@ -302,13 +290,16 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
|
|||
fields: { ...input.fields },
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async updateNotes(inputs: UpdateAnkiNoteInput[]): Promise<void> {
|
||||
updateNotes(inputs: UpdateAnkiNoteInput[]): Promise<void> {
|
||||
this.updatedNotes.push(...inputs);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async syncNoteTags(inputs: SyncAnkiNoteTagsInput[]): Promise<void> {
|
||||
syncNoteTags(inputs: SyncAnkiNoteTagsInput[]): Promise<void> {
|
||||
this.syncedNoteTags.push(...inputs);
|
||||
|
||||
for (const input of inputs) {
|
||||
|
|
@ -333,22 +324,27 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway {
|
|||
this.noteDetailsById.set(input.noteId, { ...detail, tags: nextTags });
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async changeDecks(inputs: ChangeDeckInput[]): Promise<void> {
|
||||
changeDecks(inputs: ChangeDeckInput[]): Promise<void> {
|
||||
this.changedDecks.push(...inputs);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async deleteDecks(deckNames: string[]): Promise<void> {
|
||||
deleteDecks(deckNames: string[]): Promise<void> {
|
||||
this.deletedDecks.push(deckNames);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async storeMedia(asset: MediaAsset): Promise<void> {
|
||||
await this.storeMediaFiles([asset]);
|
||||
}
|
||||
|
||||
async storeMediaFiles(assets: MediaAsset[]): Promise<void> {
|
||||
storeMediaFiles(assets: MediaAsset[]): Promise<void> {
|
||||
this.storedMedia.push(...assets);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export async function requestUrl(): Promise<never> {
|
||||
throw new Error("obsidian.requestUrl stub was called without a test mock.");
|
||||
export function requestUrl(): Promise<never> {
|
||||
return Promise.reject(new Error("obsidian.requestUrl stub was called without a test mock."));
|
||||
}
|
||||
|
||||
export function getAllTags(cache: {
|
||||
|
|
|
|||
294
styles.css
294
styles.css
|
|
@ -1 +1,293 @@
|
|||
/* Intentionally empty: settings controls use native Obsidian component styles. */
|
||||
.anki-heading-sync-settings .ahs-is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-page-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 300;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding-top: 12px;
|
||||
padding-bottom: calc(12px + var(--ahs-settings-sticky-card-gap, 8px));
|
||||
margin-bottom: 0;
|
||||
overflow: visible;
|
||||
background: var(--modal-background, var(--background-primary));
|
||||
background-color: var(--modal-background, var(--background-primary));
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-page-header-mask {
|
||||
position: absolute;
|
||||
top: -128px;
|
||||
left: -24px;
|
||||
right: -24px;
|
||||
bottom: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background: var(--modal-background, var(--background-primary));
|
||||
background-color: var(--modal-background, var(--background-primary));
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-page-title-setting {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-page-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-card-toggle {
|
||||
position: sticky;
|
||||
top: var(--ahs-settings-page-header-height, 64px);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 8px;
|
||||
border: 2px solid var(--background-modifier-border);
|
||||
border-radius: 0;
|
||||
background: var(--modal-background, var(--background-primary));
|
||||
background-color: var(--modal-background, var(--background-primary));
|
||||
box-shadow: none;
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-action-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-card-type-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-card-type-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-card-type-title {
|
||||
font-size: var(--font-ui-medium);
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-recognition-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px 14px;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-heading-select {
|
||||
width: 88px;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-marker-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-muted-text {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-grid-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.75fr) minmax(220px, 0.95fr);
|
||||
align-items: center;
|
||||
column-gap: 16px;
|
||||
row-gap: 8px;
|
||||
overflow: visible;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-qa-group-pair-row {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) max-content;
|
||||
align-items: center;
|
||||
column-gap: 16px;
|
||||
row-gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-grid-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-nowrap-start {
|
||||
justify-self: start;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-warnings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-inline-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-warning-text {
|
||||
color: var(--text-warning);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-folder-tree,
|
||||
.anki-heading-sync-settings .ahs-settings-folder-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-deck-example-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
margin-left: 32px;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-small-title {
|
||||
font-size: var(--font-ui-small);
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-helper-text {
|
||||
margin-top: 2px;
|
||||
margin-left: 32px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-small);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-helper-text-priority {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-folder-row {
|
||||
display: grid;
|
||||
grid-template-columns: 24px 24px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
column-gap: 0;
|
||||
width: 100%;
|
||||
min-height: 28px;
|
||||
box-sizing: border-box;
|
||||
margin-left: var(--ahs-folder-depth-indent, 0px);
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-folder-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 2em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-folder-toggle-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-folder-checkbox {
|
||||
justify-self: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-folder-label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-inline-control-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-inline-control-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-grid-control-slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-grid-control-label {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-grid-control-content {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-fluid-ellipsis {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings .ahs-settings-fluid-ellipsis:not(select):not(input) {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.anki-heading-sync-settings select.ahs-settings-fluid-control,
|
||||
.anki-heading-sync-settings input.ahs-settings-fluid-control {
|
||||
box-sizing: border-box;
|
||||
width: calc(100% - 2px);
|
||||
max-width: calc(100% - 2px);
|
||||
min-width: 0;
|
||||
margin: 1px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue