mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
fix(sync): make saved field mappings authoritative
中文: 统一已保存字段映射为同步权威来源,移除填空题对 isCloze 与命名猜测的阻断,并让多级列表问答按已保存 titleField 与 slots 同步。 English: Makes saved field mappings the sync authority, removes cloze blocking based on isCloze and name heuristics, and syncs QA Group cards with saved title and slot mappings.
This commit is contained in:
parent
9d0e47e0ca
commit
911949bedc
15 changed files with 699 additions and 89 deletions
69
docs/field-mapping-authority-decisions.md
Normal file
69
docs/field-mapping-authority-decisions.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# Field Mapping Authority Decisions
|
||||
|
||||
## 1. 同步权威来源
|
||||
|
||||
最终统一规则:
|
||||
|
||||
1. 用户在设置里已保存的 `noteFieldMappings` 是同步权威来源
|
||||
2. 自动识别只负责“首次建议”或“草稿建议”
|
||||
3. 同步期不再让字段名/模型名/模板名猜测覆盖 saved mapping
|
||||
|
||||
## 2. Basic / Semantic QA
|
||||
|
||||
保持现有行为:
|
||||
|
||||
1. 要求 saved mapping 存在
|
||||
2. `titleField` 与 `bodyField` 都已配置
|
||||
3. 两字段必须不同
|
||||
4. 两字段仍存在于当前 Anki 字段列表中
|
||||
|
||||
## 3. Cloze
|
||||
|
||||
最终行为:
|
||||
|
||||
1. 不再使用 `isCloze` 作为同步阻断条件
|
||||
2. 不再依赖模型名 / 模板名 / 字段名猜 Cloze 兼容性
|
||||
3. 只要求 `cloze:<modelName>` saved mapping 存在
|
||||
4. `mainField` 已配置
|
||||
5. `mainField` 仍存在于当前 Anki 字段列表
|
||||
6. 真正的模型语义不兼容,由 AnkiConnect 的 `addNote` / `updateNoteFields` / `updateNoteModel` 返回真实错误
|
||||
|
||||
## 4. QA Group
|
||||
|
||||
最终行为:
|
||||
|
||||
1. 同步直接使用已保存的 `qa-group:<modelName>` mapping
|
||||
2. saved `titleField` 和 `slots` 都是权威来源
|
||||
3. 不在同步期重新 `suggest()` slots
|
||||
4. 不再要求 warnings 已确认才能同步
|
||||
5. 只校验这些确定性事实:
|
||||
- mapping 存在
|
||||
- `titleField` 已配置
|
||||
- 至少有一个 slot
|
||||
- slot 里的 question/answer 字段仍存在
|
||||
- 当前块的 item 数量不超过 saved slot 数量
|
||||
|
||||
## 5. 设置页
|
||||
|
||||
最终行为:
|
||||
|
||||
1. 读取 Anki 字段后,仍会为缺失 mapping 的卡片类型生成建议映射
|
||||
2. Basic / Cloze 继续在刷新时保留用户已选字段,只更新 `loadedFieldNames` / `loadedAt`
|
||||
3. QA Group 若已有 saved mapping,则刷新缓存时保留已保存 `titleField` / `slots`
|
||||
4. QA Group 不再在缓存刷新时自动重算 slots 覆盖 saved mapping
|
||||
|
||||
## 6. `getModelDetails()`
|
||||
|
||||
最终行为:
|
||||
|
||||
1. 继续返回字段列表
|
||||
2. 不再通过英文模型名 / 模板名 / 字段名推断 Cloze 兼容性
|
||||
3. `isCloze` 保留在 DTO 形状中,但不再承担同步阻断职责
|
||||
|
||||
## 7. 与计划的显式偏差
|
||||
|
||||
有一个实现层偏差,但不影响本次目标:
|
||||
|
||||
1. 当前仓库没有 QA Group slot 级别的手动编辑 UI
|
||||
2. 因此本轮不新增 slot 编辑器
|
||||
3. 通过“保存后不被同步期与缓存刷新期覆盖”来落实 saved mapping authority
|
||||
138
docs/field-mapping-authority-gap-report.md
Normal file
138
docs/field-mapping-authority-gap-report.md
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
# Field Mapping Authority Gap Report
|
||||
|
||||
## 审查范围
|
||||
|
||||
本轮先按真实仓库代码审查了以下控制点:
|
||||
|
||||
1. [src/application/services/NoteFieldMappingService.ts](src/application/services/NoteFieldMappingService.ts)
|
||||
2. [src/application/services/QaGroupSyncService.ts](src/application/services/QaGroupSyncService.ts)
|
||||
3. [src/application/services/QaGroupFieldMappingService.ts](src/application/services/QaGroupFieldMappingService.ts)
|
||||
4. [src/presentation/settings/PluginSettingTab.ts](src/presentation/settings/PluginSettingTab.ts)
|
||||
5. [src/infrastructure/anki/AnkiConnectGateway.ts](src/infrastructure/anki/AnkiConnectGateway.ts)
|
||||
6. [src/application/services/AnkiBatchExecutor.ts](src/application/services/AnkiBatchExecutor.ts)
|
||||
7. [src/application/services/NoteFieldMappingService.test.ts](src/application/services/NoteFieldMappingService.test.ts)
|
||||
8. [src/application/services/QaGroupSyncService.test.ts](src/application/services/QaGroupSyncService.test.ts)
|
||||
9. [src/presentation/settings/PluginSettingTab.test.ts](src/presentation/settings/PluginSettingTab.test.ts)
|
||||
10. [src/infrastructure/anki/AnkiConnectGateway.test.ts](src/infrastructure/anki/AnkiConnectGateway.test.ts)
|
||||
|
||||
## 实际实现现状
|
||||
|
||||
### 1. 普通问答 / Cloze 的同步权威入口
|
||||
|
||||
[src/application/services/AnkiBatchExecutor.ts](src/application/services/AnkiBatchExecutor.ts) 会在 addNote / updateNoteFields / updateNoteModel 前统一调用 [src/application/services/NoteFieldMappingService.ts](src/application/services/NoteFieldMappingService.ts) 做字段映射。
|
||||
|
||||
当前普通问答行为已经基本符合“已保存映射优先”:
|
||||
|
||||
1. 通过 `createNoteFieldMappingKey(card.type, card.noteModel)` 读取已保存映射
|
||||
2. 校验字段存在
|
||||
3. 再映射 title/body
|
||||
|
||||
### 2. Cloze 目前仍被 `isCloze` 阻断
|
||||
|
||||
当前 [src/application/services/NoteFieldMappingService.ts](src/application/services/NoteFieldMappingService.ts) 里有两处同步期阻断:
|
||||
|
||||
1. `validateMapping()` 中如果 `noteModelDetails.isCloze === false`,抛出 `errors.noteFieldMapping.clozeIncompatible`
|
||||
2. `mapCloze()` 中再次以 `noteModelDetails.isCloze === false` 阻断
|
||||
|
||||
这意味着:
|
||||
|
||||
1. 只要插件猜错模型是不是 cloze
|
||||
2. 即使 `cloze:<modelName>` 已保存、`mainField` 已配置、字段也仍存在
|
||||
3. 同步也会在插件侧被提前拦截
|
||||
|
||||
### 3. `getModelDetails()` 当前确实依赖英文名/模板名/字段名猜 Cloze
|
||||
|
||||
[src/infrastructure/anki/AnkiConnectGateway.ts](src/infrastructure/anki/AnkiConnectGateway.ts) 的 `getModelDetails()` 当前会:
|
||||
|
||||
1. 先看模型名是否包含 `cloze`
|
||||
2. 再看模板名是否包含 `cloze`
|
||||
3. 最后回退到字段名里是否有 `Text`
|
||||
|
||||
这正是本次要移除或降级的语言/命名型猜测来源。
|
||||
|
||||
### 4. QA Group 同步当前不是用 saved mapping 做权威
|
||||
|
||||
[src/application/services/QaGroupSyncService.ts](src/application/services/QaGroupSyncService.ts) 的 `resolveQaGroupModel()` 当前会:
|
||||
|
||||
1. 读取 `settings.noteFieldMappings[qa-group:<modelName>]`
|
||||
2. 但不直接使用保存的 `slots`
|
||||
3. 每次同步都重新调用 `qaGroupFieldMappingService.suggest(...)`
|
||||
4. 再用自动识别结果作为 `mapping`
|
||||
|
||||
因此当前行为与计划冲突:
|
||||
|
||||
1. 同步时仍由字段名模式识别决定 slots
|
||||
2. 已保存 slots 可能被忽略
|
||||
3. 自定义语言或任意字段名的 saved mapping 无法作为同步权威
|
||||
|
||||
### 5. QA Group 同步当前还会被自动识别 warnings 阻断
|
||||
|
||||
`resolveQaGroupModel()` 还会调用:
|
||||
|
||||
1. `qaGroupFieldMappingService.validateWarningsAccepted(mapping)`
|
||||
|
||||
这意味着同步仍依赖自动识别 warnings 的确认状态,而不是只依赖确定性的“保存映射是否存在 / 是否完整 / 字段是否仍存在”。
|
||||
|
||||
### 6. 设置页当前也会覆盖已保存 QA Group 映射
|
||||
|
||||
[src/presentation/settings/PluginSettingTab.ts](src/presentation/settings/PluginSettingTab.ts) 当前在以下路径会重新生成 QA Group mapping:
|
||||
|
||||
1. `seedDraftMapping()`
|
||||
2. `syncQaGroupMappingFromCache()`
|
||||
3. `getCurrentMappingForConfig()` 的兜底逻辑
|
||||
|
||||
这些路径目前只保留:
|
||||
|
||||
1. `titleField`
|
||||
2. `acceptedWarnings`
|
||||
|
||||
但会重新 `suggest()` 出新的 `slots`,从而覆盖已保存映射。
|
||||
|
||||
### 7. 设置页的 QA Group 自动识别仍有价值,但角色需要下调为“建议”
|
||||
|
||||
当前 QA Group 设置 UI:
|
||||
|
||||
1. 只允许显式选择 `titleField`
|
||||
2. `slots` 仍来自自动识别摘要
|
||||
|
||||
这说明仓库现实和计划存在一个实现层面的差异:
|
||||
|
||||
1. 当前没有 slot 级别的手动编辑 UI
|
||||
2. 因此本轮不能把“手动选 slots”做成新交互
|
||||
3. 但必须保证一旦 settings 里已有 saved slots,同步与缓存刷新都不能再覆盖它们
|
||||
|
||||
## 需要落地的缺口
|
||||
|
||||
### Cloze
|
||||
|
||||
需要改为:
|
||||
|
||||
1. 只要求 `cloze:<modelName>` 保存映射存在
|
||||
2. `mainField` 已配置
|
||||
3. `mainField` 仍存在于当前字段列表
|
||||
4. 不再以 `isCloze` 阻断
|
||||
|
||||
### QA Group
|
||||
|
||||
需要改为:
|
||||
|
||||
1. 同步直接使用 `qa-group:<modelName>` 已保存映射
|
||||
2. 不在同步期重新 `suggest()`
|
||||
3. 不再以 auto-detect warnings 阻断
|
||||
4. 只校验 saved mapping 的 title/slots/字段存在性/slot 数量是否足够
|
||||
|
||||
### 设置页
|
||||
|
||||
需要改为:
|
||||
|
||||
1. 读字段后仍可生成建议映射
|
||||
2. 但如果当前已有 QA Group saved mapping,则刷新缓存时只更新 `loadedFieldNames` / `loadedAt`
|
||||
3. 不再用 `suggest()` 覆盖已保存 `slots`
|
||||
|
||||
## 计划适配说明
|
||||
|
||||
与附带计划相比,仓库现实有一个需要明确记录的适配点:
|
||||
|
||||
1. 当前设置页没有 QA Group slot 级手动编辑 UI
|
||||
2. 所以本轮不会新增新的 slot 编辑交互
|
||||
3. 改为把“已保存 mapping 权威”落实在同步期与缓存刷新期,避免任何自动识别覆盖 saved slots
|
||||
40
main.js
40
main.js
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PluginUserError } from "@/application/errors/PluginUserError";
|
||||
import { createNoteFieldMappingKey } from "@/application/config/NoteModelFieldMapping";
|
||||
|
||||
import {
|
||||
DEFAULT_OBSIDIAN_BACKLINK_LABEL,
|
||||
|
|
@ -174,6 +175,43 @@ describe("PluginSettings", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("hydrates missing enabled basic and cloze field mappings from cached Anki fields", () => {
|
||||
const settings = mergePluginSettings({
|
||||
ankiModelFieldCache: {
|
||||
"问答题": {
|
||||
fieldNames: ["正面", "背面"],
|
||||
loadedAt: 10,
|
||||
},
|
||||
"填空题": {
|
||||
fieldNames: ["文字", "背面额外"],
|
||||
loadedAt: 20,
|
||||
},
|
||||
},
|
||||
cardTypeConfigs: {
|
||||
...DEFAULT_SETTINGS.cardTypeConfigs,
|
||||
basic: {
|
||||
...DEFAULT_SETTINGS.cardTypeConfigs.basic,
|
||||
noteType: "问答题",
|
||||
},
|
||||
cloze: {
|
||||
...DEFAULT_SETTINGS.cardTypeConfigs.cloze,
|
||||
noteType: "填空题",
|
||||
},
|
||||
},
|
||||
noteFieldMappings: {},
|
||||
});
|
||||
|
||||
expect(settings.noteFieldMappings[createNoteFieldMappingKey("basic", "问答题")]).toEqual(expect.objectContaining({
|
||||
titleField: "正面",
|
||||
bodyField: "背面",
|
||||
loadedAt: 10,
|
||||
}));
|
||||
expect(settings.noteFieldMappings[createNoteFieldMappingKey("cloze", "填空题")]).toEqual(expect.objectContaining({
|
||||
mainField: "文字",
|
||||
loadedAt: 20,
|
||||
}));
|
||||
});
|
||||
|
||||
it("migrates the old default cloze recognition to H4 + #anki-cloze", () => {
|
||||
const settings = mergePluginSettings({
|
||||
cardTypeConfigs: {
|
||||
|
|
|
|||
|
|
@ -125,8 +125,7 @@ export function normalizeObsidianBacklinkLabel(value: string | null | undefined)
|
|||
export function normalizePluginSettings(settings: PluginSettings): PluginSettings {
|
||||
const cardTypeConfigs = mergeCardTypeConfigs(settings.cardTypeConfigs, settings);
|
||||
const legacySettings = deriveLegacySettings(cardTypeConfigs);
|
||||
|
||||
return {
|
||||
const normalizedSettings = {
|
||||
...settings,
|
||||
...legacySettings,
|
||||
cardTypeConfigs,
|
||||
|
|
@ -135,6 +134,11 @@ export function normalizePluginSettings(settings: PluginSettings): PluginSetting
|
|||
ankiModelFieldCache: normalizeAnkiModelFieldCache(settings.ankiModelFieldCache),
|
||||
obsidianBacklinkLabel: normalizeObsidianBacklinkLabel(settings.obsidianBacklinkLabel),
|
||||
};
|
||||
|
||||
return {
|
||||
...normalizedSettings,
|
||||
noteFieldMappings: hydrateMissingNoteFieldMappingsFromCache(normalizedSettings),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergePluginSettings(settings?: Partial<PluginSettings> | null): PluginSettings {
|
||||
|
|
@ -568,6 +572,61 @@ function normalizeNoteFieldMappings(value: unknown): Record<string, NoteModelFie
|
|||
return normalizedMappings;
|
||||
}
|
||||
|
||||
function hydrateMissingNoteFieldMappingsFromCache(settings: PluginSettings): Record<string, NoteModelFieldMapping> {
|
||||
const hydratedMappings = { ...settings.noteFieldMappings };
|
||||
|
||||
for (const configId of CARD_TYPE_CONFIG_IDS) {
|
||||
if (configId === "qa-group") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const config = settings.cardTypeConfigs[configId];
|
||||
if (!config.enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const modelName = config.noteType.trim();
|
||||
if (modelName.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cardType = configId === "cloze" ? "cloze" : configId === "semantic-qa" ? "semantic-qa" : "basic";
|
||||
const mappingKey = createNoteFieldMappingKey(cardType, modelName);
|
||||
if (hydratedMappings[mappingKey]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cacheEntry = settings.ankiModelFieldCache[modelName];
|
||||
if (!cacheEntry || cacheEntry.fieldNames.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldNames = [...cacheEntry.fieldNames];
|
||||
hydratedMappings[mappingKey] = cardType === "cloze"
|
||||
? {
|
||||
cardType,
|
||||
modelName,
|
||||
loadedFieldNames: fieldNames,
|
||||
mainField: findPreferredFieldName(fieldNames, ["Text", "Body", "Content"]) ?? fieldNames[0],
|
||||
loadedAt: cacheEntry.loadedAt,
|
||||
}
|
||||
: {
|
||||
cardType,
|
||||
modelName,
|
||||
loadedFieldNames: fieldNames,
|
||||
titleField: findPreferredFieldName(fieldNames, ["Front", "Title"]) ?? fieldNames[0],
|
||||
bodyField: findPreferredFieldName(fieldNames, ["Back", "Body", "Answer"]) ?? fieldNames.find((fieldName) => fieldName !== (findPreferredFieldName(fieldNames, ["Front", "Title"]) ?? fieldNames[0])),
|
||||
loadedAt: cacheEntry.loadedAt,
|
||||
};
|
||||
}
|
||||
|
||||
return hydratedMappings;
|
||||
}
|
||||
|
||||
function findPreferredFieldName(fieldNames: string[], preferredNames: string[]): string | undefined {
|
||||
return fieldNames.find((fieldName) => preferredNames.some((preferredName) => preferredName.toLowerCase() === fieldName.toLowerCase()));
|
||||
}
|
||||
|
||||
function normalizeQaGroupFieldMapping(
|
||||
mapping: Record<string, unknown>,
|
||||
modelName: string,
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ describe("NoteFieldMappingService", () => {
|
|||
}),
|
||||
{
|
||||
fieldNames: ["Text", "Extra"],
|
||||
isCloze: true,
|
||||
isCloze: false,
|
||||
},
|
||||
{
|
||||
[createNoteFieldMappingKey("cloze", "Cloze")]: {
|
||||
|
|
@ -92,6 +92,61 @@ describe("NoteFieldMappingService", () => {
|
|||
expect(fields).toEqual({ Text: "Context<br><br>{{c1::answer}}" });
|
||||
});
|
||||
|
||||
it("throws when a cloze main field is missing from the saved mapping", () => {
|
||||
const service = new NoteFieldMappingService();
|
||||
|
||||
const error = expectPluginUserError(() =>
|
||||
service.map(
|
||||
createCard({
|
||||
type: "cloze",
|
||||
noteModel: "Cloze",
|
||||
}),
|
||||
{ fieldNames: ["Text", "Extra"], isCloze: false },
|
||||
{
|
||||
[createNoteFieldMappingKey("cloze", "Cloze")]: {
|
||||
cardType: "cloze",
|
||||
modelName: "Cloze",
|
||||
loadedFieldNames: ["Text", "Extra"],
|
||||
mainField: undefined,
|
||||
loadedAt: 1,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(error.userMessage.key).toBe("errors.noteFieldMapping.incompleteSavedMapping.cloze");
|
||||
expect(error.userMessage.params).toEqual({ modelName: "Cloze" });
|
||||
});
|
||||
|
||||
it("throws when a cloze main field is stale even if isCloze is false", () => {
|
||||
const service = new NoteFieldMappingService();
|
||||
|
||||
const error = expectPluginUserError(() =>
|
||||
service.map(
|
||||
createCard({
|
||||
type: "cloze",
|
||||
noteModel: "Cloze",
|
||||
}),
|
||||
{ fieldNames: ["Body", "Extra"], isCloze: false },
|
||||
{
|
||||
[createNoteFieldMappingKey("cloze", "Cloze")]: {
|
||||
cardType: "cloze",
|
||||
modelName: "Cloze",
|
||||
loadedFieldNames: ["Text", "Extra"],
|
||||
mainField: "Text",
|
||||
loadedAt: 1,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(error.userMessage.key).toBe("errors.noteFieldMapping.stale");
|
||||
expect(error.userMessage.params).toEqual({
|
||||
modelName: "Cloze",
|
||||
fields: ["\"Text\""],
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when a mapping is missing", () => {
|
||||
const service = new NoteFieldMappingService();
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export class NoteFieldMappingService {
|
|||
return this.mapBasic(card, mapping as BasicLikeNoteModelFieldMapping);
|
||||
}
|
||||
|
||||
return this.mapCloze(card, noteModelDetails, mapping as ClozeNoteModelFieldMapping);
|
||||
return this.mapCloze(card, mapping as ClozeNoteModelFieldMapping);
|
||||
}
|
||||
|
||||
suggest(cardType: NoteModelFieldMappingCardType, modelName: string, fieldNames: string[], loadedAt = Date.now()): NoteModelFieldMapping {
|
||||
|
|
@ -103,12 +103,6 @@ export class NoteFieldMappingService {
|
|||
});
|
||||
}
|
||||
|
||||
if (!noteModelDetails.isCloze) {
|
||||
throw new PluginUserError("errors.noteFieldMapping.clozeIncompatible", {
|
||||
modelName: mapping.modelName,
|
||||
});
|
||||
}
|
||||
|
||||
if (!availableFields.has(mapping.mainField)) {
|
||||
throw new PluginUserError("errors.noteFieldMapping.stale", {
|
||||
modelName: mapping.modelName,
|
||||
|
|
@ -133,13 +127,7 @@ export class NoteFieldMappingService {
|
|||
};
|
||||
}
|
||||
|
||||
private mapCloze(card: RenderedCardInput, noteModelDetails: NoteModelDetails, mapping: ClozeNoteModelFieldMapping): Record<string, string> {
|
||||
if (!noteModelDetails.isCloze) {
|
||||
throw new PluginUserError("errors.noteFieldMapping.clozeIncompatible", {
|
||||
modelName: card.noteModel,
|
||||
});
|
||||
}
|
||||
|
||||
private mapCloze(card: RenderedCardInput, mapping: ClozeNoteModelFieldMapping): Record<string, string> {
|
||||
if (!mapping.mainField) {
|
||||
throw new PluginUserError("errors.noteFieldMapping.incompleteSavedMapping.cloze", {
|
||||
modelName: card.noteModel,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,85 @@ describe("QaGroupSyncService", () => {
|
|||
expect(ankiGateway.addedNotes[0]?.fields).not.toHaveProperty("题目");
|
||||
});
|
||||
|
||||
it("uses the saved QA Group slots as sync authority instead of re-detecting field names", async () => {
|
||||
const ankiGateway = new FakeManualSyncAnkiGateway();
|
||||
ankiGateway.modelDetailsByName[QA_GROUP_USER_NOTE_TYPE] = {
|
||||
fieldNames: ["题目", "问题01", "答案01", "Titre", "QPerso", "APerso"],
|
||||
isCloze: false,
|
||||
};
|
||||
const service = new QaGroupSyncService(ankiGateway);
|
||||
const baseSettings = createModule3Settings();
|
||||
|
||||
await service.sync([
|
||||
createIndexedGroupBlock({
|
||||
items: [{ title: "Alpha", answer: "First answer", ordinalInMarkdown: 1 }],
|
||||
}),
|
||||
], createEmptyPluginState(), {
|
||||
...baseSettings,
|
||||
noteFieldMappings: {
|
||||
...baseSettings.noteFieldMappings,
|
||||
[`qa-group:${QA_GROUP_USER_NOTE_TYPE}`]: {
|
||||
cardType: "qa-group",
|
||||
modelName: QA_GROUP_USER_NOTE_TYPE,
|
||||
loadedFieldNames: ["题目", "问题01", "答案01", "Titre", "QPerso", "APerso"],
|
||||
titleField: "Titre",
|
||||
slots: [
|
||||
{ index: 1, questionField: "QPerso", answerField: "APerso" },
|
||||
],
|
||||
warnings: [],
|
||||
loadedAt: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(ankiGateway.addedNotes[0]?.fields).toMatchObject({
|
||||
Titre: "Concepts",
|
||||
QPerso: "Alpha",
|
||||
});
|
||||
expect(ankiGateway.addedNotes[0]?.fields.APerso).toContain("First answer");
|
||||
expect(ankiGateway.addedNotes[0]?.fields).not.toHaveProperty("题目");
|
||||
expect(ankiGateway.addedNotes[0]?.fields).not.toHaveProperty("问题01", "Alpha");
|
||||
});
|
||||
|
||||
it("syncs QA Group notes with saved arbitrary field names when the fields still exist", async () => {
|
||||
const ankiGateway = new FakeManualSyncAnkiGateway();
|
||||
ankiGateway.modelDetailsByName[QA_GROUP_USER_NOTE_TYPE] = {
|
||||
fieldNames: ["Titre", "QuestionLibre", "ReponseLibre"],
|
||||
isCloze: false,
|
||||
};
|
||||
const service = new QaGroupSyncService(ankiGateway);
|
||||
const baseSettings = createModule3Settings();
|
||||
|
||||
const result = await service.sync([
|
||||
createIndexedGroupBlock({
|
||||
items: [{ title: "Alpha", answer: "Premiere reponse", ordinalInMarkdown: 1 }],
|
||||
}),
|
||||
], createEmptyPluginState(), {
|
||||
...baseSettings,
|
||||
noteFieldMappings: {
|
||||
...baseSettings.noteFieldMappings,
|
||||
[`qa-group:${QA_GROUP_USER_NOTE_TYPE}`]: {
|
||||
cardType: "qa-group",
|
||||
modelName: QA_GROUP_USER_NOTE_TYPE,
|
||||
loadedFieldNames: ["Titre", "QuestionLibre", "ReponseLibre"],
|
||||
titleField: "Titre",
|
||||
slots: [
|
||||
{ index: 1, questionField: "QuestionLibre", answerField: "ReponseLibre" },
|
||||
],
|
||||
warnings: [],
|
||||
loadedAt: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.created).toBe(1);
|
||||
expect(ankiGateway.addedNotes[0]?.fields).toMatchObject({
|
||||
Titre: "Concepts",
|
||||
QuestionLibre: "Alpha",
|
||||
});
|
||||
expect(ankiGateway.addedNotes[0]?.fields.ReponseLibre).toContain("Premiere reponse");
|
||||
});
|
||||
|
||||
it("renders QA Group stem, questions, and answers as inline Markdown HTML", async () => {
|
||||
const ankiGateway = new FakeManualSyncAnkiGateway();
|
||||
const vaultGateway = new FakeManualSyncVaultGateway();
|
||||
|
|
@ -628,10 +707,10 @@ describe("QaGroupSyncService", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("blocks sync when the selected QA Group template still has unconfirmed field warnings", async () => {
|
||||
it("fails clearly when a saved QA Group field mapping points to fields that no longer exist", async () => {
|
||||
const ankiGateway = new FakeManualSyncAnkiGateway();
|
||||
ankiGateway.modelDetailsByName[QA_GROUP_USER_NOTE_TYPE] = {
|
||||
fieldNames: ["题目", "问题01", "答案01", "问题02"],
|
||||
fieldNames: ["题目", "问题01"],
|
||||
isCloze: false,
|
||||
};
|
||||
const service = new QaGroupSyncService(ankiGateway);
|
||||
|
|
@ -639,6 +718,40 @@ describe("QaGroupSyncService", () => {
|
|||
|
||||
await expect(service.sync([
|
||||
createIndexedGroupBlock(),
|
||||
], createEmptyPluginState(), {
|
||||
...baseSettings,
|
||||
noteFieldMappings: {
|
||||
...baseSettings.noteFieldMappings,
|
||||
[`qa-group:${QA_GROUP_USER_NOTE_TYPE}`]: {
|
||||
cardType: "qa-group",
|
||||
modelName: QA_GROUP_USER_NOTE_TYPE,
|
||||
loadedFieldNames: ["题目", "问题01", "答案01"],
|
||||
titleField: "题目",
|
||||
slots: [{ index: 1, questionField: "问题01", answerField: "答案01" }],
|
||||
warnings: [],
|
||||
loadedAt: 1,
|
||||
},
|
||||
},
|
||||
})).rejects.toMatchObject({
|
||||
userMessage: {
|
||||
key: "errors.noteFieldMapping.stale",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not block sync when a saved QA Group mapping still carries unconfirmed auto-detection warnings", async () => {
|
||||
const ankiGateway = new FakeManualSyncAnkiGateway();
|
||||
ankiGateway.modelDetailsByName[QA_GROUP_USER_NOTE_TYPE] = {
|
||||
fieldNames: ["题目", "问题01", "答案01", "问题02"],
|
||||
isCloze: false,
|
||||
};
|
||||
const service = new QaGroupSyncService(ankiGateway);
|
||||
const baseSettings = createModule3Settings();
|
||||
|
||||
const result = await service.sync([
|
||||
createIndexedGroupBlock({
|
||||
items: [{ title: "Alpha", answer: "First answer", ordinalInMarkdown: 1 }],
|
||||
}),
|
||||
], createEmptyPluginState(), {
|
||||
...baseSettings,
|
||||
noteFieldMappings: {
|
||||
|
|
@ -653,9 +766,30 @@ describe("QaGroupSyncService", () => {
|
|||
loadedAt: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.created).toBe(1);
|
||||
expect(ankiGateway.addedNotes[0]?.fields).toMatchObject({
|
||||
题目: "Concepts",
|
||||
问题01: "Alpha",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails clearly when QA Group sync starts without a saved field mapping", async () => {
|
||||
const ankiGateway = new FakeManualSyncAnkiGateway();
|
||||
const service = new QaGroupSyncService(ankiGateway);
|
||||
const baseSettings = createModule3Settings();
|
||||
const nextMappings = { ...baseSettings.noteFieldMappings };
|
||||
delete nextMappings[`qa-group:${QA_GROUP_USER_NOTE_TYPE}`];
|
||||
|
||||
await expect(service.sync([
|
||||
createIndexedGroupBlock(),
|
||||
], createEmptyPluginState(), {
|
||||
...baseSettings,
|
||||
noteFieldMappings: nextMappings,
|
||||
})).rejects.toMatchObject({
|
||||
userMessage: {
|
||||
key: "errors.noteFieldMapping.qaGroupWarningsUnaccepted",
|
||||
key: "errors.noteFieldMapping.missingSavedMapping.qaGroup",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -482,16 +482,23 @@ export class QaGroupSyncService {
|
|||
}
|
||||
|
||||
const storedMapping = settings.noteFieldMappings[createNoteFieldMappingKey("qa-group", modelName)];
|
||||
if (!isQaGroupFieldMapping(storedMapping)) {
|
||||
throw new PluginUserError("errors.noteFieldMapping.missingSavedMapping.qaGroup", {
|
||||
modelName,
|
||||
});
|
||||
}
|
||||
|
||||
const fieldNames = await this.ankiGateway.getModelFieldNames(modelName);
|
||||
const mapping = this.qaGroupFieldMappingService.suggest(
|
||||
const mapping: QaGroupFieldMapping = {
|
||||
...storedMapping,
|
||||
modelName,
|
||||
fieldNames,
|
||||
this.now(),
|
||||
isQaGroupFieldMapping(storedMapping) ? storedMapping.acceptedWarnings : undefined,
|
||||
isQaGroupFieldMapping(storedMapping) ? storedMapping.titleField : undefined,
|
||||
);
|
||||
loadedFieldNames: [...fieldNames],
|
||||
slots: storedMapping.slots.map((slot) => ({ ...slot })),
|
||||
warnings: [...storedMapping.warnings],
|
||||
acceptedWarnings: storedMapping.acceptedWarnings ? [...storedMapping.acceptedWarnings] : undefined,
|
||||
loadedAt: this.now(),
|
||||
};
|
||||
this.qaGroupFieldMappingService.validateMapping(mapping, fieldNames);
|
||||
this.qaGroupFieldMappingService.validateWarningsAccepted(mapping);
|
||||
|
||||
return {
|
||||
modelName,
|
||||
|
|
|
|||
|
|
@ -125,29 +125,20 @@ describe("AnkiConnectGateway", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("loads model fields and detects cloze templates", async () => {
|
||||
requestUrlMock
|
||||
.mockResolvedValueOnce({
|
||||
json: {
|
||||
error: null,
|
||||
result: ["Text", "Extra"],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
json: {
|
||||
error: null,
|
||||
result: {
|
||||
"Cloze Card": {},
|
||||
},
|
||||
},
|
||||
});
|
||||
it("loads model fields without inferring cloze compatibility from names", async () => {
|
||||
requestUrlMock.mockResolvedValueOnce({
|
||||
json: {
|
||||
error: null,
|
||||
result: ["Text", "Extra"],
|
||||
},
|
||||
});
|
||||
|
||||
const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765");
|
||||
const details = await gateway.getModelDetails("My Cloze");
|
||||
|
||||
expect(details).toEqual({
|
||||
fieldNames: ["Text", "Extra"],
|
||||
isCloze: true,
|
||||
isCloze: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -217,19 +217,9 @@ export class AnkiConnectGateway implements AnkiGroupGateway {
|
|||
}
|
||||
|
||||
async getModelDetails(modelName: string): Promise<NoteModelDetails> {
|
||||
const fieldNames = await this.getModelFieldNames(modelName);
|
||||
let isCloze = modelName.toLowerCase().includes("cloze");
|
||||
|
||||
try {
|
||||
const templates = await this.getModelTemplates(modelName);
|
||||
isCloze = isCloze || Object.keys(templates).some((templateName) => templateName.toLowerCase().includes("cloze"));
|
||||
} catch {
|
||||
isCloze = isCloze || fieldNames.some((fieldName) => fieldName.toLowerCase() === "text");
|
||||
}
|
||||
|
||||
return {
|
||||
fieldNames,
|
||||
isCloze,
|
||||
fieldNames: await this.getModelFieldNames(modelName),
|
||||
isCloze: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -428,6 +428,7 @@ export const en = {
|
|||
basic: "No saved field mapping found for basic note type \"{{modelName}}\". Open plugin settings and read fields from Anki first.",
|
||||
cloze: "No saved field mapping found for cloze note type \"{{modelName}}\". Open plugin settings and read fields from Anki first.",
|
||||
semanticQa: "No saved field mapping found for semantic QA note type \"{{modelName}}\". Open plugin settings and read fields from Anki first.",
|
||||
qaGroup: "No saved field mapping found for QA Group note type \"{{modelName}}\". Open plugin settings, read fields from Anki, and save the mapping first.",
|
||||
},
|
||||
incompleteSavedMapping: {
|
||||
basic: "Saved field mapping for basic note type \"{{modelName}}\" is incomplete. Open plugin settings and save both title and body fields.",
|
||||
|
|
@ -440,11 +441,11 @@ export const en = {
|
|||
},
|
||||
stale: "Saved field mapping for note type \"{{modelName}}\" is stale because these fields no longer exist in Anki: {{fields}}. Open plugin settings and read fields from Anki again.",
|
||||
clozeIncompatible: "Cloze note type \"{{modelName}}\" is not cloze-compatible in Anki.",
|
||||
qaGroupMissingTitle: "QA Group note type \"{{modelName}}\" does not expose a recognizable title field. Use one of 题目, 标题, 正面, Stem, or Title.",
|
||||
qaGroupNoCompleteSlots: "QA Group note type \"{{modelName}}\" does not expose any complete question/answer slot pairs.",
|
||||
qaGroupMissingTitle: "Saved field mapping for QA Group note type \"{{modelName}}\" is incomplete because the title field is missing. Save the mapping again in settings.",
|
||||
qaGroupNoCompleteSlots: "Saved field mapping for QA Group note type \"{{modelName}}\" is incomplete because it has no saved question/answer slot pairs. Save the mapping again in settings.",
|
||||
qaGroupNonContinuousSlots: "QA Group note type \"{{modelName}}\" must start its question/answer slots at pair {{firstIndex}} = 1. Renumber the first pair to 1.",
|
||||
qaGroupWarningsUnaccepted: "QA Group note type \"{{modelName}}\" still has unconfirmed field warnings. Confirm them in settings before syncing.",
|
||||
qaGroupSlotCapacityExceeded: "QA Group note type \"{{modelName}}\" only exposes {{capacity}} question/answer pair(s), but the current block has {{itemCount}} item(s): {{filePath}}:{{blockStartLine}}.",
|
||||
qaGroupSlotCapacityExceeded: "Saved field mapping for QA Group note type \"{{modelName}}\" only includes {{capacity}} question/answer pair(s), but the current block has {{itemCount}} item(s): {{filePath}}:{{blockStartLine}}.",
|
||||
},
|
||||
noteTypeMigration: {
|
||||
failed: "Failed to migrate Anki note {{noteId}} from \"{{fromModel}}\" to \"{{toModel}}\"{{location}}: {{reason}}",
|
||||
|
|
|
|||
|
|
@ -426,6 +426,7 @@ export const zh = {
|
|||
basic: "找不到基础卡笔记类型 \"{{modelName}}\" 的已保存字段映射。请打开插件设置并先从 Anki 读取字段。",
|
||||
cloze: "找不到填空题笔记类型 \"{{modelName}}\" 的已保存字段映射。请打开插件设置并先从 Anki 读取字段。",
|
||||
semanticQa: "找不到语义 QA 笔记类型 \"{{modelName}}\" 的已保存字段映射。请打开插件设置并先从 Anki 读取字段。",
|
||||
qaGroup: "找不到问答题(多级列表)笔记模板 \"{{modelName}}\" 的已保存字段映射。请先在设置页从 Anki 读取字段并保存映射。",
|
||||
},
|
||||
incompleteSavedMapping: {
|
||||
basic: "基础卡笔记类型 \"{{modelName}}\" 的已保存字段映射不完整。请打开插件设置并保存标题字段和正文字段。",
|
||||
|
|
@ -438,11 +439,11 @@ export const zh = {
|
|||
},
|
||||
stale: "笔记类型 \"{{modelName}}\" 的已保存字段映射已过期,因为这些字段在 Anki 中已不存在:{{fields}}。请重新从 Anki 读取字段。",
|
||||
clozeIncompatible: "填空题笔记类型 \"{{modelName}}\" 在 Anki 中不是填空题兼容模型。",
|
||||
qaGroupMissingTitle: "问答题(多级列表)笔记模板 \"{{modelName}}\" 缺少可识别的题目字段。请使用 题目、标题、正面、Stem 或 Title 之一。",
|
||||
qaGroupNoCompleteSlots: "问答题(多级列表)笔记模板 \"{{modelName}}\" 没有可用的完整问题/答案字段组。",
|
||||
qaGroupMissingTitle: "问答题(多级列表)笔记模板 \"{{modelName}}\" 的已保存字段映射不完整,缺少标题字段。请在设置页重新保存字段映射。",
|
||||
qaGroupNoCompleteSlots: "问答题(多级列表)笔记模板 \"{{modelName}}\" 的已保存字段映射不完整,至少需要一组问题/答案字段。请在设置页重新保存字段映射。",
|
||||
qaGroupNonContinuousSlots: "问答题(多级列表)笔记模板 \"{{modelName}}\" 的第一组问题/答案字段不是从第 {{firstIndex}} 组开始连续编号。请从第 1 组开始。",
|
||||
qaGroupWarningsUnaccepted: "问答题(多级列表)笔记模板 \"{{modelName}}\" 仍有未确认的字段警告。请先在设置页确认后再同步。",
|
||||
qaGroupSlotCapacityExceeded: "问答题(多级列表)笔记模板 \"{{modelName}}\" 只识别到 {{capacity}} 组问题/答案字段,但当前块有 {{itemCount}} 项:{{filePath}}:{{blockStartLine}}。",
|
||||
qaGroupSlotCapacityExceeded: "问答题(多级列表)笔记模板 \"{{modelName}}\" 的已保存字段映射只包含 {{capacity}} 组问题/答案字段,但当前块有 {{itemCount}} 项:{{filePath}}:{{blockStartLine}}。",
|
||||
},
|
||||
noteTypeMigration: {
|
||||
failed: "无法将 Anki 笔记 {{noteId}} 从“{{fromModel}}”迁移到“{{toModel}}”{{location}}:{{reason}}",
|
||||
|
|
|
|||
|
|
@ -1255,6 +1255,11 @@ describe("PluginSettingTab", () => {
|
|||
const basicAnswerFieldSelect = queryByDataset(tab.containerEl, "cardTypeAnswerField", "basic");
|
||||
expect(collectOptionValues(basicQuestionFieldSelect)).toEqual(["", "Front", "Back", "Extra"]);
|
||||
expect(collectOptionValues(basicAnswerFieldSelect)).toEqual(["", "Front", "Back", "Extra"]);
|
||||
expect(plugin.settings.noteFieldMappings[createNoteFieldMappingKey("basic", "Basic")]).toEqual(expect.objectContaining({
|
||||
titleField: "Front",
|
||||
bodyField: "Back",
|
||||
loadedFieldNames: ["Front", "Back", "Extra"],
|
||||
}));
|
||||
expect(plugin.getNoteModelDetailsCalls).toBe(0);
|
||||
});
|
||||
|
||||
|
|
@ -1299,9 +1304,62 @@ describe("PluginSettingTab", () => {
|
|||
|
||||
expect(plugin.settings.noteFieldMappings[mappingKey]).toEqual(expect.objectContaining({
|
||||
warnings: expect.any(Array),
|
||||
acceptedWarnings: undefined,
|
||||
acceptedWarnings: expect.any(Array),
|
||||
}));
|
||||
expect(queryByDataset(tab.containerEl, "qaGroupWarning", "qa-group").textContent).toContain("第 03 组缺少答案字段");
|
||||
expect(queryByDataset(tab.containerEl, "qaGroupWarning", "qa-group").textContent).toContain("第 02 组缺少答案字段");
|
||||
});
|
||||
|
||||
it("keeps the saved qa-group title field and slots when reading Anki fields again", async () => {
|
||||
const plugin = new FakePlugin();
|
||||
plugin.settings = normalizePluginSettings({
|
||||
...plugin.settings,
|
||||
ankiNoteTypeCache: [QA_GROUP_USER_MODEL_NAME],
|
||||
ankiModelFieldCache: {
|
||||
[QA_GROUP_USER_MODEL_NAME]: {
|
||||
fieldNames: ["题目", "问题01", "答案01", "Titre", "QPerso", "APerso"],
|
||||
loadedAt: 100,
|
||||
},
|
||||
},
|
||||
cardTypeConfigs: {
|
||||
...plugin.settings.cardTypeConfigs,
|
||||
"qa-group": {
|
||||
...plugin.settings.cardTypeConfigs["qa-group"],
|
||||
noteType: QA_GROUP_USER_MODEL_NAME,
|
||||
},
|
||||
},
|
||||
noteFieldMappings: {
|
||||
...plugin.settings.noteFieldMappings,
|
||||
[createNoteFieldMappingKey("qa-group", QA_GROUP_USER_MODEL_NAME)]: {
|
||||
cardType: "qa-group",
|
||||
modelName: QA_GROUP_USER_MODEL_NAME,
|
||||
loadedFieldNames: ["Titre", "QPerso", "APerso"],
|
||||
titleField: "Titre",
|
||||
slots: [
|
||||
{ index: 1, questionField: "QPerso", answerField: "APerso" },
|
||||
],
|
||||
warnings: [],
|
||||
loadedAt: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
plugin.fieldNamesByModelName[QA_GROUP_USER_MODEL_NAME] = ["题目", "问题01", "答案01", "Titre", "QPerso", "APerso"];
|
||||
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
|
||||
|
||||
tab.display();
|
||||
await expandCard(tab, "card-types");
|
||||
await queryByDataset(tab.containerEl, "cardTypesRefresh", "true").trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
const mappingKey = createNoteFieldMappingKey("qa-group", QA_GROUP_USER_MODEL_NAME);
|
||||
expect(plugin.settings.noteFieldMappings[mappingKey]).toEqual(expect.objectContaining({
|
||||
titleField: "Titre",
|
||||
slots: [
|
||||
{ index: 1, questionField: "QPerso", answerField: "APerso" },
|
||||
],
|
||||
loadedFieldNames: ["题目", "问题01", "答案01", "Titre", "QPerso", "APerso"],
|
||||
}));
|
||||
expect(queryByDataset(tab.containerEl, "qaGroupTitleField", "qa-group").value).toBe("Titre");
|
||||
expect(queryByDataset(tab.containerEl, "qaGroupSlotSummary", "qa-group").textContent).toBe("已识别 1 组");
|
||||
});
|
||||
|
||||
it("renders the scope card with renamed copy and aligned folder tree rows", async () => {
|
||||
|
|
|
|||
|
|
@ -880,6 +880,10 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
}
|
||||
}
|
||||
|
||||
if ((configId === "basic" || configId === "cloze") && typeof partialConfig.noteType === "string") {
|
||||
await this.syncFieldMappingFromCache(configId);
|
||||
}
|
||||
|
||||
this.renderCard("card-types");
|
||||
return true;
|
||||
}
|
||||
|
|
@ -937,6 +941,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
});
|
||||
|
||||
this.hydrateVisibleCardTypeCaches(ankiModelFieldCache);
|
||||
await this.syncVisibleFieldMappingsFromCache(ankiModelFieldCache);
|
||||
await this.syncQaGroupMappingFromCache(this.plugin.settings.cardTypeConfigs["qa-group"].noteType, ankiModelFieldCache);
|
||||
const configuredCount = this.countConfiguredCardTypes(ankiModelFieldCache);
|
||||
|
||||
|
|
@ -1080,10 +1085,56 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
this.seedDraftMapping(runtimeCardType, modelName, modelDetails, cacheEntry.loadedAt);
|
||||
}
|
||||
|
||||
private async syncVisibleFieldMappingsFromCache(
|
||||
ankiModelFieldCache: AnkiHeadingSyncPlugin["settings"]["ankiModelFieldCache"] = this.plugin.settings.ankiModelFieldCache,
|
||||
): Promise<void> {
|
||||
for (const configId of VISIBLE_CARD_TYPE_CONFIG_IDS) {
|
||||
if (configId === "qa-group") {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.syncFieldMappingFromCache(configId, ankiModelFieldCache);
|
||||
}
|
||||
}
|
||||
|
||||
private async syncFieldMappingFromCache(
|
||||
configId: Extract<CardTypeConfigId, "basic" | "cloze">,
|
||||
ankiModelFieldCache: AnkiHeadingSyncPlugin["settings"]["ankiModelFieldCache"] = this.plugin.settings.ankiModelFieldCache,
|
||||
): Promise<void> {
|
||||
const runtimeCardType = this.getRuntimeCardType(configId);
|
||||
const modelName = this.plugin.settings.cardTypeConfigs[configId].noteType.trim();
|
||||
const cacheEntry = ankiModelFieldCache[modelName];
|
||||
if (!cacheEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mappingKey = createNoteFieldMappingKey(runtimeCardType, modelName);
|
||||
const currentMapping = this.plugin.settings.noteFieldMappings[mappingKey] ?? this.draftMappings[mappingKey];
|
||||
const nextMapping = currentMapping
|
||||
? {
|
||||
...currentMapping,
|
||||
loadedFieldNames: [...cacheEntry.fieldNames],
|
||||
loadedAt: cacheEntry.loadedAt,
|
||||
} as NoteModelFieldMapping
|
||||
: this.noteFieldMappingService.suggest(runtimeCardType, modelName, cacheEntry.fieldNames, cacheEntry.loadedAt);
|
||||
|
||||
this.draftMappings[mappingKey] = nextMapping;
|
||||
if (areMappingsEqual(this.plugin.settings.noteFieldMappings[mappingKey], nextMapping)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.plugin.updateSettings({
|
||||
noteFieldMappings: {
|
||||
...this.plugin.settings.noteFieldMappings,
|
||||
[mappingKey]: nextMapping,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private createCachedModelDetails(modelName: string, fieldNames: string[]): NoteModelDetails {
|
||||
return {
|
||||
fieldNames: [...fieldNames],
|
||||
isCloze: modelName.toLowerCase().includes("cloze"),
|
||||
isCloze: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1103,13 +1154,18 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
const currentMapping = this.plugin.settings.noteFieldMappings[mappingKey] ?? this.draftMappings[mappingKey];
|
||||
|
||||
if (runtimeCardType === "qa-group") {
|
||||
if (isQaGroupFieldMapping(currentMapping)) {
|
||||
this.draftMappings[mappingKey] = this.refreshSavedQaGroupMapping(currentMapping, modelDetails.fieldNames, loadedAt);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.draftMappings[mappingKey] = this.qaGroupFieldMappingService.suggest(
|
||||
modelName,
|
||||
modelDetails.fieldNames,
|
||||
loadedAt,
|
||||
isQaGroupFieldMapping(currentMapping) ? currentMapping.acceptedWarnings : undefined,
|
||||
isQaGroupFieldMapping(currentMapping) ? currentMapping.titleField : undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
} catch {
|
||||
delete this.draftMappings[mappingKey];
|
||||
|
|
@ -1277,13 +1333,27 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
const mappingKey = createNoteFieldMappingKey("qa-group", trimmedModelName);
|
||||
const currentMapping = this.plugin.settings.noteFieldMappings[mappingKey] ?? this.draftMappings[mappingKey];
|
||||
|
||||
if (isQaGroupFieldMapping(currentMapping)) {
|
||||
const nextMapping = this.refreshSavedQaGroupMapping(currentMapping, cacheEntry.fieldNames, cacheEntry.loadedAt);
|
||||
this.draftMappings[mappingKey] = nextMapping;
|
||||
if (!areMappingsEqual(this.plugin.settings.noteFieldMappings[mappingKey], nextMapping)) {
|
||||
await this.plugin.updateSettings({
|
||||
noteFieldMappings: {
|
||||
...this.plugin.settings.noteFieldMappings,
|
||||
[mappingKey]: nextMapping,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const nextMapping = this.qaGroupFieldMappingService.suggest(
|
||||
trimmedModelName,
|
||||
cacheEntry.fieldNames,
|
||||
cacheEntry.loadedAt,
|
||||
isQaGroupFieldMapping(currentMapping) ? currentMapping.acceptedWarnings : undefined,
|
||||
isQaGroupFieldMapping(currentMapping) ? currentMapping.titleField : undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
this.draftMappings[mappingKey] = nextMapping;
|
||||
|
|
@ -1344,6 +1414,17 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
|
|||
}
|
||||
}
|
||||
|
||||
private refreshSavedQaGroupMapping(mapping: QaGroupFieldMapping, fieldNames: string[], loadedAt: number): QaGroupFieldMapping {
|
||||
return {
|
||||
...mapping,
|
||||
loadedFieldNames: [...fieldNames],
|
||||
slots: mapping.slots.map((slot) => ({ ...slot })),
|
||||
warnings: [...mapping.warnings],
|
||||
acceptedWarnings: mapping.acceptedWarnings ? [...mapping.acceptedWarnings] : undefined,
|
||||
loadedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private renderQaGroupWarning(warning: string): string {
|
||||
const parsedWarning = parseQaGroupFieldWarning(warning);
|
||||
if (!parsedWarning) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue