Fix Obsidian review issues

This commit is contained in:
Dusk 2026-04-30 10:15:44 +08:00
parent b4e6e53c61
commit 6fb8583feb
28 changed files with 3939 additions and 623 deletions

View file

@ -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

File diff suppressed because one or more lines are too long

2970
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

@ -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.");
});

View file

@ -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,

View file

@ -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(

View file

@ -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 () => {

View file

@ -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);

View file

@ -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 () => {

View file

@ -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: {

View file

@ -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> {

View file

@ -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);

View file

@ -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() {

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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;
}

View file

@ -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");
}
});

View file

@ -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";
}

View file

@ -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.",

View file

@ -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();

View file

@ -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");

View file

@ -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({

View file

@ -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(["文件夹映射模式"]);

View file

@ -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;
}

View file

@ -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();
}
}

View file

@ -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: {

View file

@ -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;
}