mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
Release 1.0.3
This commit is contained in:
parent
befe28cd77
commit
0dc2d7cb5d
19 changed files with 438 additions and 50 deletions
15
AGENTS.md
15
AGENTS.md
|
|
@ -7,3 +7,18 @@ When syncing this plugin into the user's Obsidian vault, use this exact plugin d
|
|||
`/Users/panxiaorong/Library/Mobile Documents/iCloud~md~obsidian/Documents/obsidian/.obsidian/plugins/Anki Heading Sync`
|
||||
|
||||
Copy only the built plugin files (`main.js`, `manifest.json`, `styles.css`) into that directory. Do not delete or overwrite `data.json`.
|
||||
|
||||
## GitHub Release Assets
|
||||
|
||||
When creating or updating a GitHub release, upload the runtime plugin assets:
|
||||
|
||||
- `main.js`
|
||||
- `manifest.json`
|
||||
- `styles.css`
|
||||
|
||||
Also upload the example files from `dist/`:
|
||||
|
||||
- `dist/dead-sea-example.md`
|
||||
- `dist/dead-sea-example.apkg`
|
||||
|
||||
The local Chinese-named example files duplicate the English `dead-sea-example.*` files. The English names are the ones referenced by `README.md`; keep only those two example assets attached to every release that includes downloadable assets.
|
||||
|
|
|
|||
42
main.js
42
main.js
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "anki-heading-sync",
|
||||
"name": "Anki Heading Sync",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"minAppVersion": "1.8.7",
|
||||
"description": "Focused heading-based Markdown to Anki sync plugin.",
|
||||
"author": "Dusk",
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "obsidian-anki-heading-sync",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-anki-heading-sync",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"markdown-it": "^14.1.0"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-anki-heading-sync",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"description": "Focused heading-based Markdown to Anki sync plugin.",
|
||||
"author": "Dusk",
|
||||
"main": "dist/plugin/main.js",
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ describe("PluginSettings", () => {
|
|||
expect(DEFAULT_SETTINGS.qaGroupMarker).toBe("#anki-list");
|
||||
expect(DEFAULT_SETTINGS.cardAnswerCutoffMode).toBe("heading-block");
|
||||
expect(DEFAULT_SETTINGS.alternateFolderDeckModeFolders).toEqual([]);
|
||||
expect(DEFAULT_SETTINGS.standaloneParentDeckFolders).toEqual([]);
|
||||
expect(DEFAULT_SETTINGS.obsidianBacklinkLabel).toBe(DEFAULT_OBSIDIAN_BACKLINK_LABEL);
|
||||
expect(DEFAULT_SETTINGS.obsidianBacklinkPlacement).toBe("answer-last-line");
|
||||
expect(DEFAULT_SETTINGS.syncObsidianTagsToAnki).toBe(true);
|
||||
|
|
@ -120,6 +121,14 @@ describe("PluginSettings", () => {
|
|||
expect(settings.alternateFolderDeckModeFolders).toEqual([]);
|
||||
});
|
||||
|
||||
it("defaults standalone parent deck folders to an empty array when missing", () => {
|
||||
const settings = mergePluginSettings({
|
||||
defaultDeck: "Default",
|
||||
});
|
||||
|
||||
expect(settings.standaloneParentDeckFolders).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves alternate folder deck mode override folders using the current folder-list convention", () => {
|
||||
const settings = mergePluginSettings({
|
||||
alternateFolderDeckModeFolders: ["notes", "notes/sub", "notes"],
|
||||
|
|
@ -128,6 +137,14 @@ describe("PluginSettings", () => {
|
|||
expect(settings.alternateFolderDeckModeFolders).toEqual(["notes", "notes/sub", "notes"]);
|
||||
});
|
||||
|
||||
it("preserves standalone parent deck folders using the current folder-list convention", () => {
|
||||
const settings = mergePluginSettings({
|
||||
standaloneParentDeckFolders: ["notes/sub", "notes/other", "notes/sub"],
|
||||
});
|
||||
|
||||
expect(settings.standaloneParentDeckFolders).toEqual(["notes/sub", "notes/other", "notes/sub"]);
|
||||
});
|
||||
|
||||
it("rejects non-array alternate folder deck mode override folders", () => {
|
||||
expectPluginUserError(() => {
|
||||
validatePluginSettings({
|
||||
|
|
@ -146,6 +163,24 @@ describe("PluginSettings", () => {
|
|||
}, "errors.settings.alternateFolderDeckModeFoldersStrings");
|
||||
});
|
||||
|
||||
it("rejects non-array standalone parent deck folders", () => {
|
||||
expectPluginUserError(() => {
|
||||
validatePluginSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
standaloneParentDeckFolders: "notes/sub" as never,
|
||||
});
|
||||
}, "errors.settings.standaloneParentDeckFoldersArray");
|
||||
});
|
||||
|
||||
it("rejects non-string standalone parent deck folder entries", () => {
|
||||
expectPluginUserError(() => {
|
||||
validatePluginSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
standaloneParentDeckFolders: ["notes/sub", 1] as never,
|
||||
});
|
||||
}, "errors.settings.standaloneParentDeckFoldersStrings");
|
||||
});
|
||||
|
||||
it("normalizes cached Anki note types on load and save paths", () => {
|
||||
const settings = mergePluginSettings({
|
||||
ankiNoteTypeCache: [" Custom Basic ", "", "Cloze", "Custom Basic"],
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export interface PluginSettings {
|
|||
includeFolders: string[];
|
||||
excludeFolders: string[];
|
||||
alternateFolderDeckModeFolders: string[];
|
||||
standaloneParentDeckFolders: string[];
|
||||
addObsidianBacklink: boolean;
|
||||
obsidianBacklinkLabel: string;
|
||||
obsidianBacklinkPlacement: ObsidianBacklinkPlacement;
|
||||
|
|
@ -91,6 +92,7 @@ export const DEFAULT_SETTINGS: PluginSettings = {
|
|||
includeFolders: [],
|
||||
excludeFolders: [],
|
||||
alternateFolderDeckModeFolders: [],
|
||||
standaloneParentDeckFolders: [],
|
||||
addObsidianBacklink: true,
|
||||
obsidianBacklinkLabel: DEFAULT_OBSIDIAN_BACKLINK_LABEL,
|
||||
obsidianBacklinkPlacement: "answer-last-line",
|
||||
|
|
@ -147,6 +149,7 @@ export function mergePluginSettings(settings?: Partial<PluginSettings> | null):
|
|||
includeFolders: partialSettings.includeFolders ?? DEFAULT_SETTINGS.includeFolders,
|
||||
excludeFolders: partialSettings.excludeFolders ?? DEFAULT_SETTINGS.excludeFolders,
|
||||
alternateFolderDeckModeFolders: partialSettings.alternateFolderDeckModeFolders ?? DEFAULT_SETTINGS.alternateFolderDeckModeFolders,
|
||||
standaloneParentDeckFolders: partialSettings.standaloneParentDeckFolders ?? DEFAULT_SETTINGS.standaloneParentDeckFolders,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -208,6 +211,7 @@ export function validatePluginSettings(settings: PluginSettings): void {
|
|||
validateFolderList(settings.includeFolders, "include");
|
||||
validateFolderList(settings.excludeFolders, "exclude");
|
||||
validateFolderList(settings.alternateFolderDeckModeFolders, "alternateFolderDeckMode");
|
||||
validateFolderList(settings.standaloneParentDeckFolders, "standaloneParentDeck");
|
||||
|
||||
if (!settings.ankiConnectUrl.trim()) {
|
||||
throw new PluginUserError("errors.settings.ankiConnectUrlRequired");
|
||||
|
|
@ -260,30 +264,32 @@ function validateAnkiModelFieldCache(ankiModelFieldCache: AnkiModelFieldCache):
|
|||
}
|
||||
}
|
||||
|
||||
function validateFolderList(folderList: string[], label: "include" | "exclude" | "alternateFolderDeckMode"): void {
|
||||
function validateFolderList(folderList: string[], label: "include" | "exclude" | "alternateFolderDeckMode" | "standaloneParentDeck"): void {
|
||||
if (!Array.isArray(folderList)) {
|
||||
if (label === "include") {
|
||||
throw new PluginUserError("errors.settings.includeFoldersArray");
|
||||
switch (label) {
|
||||
case "include":
|
||||
throw new PluginUserError("errors.settings.includeFoldersArray");
|
||||
case "exclude":
|
||||
throw new PluginUserError("errors.settings.excludeFoldersArray");
|
||||
case "alternateFolderDeckMode":
|
||||
throw new PluginUserError("errors.settings.alternateFolderDeckModeFoldersArray");
|
||||
case "standaloneParentDeck":
|
||||
throw new PluginUserError("errors.settings.standaloneParentDeckFoldersArray");
|
||||
}
|
||||
|
||||
if (label === "exclude") {
|
||||
throw new PluginUserError("errors.settings.excludeFoldersArray");
|
||||
}
|
||||
|
||||
throw new PluginUserError("errors.settings.alternateFolderDeckModeFoldersArray");
|
||||
}
|
||||
|
||||
for (const folder of folderList) {
|
||||
if (typeof folder !== "string") {
|
||||
if (label === "include") {
|
||||
throw new PluginUserError("errors.settings.includeFoldersStrings");
|
||||
switch (label) {
|
||||
case "include":
|
||||
throw new PluginUserError("errors.settings.includeFoldersStrings");
|
||||
case "exclude":
|
||||
throw new PluginUserError("errors.settings.excludeFoldersStrings");
|
||||
case "alternateFolderDeckMode":
|
||||
throw new PluginUserError("errors.settings.alternateFolderDeckModeFoldersStrings");
|
||||
case "standaloneParentDeck":
|
||||
throw new PluginUserError("errors.settings.standaloneParentDeckFoldersStrings");
|
||||
}
|
||||
|
||||
if (label === "exclude") {
|
||||
throw new PluginUserError("errors.settings.excludeFoldersStrings");
|
||||
}
|
||||
|
||||
throw new PluginUserError("errors.settings.alternateFolderDeckModeFoldersStrings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -368,6 +368,75 @@ describe("FileIndexerService", () => {
|
|||
|
||||
expect(createDeckRulesFingerprint(first)).toBe(createDeckRulesFingerprint(second));
|
||||
});
|
||||
|
||||
it("changes the fingerprint and forces re-read when standalone parent deck folders changed", async () => {
|
||||
const content = ["#### One", "Body"].join("\n");
|
||||
const oldSettings = createIndexSettingsForPath("notes/one.md", { folderDeckMode: "folder" });
|
||||
const newSettings = createIndexSettingsForPath("notes/one.md", {
|
||||
folderDeckMode: "folder",
|
||||
standaloneParentDeckFolders: ["notes"],
|
||||
});
|
||||
const vaultGateway = new FakeManualSyncVaultGateway({
|
||||
"notes/one.md": content,
|
||||
});
|
||||
const service = new FileIndexerService(vaultGateway);
|
||||
const state = {
|
||||
files: {
|
||||
"notes/one.md": {
|
||||
filePath: "notes/one.md",
|
||||
fileHash: "hash-a",
|
||||
fileStamp: `1:${content.length}`,
|
||||
deckRulesFingerprint: createDeckRulesFingerprint(oldSettings),
|
||||
lastIndexedAt: 1,
|
||||
noteIds: [10],
|
||||
},
|
||||
},
|
||||
cards: {
|
||||
"10": {
|
||||
noteId: 10,
|
||||
filePath: "notes/one.md",
|
||||
heading: "One",
|
||||
backlinkHeadingText: "One",
|
||||
headingLevel: 4,
|
||||
bodyMarkdown: "Body",
|
||||
cardType: "basic" as const,
|
||||
blockStartOffset: 0,
|
||||
blockEndOffset: 10,
|
||||
blockStartLine: 1,
|
||||
bodyStartLine: 2,
|
||||
blockEndLine: 2,
|
||||
contentEndLine: 2,
|
||||
rawBlockText: content,
|
||||
rawBlockHash: "hash-card",
|
||||
renderConfigHash: "render-hash",
|
||||
deck: "notes",
|
||||
deckWarnings: [],
|
||||
tagsHint: [],
|
||||
lastSyncedAt: 1,
|
||||
orphan: false,
|
||||
},
|
||||
},
|
||||
pendingWriteBack: [],
|
||||
};
|
||||
|
||||
expect(createDeckRulesFingerprint(oldSettings)).not.toBe(createDeckRulesFingerprint(newSettings));
|
||||
|
||||
const result = await service.indexVault(newSettings, state);
|
||||
|
||||
expect(result.skippedUnchangedFiles).toBe(0);
|
||||
expect(vaultGateway.readCalls).toEqual(["notes/one.md"]);
|
||||
});
|
||||
|
||||
it("normalizes standalone parent deck folder ordering inside the fingerprint", () => {
|
||||
const first = createIndexSettingsForPath("notes/one.md", {
|
||||
standaloneParentDeckFolders: ["notes/sub", "notes"],
|
||||
});
|
||||
const second = createIndexSettingsForPath("notes/one.md", {
|
||||
standaloneParentDeckFolders: ["notes", "notes/sub"],
|
||||
});
|
||||
|
||||
expect(createDeckRulesFingerprint(first)).toBe(createDeckRulesFingerprint(second));
|
||||
});
|
||||
});
|
||||
|
||||
function createIndexSettingsForPath(filePath: string, overrides: Parameters<typeof createModule3Settings>[0] = {}) {
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ export function createFileStamp(mtime: number, size: number): string {
|
|||
return `${mtime}:${size}`;
|
||||
}
|
||||
|
||||
const DECK_RULES_FINGERPRINT_VERSION = "deck-rules-v5";
|
||||
const DECK_RULES_FINGERPRINT_VERSION = "deck-rules-v6";
|
||||
|
||||
export function createDeckRulesFingerprint(settings: PluginSettings): string {
|
||||
return hashString(JSON.stringify({
|
||||
|
|
@ -303,6 +303,7 @@ export function createDeckRulesFingerprint(settings: PluginSettings): string {
|
|||
fileDeckMarker: settings.fileDeckMarker,
|
||||
folderDeckMode: settings.folderDeckMode,
|
||||
alternateFolderDeckModeFolders: [...settings.alternateFolderDeckModeFolders].sort(),
|
||||
standaloneParentDeckFolders: [...settings.standaloneParentDeckFolders].sort(),
|
||||
syncObsidianTagsToAnki: settings.syncObsidianTagsToAnki,
|
||||
keepPureTagLinesInCardBody: settings.keepPureTagLinesInCardBody,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -90,15 +90,30 @@ describe("DeckResolutionService", () => {
|
|||
source: "folder",
|
||||
});
|
||||
});
|
||||
|
||||
it("starts folder mapping from a standalone parent deck folder when configured", () => {
|
||||
const service = new DeckResolutionService();
|
||||
|
||||
const result = service.resolve(createIndexedCard({ filePath: "3Resources/Books/BookA/第1章.md" }), createDeckSettings({
|
||||
folderDeckMode: "folder",
|
||||
standaloneParentDeckFolders: ["3Resources/Books"],
|
||||
}));
|
||||
|
||||
expect(result.resolvedDeck).toEqual({
|
||||
value: "Books::BookA",
|
||||
source: "folder",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createDeckSettings(
|
||||
overrides: Partial<Pick<PluginSettings, "defaultDeck" | "folderDeckMode" | "alternateFolderDeckModeFolders">> = {},
|
||||
): Pick<PluginSettings, "defaultDeck" | "folderDeckMode" | "alternateFolderDeckModeFolders"> {
|
||||
overrides: Partial<Pick<PluginSettings, "defaultDeck" | "folderDeckMode" | "alternateFolderDeckModeFolders" | "standaloneParentDeckFolders">> = {},
|
||||
): Pick<PluginSettings, "defaultDeck" | "folderDeckMode" | "alternateFolderDeckModeFolders" | "standaloneParentDeckFolders"> {
|
||||
return {
|
||||
defaultDeck: "Default",
|
||||
folderDeckMode: "folder-and-file",
|
||||
alternateFolderDeckModeFolders: [],
|
||||
standaloneParentDeckFolders: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ export class DeckResolutionService {
|
|||
private readonly deckNormalizationService = new DeckNormalizationService(),
|
||||
) {}
|
||||
|
||||
resolve(card: IndexedCard, settings: Pick<PluginSettings, "defaultDeck" | "folderDeckMode" | "alternateFolderDeckModeFolders">): DeckResolutionResult {
|
||||
resolve(
|
||||
card: IndexedCard,
|
||||
settings: Pick<PluginSettings, "defaultDeck" | "folderDeckMode" | "alternateFolderDeckModeFolders" | "standaloneParentDeckFolders">,
|
||||
): DeckResolutionResult {
|
||||
if (card.deckHint) {
|
||||
return {
|
||||
resolvedDeck: {
|
||||
|
|
@ -26,6 +29,7 @@ export class DeckResolutionService {
|
|||
card.filePath,
|
||||
settings.folderDeckMode,
|
||||
settings.alternateFolderDeckModeFolders,
|
||||
settings.standaloneParentDeckFolders,
|
||||
);
|
||||
if (folderMapping.deck) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,32 @@ describe("FolderDeckMappingService", () => {
|
|||
expect(service.mapFilePathToDeck("notes/sub/topic.md", "off", ["notes/sub"])).toEqual({ warnings: [] });
|
||||
});
|
||||
|
||||
it("starts deck paths from a standalone parent deck folder", () => {
|
||||
const service = new FolderDeckMappingService();
|
||||
|
||||
expect(service.mapFilePathToDeck("3Resources/Books/BookA/第1章.md", "folder", [], ["3Resources/Books"])).toEqual({
|
||||
deck: "Books::BookA",
|
||||
warnings: [],
|
||||
});
|
||||
expect(service.mapFilePathToDeck("3Resources/Books/BookA/第1章.md", "folder-and-file", [], ["3Resources/Books"])).toEqual({
|
||||
deck: "Books::BookA::第1章",
|
||||
warnings: [],
|
||||
});
|
||||
expect(service.mapFilePathToDeck("3Resources/Books/a.md", "folder", [], ["3Resources/Books"])).toEqual({
|
||||
deck: "Books",
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the deepest standalone parent deck folder", () => {
|
||||
const service = new FolderDeckMappingService();
|
||||
|
||||
expect(service.mapFilePathToDeck("3Resources/Books/Sub/a.md", "folder-and-file", [], ["3Resources", "3Resources/Books"])).toEqual({
|
||||
deck: "Books::Sub::a",
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty for root-level files", () => {
|
||||
const service = new FolderDeckMappingService();
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,12 @@ export interface FolderDeckMappingResult {
|
|||
export class FolderDeckMappingService {
|
||||
constructor(private readonly deckNormalizationService = new DeckNormalizationService()) {}
|
||||
|
||||
mapFilePathToDeck(filePath: string, mode: FolderDeckMode, alternateFolderDeckModeFolders: string[] = []): FolderDeckMappingResult {
|
||||
mapFilePathToDeck(
|
||||
filePath: string,
|
||||
mode: FolderDeckMode,
|
||||
alternateFolderDeckModeFolders: string[] = [],
|
||||
standaloneParentDeckFolders: string[] = [],
|
||||
): FolderDeckMappingResult {
|
||||
const effectiveMode = resolveEffectiveFolderDeckMode(filePath, mode, alternateFolderDeckModeFolders);
|
||||
|
||||
if (effectiveMode === "off") {
|
||||
|
|
@ -29,7 +34,7 @@ export class FolderDeckMappingService {
|
|||
return { warnings: [] };
|
||||
}
|
||||
|
||||
const segments = folderPath.split("/").filter(Boolean);
|
||||
const segments = resolveDeckSegments(folderPath, standaloneParentDeckFolders);
|
||||
if (effectiveMode === "folder-and-file") {
|
||||
const fileName = normalizedFilePath.slice(lastSlash + 1).replace(/\.[^.]+$/, "").trim();
|
||||
if (fileName) {
|
||||
|
|
@ -75,6 +80,35 @@ function normalizeFolderPath(folderPath: string): string {
|
|||
return folderPath.trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
|
||||
function resolveDeckSegments(folderPath: string, standaloneParentDeckFolders: string[]): string[] {
|
||||
const allSegments = folderPath.split("/").filter(Boolean);
|
||||
const standaloneParentDeckFolder = resolveStandaloneParentDeckFolder(folderPath, standaloneParentDeckFolders);
|
||||
if (!standaloneParentDeckFolder) {
|
||||
return allSegments;
|
||||
}
|
||||
|
||||
const standaloneSegments = standaloneParentDeckFolder.split("/").filter(Boolean);
|
||||
const startIndex = Math.max(standaloneSegments.length - 1, 0);
|
||||
return allSegments.slice(startIndex);
|
||||
}
|
||||
|
||||
function resolveStandaloneParentDeckFolder(folderPath: string, standaloneParentDeckFolders: string[]): string | undefined {
|
||||
let matchedFolder: string | undefined;
|
||||
|
||||
for (const folder of standaloneParentDeckFolders) {
|
||||
const normalizedFolderPath = normalizeFolderPath(folder);
|
||||
if (!normalizedFolderPath || !isPathInsideFolder(folderPath, normalizedFolderPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!matchedFolder || normalizedFolderPath.length > matchedFolder.length) {
|
||||
matchedFolder = normalizedFolderPath;
|
||||
}
|
||||
}
|
||||
|
||||
return matchedFolder;
|
||||
}
|
||||
|
||||
function normalizeFilePath(filePath: string): string {
|
||||
return filePath.trim().replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,20 @@ describe("DataJsonPluginConfigRepository", () => {
|
|||
expect(reloaded.alternateFolderDeckModeFolders).toEqual(["notes", "notes/sub"]);
|
||||
});
|
||||
|
||||
it("persists standalone parent deck folders across save and reload", async () => {
|
||||
const store = new InMemoryPluginDataStore();
|
||||
const repository = new DataJsonPluginConfigRepository(store);
|
||||
|
||||
await repository.save({
|
||||
...DEFAULT_SETTINGS,
|
||||
standaloneParentDeckFolders: ["notes/sub", "notes/other"],
|
||||
});
|
||||
|
||||
const reloaded = await repository.load();
|
||||
|
||||
expect(reloaded.standaloneParentDeckFolders).toEqual(["notes/sub", "notes/other"]);
|
||||
});
|
||||
|
||||
it("normalizes blank backlink labels on load and save", async () => {
|
||||
const store = new InMemoryPluginDataStore({
|
||||
settings: {
|
||||
|
|
|
|||
|
|
@ -207,6 +207,11 @@ export const en = {
|
|||
folderAndFile: "Global mode is folder-only; enable this to use folder-and-file deck names for this folder",
|
||||
},
|
||||
},
|
||||
standaloneParentDeck: {
|
||||
ariaLabel: "Use {{name}} as a standalone parent deck root",
|
||||
hint: "This folder is used as a standalone parent deck",
|
||||
title: "Enable this to treat this folder as a standalone parent deck root in Anki",
|
||||
},
|
||||
},
|
||||
cards: {
|
||||
cardTypes: {
|
||||
|
|
@ -390,6 +395,8 @@ export const en = {
|
|||
excludeFoldersStrings: "Exclude folders must only contain strings.",
|
||||
alternateFolderDeckModeFoldersArray: "Alternate folder deck mode folders must be an array.",
|
||||
alternateFolderDeckModeFoldersStrings: "Alternate folder deck mode folders must only contain strings.",
|
||||
standaloneParentDeckFoldersArray: "Standalone parent deck folders must be an array.",
|
||||
standaloneParentDeckFoldersStrings: "Standalone parent deck folders must only contain strings.",
|
||||
ankiConnectUrlRequired: "AnkiConnect URL is required.",
|
||||
ankiNoteTypeCacheArray: "Anki note type cache must be an array.",
|
||||
ankiNoteTypeCacheStrings: "Anki note type cache can only contain strings.",
|
||||
|
|
|
|||
|
|
@ -205,6 +205,11 @@ export const zh = {
|
|||
folderAndFile: "当前全局为「文件夹」,勾选后此文件夹改用「文件夹及文件名」作为牌组名",
|
||||
},
|
||||
},
|
||||
standaloneParentDeck: {
|
||||
ariaLabel: "将 {{name}} 指定为单独的父牌组",
|
||||
hint: "本文件夹指定为「单独的父牌组」",
|
||||
title: "勾选后,此文件夹在 Anki 中会作为单独的父牌组起点",
|
||||
},
|
||||
},
|
||||
cards: {
|
||||
cardTypes: {
|
||||
|
|
@ -388,6 +393,8 @@ export const zh = {
|
|||
excludeFoldersStrings: "排除文件夹列表中只能包含字符串。",
|
||||
alternateFolderDeckModeFoldersArray: "文件夹牌组模式覆盖列表必须是数组。",
|
||||
alternateFolderDeckModeFoldersStrings: "文件夹牌组模式覆盖列表中只能包含字符串。",
|
||||
standaloneParentDeckFoldersArray: "单独父牌组文件夹列表必须是数组。",
|
||||
standaloneParentDeckFoldersStrings: "单独父牌组文件夹列表中只能包含字符串。",
|
||||
ankiConnectUrlRequired: "AnkiConnect URL 不能为空。",
|
||||
ankiNoteTypeCacheArray: "Anki 笔记模板缓存必须是数组。",
|
||||
ankiNoteTypeCacheStrings: "Anki 笔记模板缓存中只能包含字符串。",
|
||||
|
|
|
|||
|
|
@ -1733,17 +1733,24 @@ describe("PluginSettingTab", () => {
|
|||
await flushPromises();
|
||||
|
||||
const overrideCheckbox = queryByDataset(tab.containerEl, "folderDeckModeOverride", "notes/sub");
|
||||
const standaloneParentDeckCheckbox = queryByDataset(tab.containerEl, "folderStandaloneParentDeck", "notes/sub");
|
||||
const childLabel = queryByDataset(tab.containerEl, "folderPathLabel", "notes/sub");
|
||||
const overrideAttributes = overrideCheckbox as unknown as { title?: string; "aria-label"?: string };
|
||||
const standaloneParentDeckAttributes = standaloneParentDeckCheckbox as unknown as { title?: string; "aria-label"?: string };
|
||||
const labelParent = (childLabel as unknown as { parent?: { children?: unknown[] } }).parent;
|
||||
const overrideParent = (overrideCheckbox as unknown as { parent?: { children?: unknown[] } }).parent;
|
||||
expect(overrideCheckbox.classList.contains("ahs-settings-folder-override-checkbox")).toBe(true);
|
||||
expect(standaloneParentDeckCheckbox.classList.contains("ahs-settings-folder-override-checkbox")).toBe(true);
|
||||
expect(overrideAttributes.title).toBe("当前全局为「文件夹」,勾选后此文件夹改用「文件夹及文件名」作为牌组名");
|
||||
expect(overrideAttributes["aria-label"]).toBe("切换 sub 的文件夹牌组映射模式");
|
||||
expect(standaloneParentDeckAttributes.title).toBe("勾选后,此文件夹在 Anki 中会作为单独的父牌组起点");
|
||||
expect(standaloneParentDeckAttributes["aria-label"]).toBe("将 sub 指定为单独的父牌组");
|
||||
expect(overrideParent).toBe(labelParent);
|
||||
expect(labelParent?.children?.[0]).toBe(childLabel);
|
||||
expect(labelParent?.children?.[1]).toBe(overrideCheckbox);
|
||||
expect(labelParent?.children?.[2]).toBe(standaloneParentDeckCheckbox);
|
||||
expect(() => queryByDataset(tab.containerEl, "folderDeckModeOverrideHint", "notes/sub")).toThrow();
|
||||
expect(() => queryByDataset(tab.containerEl, "folderStandaloneParentDeckHint", "notes/sub")).toThrow();
|
||||
});
|
||||
|
||||
it("toggling a folder deck mode override only updates alternateFolderDeckModeFolders and shows the hint", async () => {
|
||||
|
|
@ -1771,6 +1778,7 @@ describe("PluginSettingTab", () => {
|
|||
});
|
||||
const childLabel = queryByDataset(tab.containerEl, "folderPathLabel", "notes/sub");
|
||||
const rerenderedOverrideCheckbox = queryByDataset(tab.containerEl, "folderDeckModeOverride", "notes/sub");
|
||||
const standaloneParentDeckCheckbox = queryByDataset(tab.containerEl, "folderStandaloneParentDeck", "notes/sub");
|
||||
const hint = queryByDataset(tab.containerEl, "folderDeckModeOverrideHint", "notes/sub");
|
||||
const inlineParent = (childLabel as unknown as { parent?: { children?: unknown[] } }).parent;
|
||||
expect(hint.classList.contains("ahs-settings-folder-override-hint")).toBe(true);
|
||||
|
|
@ -1778,9 +1786,69 @@ describe("PluginSettingTab", () => {
|
|||
expect(inlineParent?.children?.[0]).toBe(childLabel);
|
||||
expect(inlineParent?.children?.[1]).toBe(rerenderedOverrideCheckbox);
|
||||
expect(inlineParent?.children?.[2]).toBe(hint);
|
||||
expect(inlineParent?.children?.[3]).toBe(standaloneParentDeckCheckbox);
|
||||
expect(hint.textContent).toContain("文件夹及文件名");
|
||||
});
|
||||
|
||||
it("renders standalone parent deck controls for inherited checked non-root rows and hides them for top-level rows", async () => {
|
||||
const plugin = new FakePlugin();
|
||||
plugin.settings = normalizePluginSettings({
|
||||
...plugin.settings,
|
||||
scopeMode: "include",
|
||||
includeFolders: ["notes"],
|
||||
folderDeckMode: "folder",
|
||||
});
|
||||
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
|
||||
|
||||
tab.display();
|
||||
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(() => queryByDataset(tab.containerEl, "folderStandaloneParentDeck", "notes")).toThrow();
|
||||
|
||||
await queryByDataset(tab.containerEl, "folderToggle", "notes").trigger("click");
|
||||
|
||||
expect(queryByDataset(tab.containerEl, "folderStandaloneParentDeck", "notes/sub")).toBeDefined();
|
||||
});
|
||||
|
||||
it("toggling a standalone parent deck only updates standaloneParentDeckFolders and shows the hint", async () => {
|
||||
const plugin = new FakePlugin();
|
||||
plugin.settings = normalizePluginSettings({
|
||||
...plugin.settings,
|
||||
scopeMode: "include",
|
||||
includeFolders: ["notes/sub"],
|
||||
folderDeckMode: "folder",
|
||||
});
|
||||
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
|
||||
|
||||
tab.display();
|
||||
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
const standaloneParentDeckCheckbox = queryByDataset(tab.containerEl, "folderStandaloneParentDeck", "notes/sub");
|
||||
standaloneParentDeckCheckbox.checked = true;
|
||||
await standaloneParentDeckCheckbox.trigger("change");
|
||||
|
||||
expect(plugin.settings.includeFolders).toEqual(["notes/sub"]);
|
||||
expect(plugin.settings.alternateFolderDeckModeFolders).toEqual([]);
|
||||
expect(plugin.settings.standaloneParentDeckFolders).toEqual(["notes/sub"]);
|
||||
expect(plugin.updateCalls).toContainEqual({
|
||||
standaloneParentDeckFolders: ["notes/sub"],
|
||||
});
|
||||
const childLabel = queryByDataset(tab.containerEl, "folderPathLabel", "notes/sub");
|
||||
const rerenderedOverrideCheckbox = queryByDataset(tab.containerEl, "folderDeckModeOverride", "notes/sub");
|
||||
const rerenderedStandaloneParentDeckCheckbox = queryByDataset(tab.containerEl, "folderStandaloneParentDeck", "notes/sub");
|
||||
const hint = queryByDataset(tab.containerEl, "folderStandaloneParentDeckHint", "notes/sub");
|
||||
const inlineParent = (childLabel as unknown as { parent?: { children?: unknown[] } }).parent;
|
||||
expect(hint.classList.contains("ahs-settings-folder-override-hint")).toBe(true);
|
||||
expect((hint as unknown as { parent?: unknown }).parent).toBe(inlineParent);
|
||||
expect(inlineParent?.children?.[0]).toBe(childLabel);
|
||||
expect(inlineParent?.children?.[1]).toBe(rerenderedOverrideCheckbox);
|
||||
expect(inlineParent?.children?.[2]).toBe(rerenderedStandaloneParentDeckCheckbox);
|
||||
expect(inlineParent?.children?.[3]).toBe(hint);
|
||||
expect(hint.textContent).toContain("单独的父牌组");
|
||||
});
|
||||
|
||||
it("cleans folder deck mode overrides for an unchecked include folder and its descendants", async () => {
|
||||
const plugin = new FakePlugin();
|
||||
plugin.settings = normalizePluginSettings({
|
||||
|
|
@ -1789,6 +1857,7 @@ describe("PluginSettingTab", () => {
|
|||
includeFolders: ["notes"],
|
||||
folderDeckMode: "folder",
|
||||
alternateFolderDeckModeFolders: ["notes", "notes/sub"],
|
||||
standaloneParentDeckFolders: ["notes", "notes/sub"],
|
||||
});
|
||||
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
|
||||
|
||||
|
|
@ -1802,6 +1871,25 @@ describe("PluginSettingTab", () => {
|
|||
|
||||
expect(plugin.settings.includeFolders).toEqual([]);
|
||||
expect(plugin.settings.alternateFolderDeckModeFolders).toEqual([]);
|
||||
expect(plugin.settings.standaloneParentDeckFolders).toEqual([]);
|
||||
});
|
||||
|
||||
it("auto expands ancestors for saved standalone parent deck folders", async () => {
|
||||
const plugin = new FakePlugin();
|
||||
plugin.settings = normalizePluginSettings({
|
||||
...plugin.settings,
|
||||
scopeMode: "include",
|
||||
includeFolders: ["notes"],
|
||||
folderDeckMode: "folder",
|
||||
standaloneParentDeckFolders: ["notes/sub"],
|
||||
});
|
||||
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
|
||||
|
||||
tab.display();
|
||||
await queryByDataset(tab.containerEl, "settingsCardToggle", "scope").trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(queryByDataset(tab.containerEl, "folderPathLabel", "notes/sub")).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides folder deck mode override controls when folder mapping is off", async () => {
|
||||
|
|
@ -1812,6 +1900,7 @@ describe("PluginSettingTab", () => {
|
|||
includeFolders: ["notes/sub"],
|
||||
folderDeckMode: "off",
|
||||
alternateFolderDeckModeFolders: ["notes/sub"],
|
||||
standaloneParentDeckFolders: ["notes/sub"],
|
||||
});
|
||||
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
|
||||
|
||||
|
|
@ -1821,6 +1910,8 @@ describe("PluginSettingTab", () => {
|
|||
|
||||
expect(() => queryByDataset(tab.containerEl, "folderDeckModeOverride", "notes/sub")).toThrow();
|
||||
expect(plugin.settings.alternateFolderDeckModeFolders).toEqual(["notes/sub"]);
|
||||
expect(() => queryByDataset(tab.containerEl, "folderStandaloneParentDeck", "notes/sub")).toThrow();
|
||||
expect(plugin.settings.standaloneParentDeckFolders).toEqual(["notes/sub"]);
|
||||
});
|
||||
|
||||
it("shows an explicit warning when include mode has no selected folders", async () => {
|
||||
|
|
|
|||
|
|
@ -1631,10 +1631,14 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
const nextAlternateFolderDeckModeFolders = checked
|
||||
? this.plugin.settings.alternateFolderDeckModeFolders
|
||||
: this.plugin.settings.alternateFolderDeckModeFolders.filter((path) => !isPathInsideFolder(path, folderPath));
|
||||
const nextStandaloneParentDeckFolders = checked
|
||||
? this.plugin.settings.standaloneParentDeckFolders
|
||||
: this.plugin.settings.standaloneParentDeckFolders.filter((path) => !isPathInsideFolder(path, folderPath));
|
||||
|
||||
await this.plugin.updateSettings({
|
||||
includeFolders: nextSelection,
|
||||
alternateFolderDeckModeFolders: nextAlternateFolderDeckModeFolders,
|
||||
standaloneParentDeckFolders: nextStandaloneParentDeckFolders,
|
||||
});
|
||||
} else {
|
||||
await this.plugin.updateSettings({ excludeFolders: nextSelection });
|
||||
|
|
@ -1656,6 +1660,19 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
this.renderCard("scope");
|
||||
}
|
||||
|
||||
private async updateStandaloneParentDeckFolder(folderPath: string, checked: boolean): Promise<void> {
|
||||
const nextStandaloneParentDeckFolders = checked
|
||||
? (this.plugin.settings.standaloneParentDeckFolders.includes(folderPath)
|
||||
? [...this.plugin.settings.standaloneParentDeckFolders]
|
||||
: [...this.plugin.settings.standaloneParentDeckFolders, folderPath])
|
||||
: this.plugin.settings.standaloneParentDeckFolders.filter((path) => path !== folderPath);
|
||||
|
||||
await this.plugin.updateSettings({
|
||||
standaloneParentDeckFolders: nextStandaloneParentDeckFolders,
|
||||
});
|
||||
this.renderCard("scope");
|
||||
}
|
||||
|
||||
private renderFolderNode(containerEl: HTMLElement, node: FolderTreeSelectionNode, scopeMode: ScopeMode, depth: number): void {
|
||||
const row = containerEl.createDiv();
|
||||
row.dataset.folderRow = node.path;
|
||||
|
|
@ -1730,6 +1747,32 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
hintEl.dataset.folderDeckModeOverrideHint = node.path;
|
||||
addClasses(hintEl, "ahs-settings-folder-override-hint");
|
||||
}
|
||||
|
||||
if (depth > 0) {
|
||||
const standaloneParentDeckCheckbox = inlineLabelEl.createEl("input");
|
||||
const standaloneParentDeckLabel = t("settings.scope.standaloneParentDeck.ariaLabel", { name: node.name });
|
||||
const standaloneParentDeckTitle = this.getStandaloneParentDeckTitle();
|
||||
const standaloneParentDeckChecked = this.plugin.settings.standaloneParentDeckFolders.includes(node.path);
|
||||
|
||||
standaloneParentDeckCheckbox.type = "checkbox";
|
||||
standaloneParentDeckCheckbox.checked = standaloneParentDeckChecked;
|
||||
standaloneParentDeckCheckbox.dataset.folderStandaloneParentDeck = node.path;
|
||||
standaloneParentDeckCheckbox.setAttr("aria-label", standaloneParentDeckLabel);
|
||||
standaloneParentDeckCheckbox.setAttr("title", standaloneParentDeckTitle);
|
||||
addClasses(standaloneParentDeckCheckbox, "ahs-settings-folder-override-checkbox");
|
||||
standaloneParentDeckCheckbox.addEventListener("click", (event?: Event) => {
|
||||
event?.stopPropagation();
|
||||
});
|
||||
standaloneParentDeckCheckbox.addEventListener("change", () => {
|
||||
void this.updateStandaloneParentDeckFolder(node.path, standaloneParentDeckCheckbox.checked);
|
||||
});
|
||||
|
||||
if (standaloneParentDeckChecked) {
|
||||
const hintEl = inlineLabelEl.createSpan({ text: this.getStandaloneParentDeckHint() });
|
||||
hintEl.dataset.folderStandaloneParentDeckHint = node.path;
|
||||
addClasses(hintEl, "ahs-settings-folder-override-hint");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasChildren || !expanded) {
|
||||
|
|
@ -1754,13 +1797,25 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
}
|
||||
|
||||
this.scopeNeedsSelectedAncestorExpansion = false;
|
||||
for (const folderPath of this.getSelectedFoldersForScopeMode(scopeMode)) {
|
||||
for (const folderPath of this.getFoldersForAncestorExpansion(scopeMode)) {
|
||||
for (const ancestorPath of getAncestorFolderPaths(folderPath)) {
|
||||
this.expandedFolderPaths.add(ancestorPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getFoldersForAncestorExpansion(scopeMode: ScopeMode): string[] {
|
||||
if (scopeMode !== "include") {
|
||||
return this.getSelectedFoldersForScopeMode(scopeMode);
|
||||
}
|
||||
|
||||
return Array.from(new Set([
|
||||
...this.plugin.settings.includeFolders,
|
||||
...this.plugin.settings.alternateFolderDeckModeFolders,
|
||||
...this.plugin.settings.standaloneParentDeckFolders,
|
||||
]));
|
||||
}
|
||||
|
||||
private getSelectedFoldersForScopeMode(scopeMode: ScopeMode): string[] {
|
||||
return scopeMode === "include" ? this.plugin.settings.includeFolders : this.plugin.settings.excludeFolders;
|
||||
}
|
||||
|
|
@ -1781,6 +1836,14 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
return t("settings.scope.folderDeckModeOverride.title.folder");
|
||||
}
|
||||
|
||||
private getStandaloneParentDeckHint(): string {
|
||||
return t("settings.scope.standaloneParentDeck.hint");
|
||||
}
|
||||
|
||||
private getStandaloneParentDeckTitle(): string {
|
||||
return t("settings.scope.standaloneParentDeck.title");
|
||||
}
|
||||
|
||||
private createInlineControlGroup(containerEl: HTMLElement, label: string): HTMLElement {
|
||||
const groupEl = containerEl.createEl("label");
|
||||
addClasses(groupEl, "ahs-settings-inline-control-group");
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"1.0.0": "1.8.7",
|
||||
"1.0.1": "1.8.7",
|
||||
"1.0.2": "1.8.7"
|
||||
"1.0.2": "1.8.7",
|
||||
"1.0.3": "1.8.7"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue