mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
feat(management): add commands to clear synced cards and cleanup empty decks | 新增清空已同步卡片与清理空牌组管理命令
Introduced two new management commands: 'Clear currently synced cards' to reset sync state for the current file, and 'Cleanup empty decks' to remove unused decks from Anki. Both commands improve plugin maintenance and synchronization flow. 新增两个管理命令:'清空当前文件已同步卡片'用于重置当前文件的同步状态,'清理空牌组'用于从 Anki 中移除未使用的牌组。这两个命令改进了插件的维护和同步流程。
This commit is contained in:
parent
bd149b9d11
commit
412fddebc1
23 changed files with 1304 additions and 4 deletions
123
docs/cleanup-reset-decisions.md
Normal file
123
docs/cleanup-reset-decisions.md
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# Cleanup / Reset Decisions
|
||||
|
||||
## 1. 命令边界
|
||||
|
||||
两个能力都作为显式管理命令实现,不接入 `ManualSyncService` 的普通同步主链路:
|
||||
|
||||
1. `清空当前文件已同步卡片`
|
||||
2. `清理空牌组`
|
||||
|
||||
这样可以直接满足“普通 sync / rebuild / orphan 语义不变”的硬约束。
|
||||
|
||||
## 2. AnkiGateway 扩展
|
||||
|
||||
新增接口:
|
||||
|
||||
1. `deleteNotes(noteIds: number[]): Promise<void>`
|
||||
2. `listDeckNames(): Promise<string[]>`
|
||||
3. `getDeckStats(deckNames: string[]): Promise<Array<{ deckName: string; noteCount: number }>>`
|
||||
4. `deleteDecks(deckNames: string[]): Promise<void>`
|
||||
|
||||
实现策略:
|
||||
|
||||
1. 上层只看“列 deck / 判空 / 删除 deck”语义
|
||||
2. AnkiConnect action 的兼容细节全部封装在 `AnkiConnectGateway`
|
||||
3. `getDeckStats` 只要求足够判断空 deck;如果底层返回的是 card 统计而不是 note 统计,网关内部统一映射为 `noteCount` 风格字段供上层使用
|
||||
|
||||
## 3. Current-file reset 编排
|
||||
|
||||
新增独立 use case:`ClearCurrentFileSyncedCardsUseCase`。
|
||||
|
||||
固定执行顺序:
|
||||
|
||||
1. 读取 `pluginState`
|
||||
2. 找出当前文件对应 card records
|
||||
3. 收集有 `noteId` 的 noteIds
|
||||
4. 先调用 `deleteNotes`
|
||||
5. 再尝试删除该文件内对应 marker
|
||||
6. 最后保存清理后的本地 state
|
||||
|
||||
保存规则:
|
||||
|
||||
1. marker 删除成功时:删除对应 `pluginState.cards`、`pluginState.files[filePath].cardIds`、相关 `pendingWriteBack`
|
||||
2. marker 删除失败时:不回滚已删除的 Anki notes;保留失败文件对应 card record,但清空其 `noteId`,并清理相关 `pendingWriteBack`
|
||||
|
||||
另外,无论 marker 删除成功还是失败,都删除该文件的 `FileState` 记录,强制下一次普通同步重新读取正文,而不是复用“当前文件没有卡片变更”的旧索引结论。
|
||||
|
||||
这样做的原因:
|
||||
|
||||
1. 文件内容仍带旧 marker 时,完全删除本地 state 会让后续索引误把旧 marker 的 `noteId` 当成仍可更新的 note
|
||||
2. 保留 cardId 但清空 `noteId`,能让下一次普通同步把它当作“正文仍在、需要重新创建”的卡片,而不是对已删除 note 做 update
|
||||
|
||||
这是一项为满足“失败时保持内部一致”而做的最小兼容调整,不扩展成长期 tombstone 状态。
|
||||
|
||||
## 4. Marker removal 实现
|
||||
|
||||
不把删除逻辑塞进现有 `MarkdownWriteBackService`。
|
||||
|
||||
新增两层:
|
||||
|
||||
1. 纯内容变换:marker removal service
|
||||
2. 文件改写编排:Markdown marker removal application service
|
||||
|
||||
删除原则:
|
||||
|
||||
1. 仅删除匹配的 AHS marker 行
|
||||
2. 不改正文
|
||||
3. 不调整其他 card block 顺序
|
||||
|
||||
## 5. Empty deck cleanup 编排
|
||||
|
||||
新增独立 use case:`CleanupEmptyDecksUseCase`。
|
||||
|
||||
分两步:
|
||||
|
||||
1. `listCandidates()`:列 deck、取统计、筛空 deck
|
||||
2. `execute(selectedDeckNames)`:删除用户确认的 deck,并把“已不存在/已非空”归入 skipped
|
||||
|
||||
UI 选择发生在 presentation 层 modal 中,不下沉到 application 层。
|
||||
|
||||
## 6. Presentation 交互
|
||||
|
||||
新增两个 plugin 入口方法与两个命令注册项。
|
||||
|
||||
空 deck 清理使用一个小型多选 modal:
|
||||
|
||||
1. 先展示候选空 deck
|
||||
2. 用户勾选
|
||||
3. 确认删除后才调用 use case
|
||||
|
||||
如果没有候选 deck,则直接提示,不进入确认流程。
|
||||
|
||||
## 7. 结果类型与 notice
|
||||
|
||||
新增独立结果类型:
|
||||
|
||||
1. `ClearCurrentFileSyncedCardsResult`
|
||||
2. `CleanupEmptyDecksResult`
|
||||
|
||||
不扩展 `ManualSyncResult`。
|
||||
|
||||
`NoticeService` 新增两组摘要:
|
||||
|
||||
1. reset:deleted notes、removed markers、deleted local records、failed/conflict files
|
||||
2. empty decks:candidate count、selected count、deleted count、skipped count
|
||||
|
||||
## 8. 索引兼容修正
|
||||
|
||||
当 marker 中有 cardId,且本地 `CardState` 已存在但 `noteId` 被清空时,索引恢复身份时应优先信任本地 state 的“无 noteId”结果,而不是回退到 marker 里的旧 `noteId`。
|
||||
|
||||
该修正只服务于 reset 命令的失败恢复,不改变普通同步的删除语义。
|
||||
|
||||
## 9. 测试范围
|
||||
|
||||
至少覆盖:
|
||||
|
||||
1. current-file reset 的全成功路径
|
||||
2. current-file reset 的无 `noteId` 路径
|
||||
3. current-file reset 的 marker 写回失败路径
|
||||
4. reset 后普通同步可重建
|
||||
5. empty deck 候选识别
|
||||
6. empty deck 手选删除
|
||||
7. 已非空/已不存在 deck 的 skip 汇总
|
||||
8. 普通 sync / rebuild / orphan 行为不变
|
||||
121
docs/cleanup-reset-gap-report.md
Normal file
121
docs/cleanup-reset-gap-report.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# Cleanup / Reset Gap Report
|
||||
|
||||
## 审查范围
|
||||
|
||||
本报告只覆盖本任务要求的两个显式管理命令:
|
||||
|
||||
1. 清理空牌组
|
||||
2. 清空当前文件已同步卡片
|
||||
|
||||
审查对象:
|
||||
|
||||
1. `AnkiGateway` / `AnkiConnectGateway`
|
||||
2. marker 写回与 Markdown 文件改写链路
|
||||
3. `pluginState` 读取、删除与持久化
|
||||
4. 当前文件命令入口与命令注册
|
||||
5. `NoticeService`
|
||||
6. 当前 UI 是否已有确认/多选路径
|
||||
7. 回归风险:是否会误泄漏到普通 sync/rebuild 主链路
|
||||
|
||||
## 当前缺口
|
||||
|
||||
### 1. 网关层没有任何删除型能力
|
||||
|
||||
当前 `AnkiGateway` 只覆盖:
|
||||
|
||||
1. note add/update
|
||||
2. deck ensure/change
|
||||
3. model 查询
|
||||
4. media 上传
|
||||
|
||||
缺口:
|
||||
|
||||
1. 不能删除 note
|
||||
2. 不能列出 deck
|
||||
3. 不能查询 deck 是否为空
|
||||
4. 不能删除 deck
|
||||
|
||||
这意味着两个管理命令都无法从基础设施层闭环。
|
||||
|
||||
### 2. Markdown 写回只有“插入/替换 marker”,没有“删除 marker”路径
|
||||
|
||||
当前 `MarkdownWriteBackService` 只为同步主链路服务:
|
||||
|
||||
1. 批量插入 marker
|
||||
2. 替换 card-only / stale marker
|
||||
3. 失败时生成 pending write-back
|
||||
|
||||
缺口:
|
||||
|
||||
1. 没有独立的 marker removal service
|
||||
2. 删除型命令如果复用现有写回逻辑,会把“同步写 marker”和“管理命令删 marker”混成同一套 pending 语义
|
||||
|
||||
### 3. 没有“当前文件重置已同步卡片”的独立 use case
|
||||
|
||||
当前只有:
|
||||
|
||||
1. `ManualSyncCurrentFileUseCase`
|
||||
2. `ManualSyncVaultUseCase`
|
||||
3. `RebuildCardIndexUseCase`
|
||||
|
||||
缺口:
|
||||
|
||||
1. 没有只按当前文件删 note / 删 marker / 删本地 state 的独立编排
|
||||
2. 现有 `ManualSyncService` 主链路不应承担删除行为
|
||||
|
||||
### 4. Presentation 层没有任何用户确认或多选交互
|
||||
|
||||
当前插件入口只注册三个命令:
|
||||
|
||||
1. 同步当前文件
|
||||
2. 同步全库
|
||||
3. 重建卡片索引
|
||||
|
||||
缺口:
|
||||
|
||||
1. 没有“当前文件 reset”命令入口
|
||||
2. 没有“空牌组清理”命令入口
|
||||
3. 没有 deck 候选列表确认/多选 modal
|
||||
|
||||
### 5. Notice 层只会展示普通同步和 rebuild 摘要
|
||||
|
||||
当前 `NoticeService` 不支持:
|
||||
|
||||
1. 当前文件清空结果摘要
|
||||
2. 空 deck 清理结果摘要
|
||||
3. 空结果/手动取消/跳过删除的管理命令提示
|
||||
|
||||
### 6. 当前状态模型对“删 note 后 marker 删除失败”的恢复不够安全
|
||||
|
||||
现有索引链路会在 marker 仍保留 `noteId` 时继续把该 noteId 当作候选同步身份。
|
||||
|
||||
风险:
|
||||
|
||||
1. 如果 reset 命令已经删掉 Anki note
|
||||
2. 但 marker 写回失败,文件里仍保留旧 `noteId`
|
||||
3. 又把本地 card state 全删掉
|
||||
|
||||
那么下次普通同步可能把这个已删除 note 当成可 update 对象,产生错误。
|
||||
|
||||
这部分需要一个最小兼容修正,确保失败文件不会伪装成“还在同步中”。
|
||||
|
||||
## 范围控制结论
|
||||
|
||||
本任务必须新增两条显式管理命令,但不能改动以下默认语义:
|
||||
|
||||
1. `sync current file` 不自动删 note
|
||||
2. `sync vault` 不自动删 note
|
||||
3. `rebuild index` 不做 note 删除
|
||||
4. orphan 仍只标记,不自动删除
|
||||
5. 空 deck 清理只能显式触发,且必须先列候选再手选删除
|
||||
|
||||
## 需要补齐的实现块
|
||||
|
||||
1. `AnkiGateway` 删除/查询/删除 deck 接口
|
||||
2. `AnkiConnectGateway` 对应 action 封装
|
||||
3. 独立 marker removal service
|
||||
4. `ClearCurrentFileSyncedCardsUseCase`
|
||||
5. `CleanupEmptyDecksUseCase`
|
||||
6. Presentation modal / 选择流程
|
||||
7. 新结果类型与 `NoticeService` 摘要
|
||||
8. 回归测试与最终验证
|
||||
|
|
@ -25,17 +25,26 @@ export interface ChangeDeckInput {
|
|||
cardIds: number[];
|
||||
}
|
||||
|
||||
export interface DeckStat {
|
||||
deckName: string;
|
||||
noteCount: number;
|
||||
}
|
||||
|
||||
export interface AnkiGateway {
|
||||
ensureDeckExists(deckName: string): Promise<void>;
|
||||
ensureDecks(deckNames: string[]): Promise<void>;
|
||||
listNoteModels(): Promise<string[]>;
|
||||
listDeckNames(): Promise<string[]>;
|
||||
getModelDetails(modelName: string): Promise<NoteModelDetails>;
|
||||
getDeckStats(deckNames: string[]): Promise<DeckStat[]>;
|
||||
getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]>;
|
||||
addNote(input: AddAnkiNoteInput): Promise<number>;
|
||||
addNotes(inputs: AddAnkiNoteInput[]): Promise<number[]>;
|
||||
deleteNotes(noteIds: number[]): Promise<void>;
|
||||
updateNote(input: UpdateAnkiNoteInput): Promise<void>;
|
||||
updateNotes(inputs: UpdateAnkiNoteInput[]): Promise<void>;
|
||||
changeDecks(inputs: ChangeDeckInput[]): Promise<void>;
|
||||
deleteDecks(deckNames: string[]): Promise<void>;
|
||||
storeMedia(asset: MediaAsset): Promise<void>;
|
||||
storeMediaFiles(assets: MediaAsset[]): Promise<void>;
|
||||
}
|
||||
|
|
@ -26,6 +26,8 @@ describe("ManualSyncService", () => {
|
|||
expect(result.warnings).toEqual([]);
|
||||
expect(ankiGateway.addedNotes[0]?.deckName).toBe("notes");
|
||||
expect(ankiGateway.ensuredDecks).toEqual([["notes"]]);
|
||||
expect(ankiGateway.deletedNotes).toEqual([]);
|
||||
expect(ankiGateway.deletedDecks).toEqual([]);
|
||||
expect(vaultGateway.getFileContent("notes/example.md")).toContain("<!-- AHS:card=");
|
||||
expect(vaultGateway.getFileContent("notes/example.md")).toContain("note=9001");
|
||||
expect(stateRepository.savedState?.pendingWriteBack).toEqual([]);
|
||||
|
|
@ -63,6 +65,8 @@ describe("ManualSyncService", () => {
|
|||
expect(result.migratedDecks).toBe(0);
|
||||
expect(ankiGateway.addedNotes).toHaveLength(0);
|
||||
expect(ankiGateway.changedDecks).toEqual([]);
|
||||
expect(ankiGateway.deletedNotes).toEqual([]);
|
||||
expect(ankiGateway.deletedDecks).toEqual([]);
|
||||
expect(vaultGateway.getFileContent("notes/example.md")).toContain("<!-- AHS:card=");
|
||||
expect(vaultGateway.getFileContent("notes/example.md")).not.toContain("note=");
|
||||
});
|
||||
|
|
@ -375,6 +379,8 @@ describe("ManualSyncService", () => {
|
|||
|
||||
expect(result.migratedDecks).toBe(1);
|
||||
expect(ankiGateway.changedDecks).toEqual([{ deckName: "Scoped::Deck", cardIds: [7001] }]);
|
||||
expect(ankiGateway.deletedNotes).toEqual([]);
|
||||
expect(ankiGateway.deletedDecks).toEqual([]);
|
||||
});
|
||||
|
||||
it("ordinary sync re-evaluates unchanged files when file-level deck is enabled and migrates old notes", async () => {
|
||||
|
|
|
|||
66
src/application/services/MarkdownMarkerRemovalService.ts
Normal file
66
src/application/services/MarkdownMarkerRemovalService.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { MarkdownWriteConflictError } from "@/application/ports/VaultGateway";
|
||||
import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway";
|
||||
import { CardMarkerRemovalService, type MarkerRemovalTarget } from "@/domain/manual-sync/services/CardMarkerRemovalService";
|
||||
|
||||
export interface MarkdownMarkerRemovalResult {
|
||||
removedMarkers: number;
|
||||
conflictFiles: string[];
|
||||
failureFiles: Array<{ filePath: string; message: string }>;
|
||||
}
|
||||
|
||||
export class MarkdownMarkerRemovalService {
|
||||
constructor(
|
||||
private readonly vaultGateway: ManualSyncVaultGateway,
|
||||
private readonly markerRemovalService = new CardMarkerRemovalService(),
|
||||
) {}
|
||||
|
||||
async remove(filePath: string, targets: MarkerRemovalTarget[]): Promise<MarkdownMarkerRemovalResult> {
|
||||
if (targets.length === 0) {
|
||||
return {
|
||||
removedMarkers: 0,
|
||||
conflictFiles: [],
|
||||
failureFiles: [],
|
||||
};
|
||||
}
|
||||
|
||||
const sourceFile = await this.vaultGateway.readMarkdownFile(filePath);
|
||||
if (!sourceFile) {
|
||||
return {
|
||||
removedMarkers: 0,
|
||||
conflictFiles: [],
|
||||
failureFiles: [{ filePath, message: `Markdown file not found: ${filePath}` }],
|
||||
};
|
||||
}
|
||||
|
||||
const removal = this.markerRemovalService.apply(sourceFile.content, targets);
|
||||
|
||||
try {
|
||||
if (removal.nextContent !== sourceFile.content) {
|
||||
await this.vaultGateway.replaceMarkdownFile(filePath, sourceFile.content, removal.nextContent);
|
||||
}
|
||||
|
||||
return {
|
||||
removedMarkers: removal.removedMarkers,
|
||||
conflictFiles: [],
|
||||
failureFiles: [],
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof MarkdownWriteConflictError) {
|
||||
return {
|
||||
removedMarkers: 0,
|
||||
conflictFiles: [filePath],
|
||||
failureFiles: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
removedMarkers: 0,
|
||||
conflictFiles: [],
|
||||
failureFiles: [{
|
||||
filePath,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/application/use-cases/CleanupEmptyDecksUseCase.test.ts
Normal file
57
src/application/use-cases/CleanupEmptyDecksUseCase.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { FakeManualSyncAnkiGateway } from "@/test-support/manualSyncFakes";
|
||||
|
||||
import { CleanupEmptyDecksUseCase } from "./CleanupEmptyDecksUseCase";
|
||||
|
||||
describe("CleanupEmptyDecksUseCase", () => {
|
||||
it("detects empty decks from Anki stats", async () => {
|
||||
const ankiGateway = new FakeManualSyncAnkiGateway();
|
||||
ankiGateway.deckStatsByName.set("Busy", { deckName: "Busy", noteCount: 2 });
|
||||
ankiGateway.deckStatsByName.set("Empty", { deckName: "Empty", noteCount: 0 });
|
||||
ankiGateway.deckStatsByName.set("Another::Empty", { deckName: "Another::Empty", noteCount: 0 });
|
||||
const useCase = new CleanupEmptyDecksUseCase(ankiGateway);
|
||||
|
||||
const candidates = await useCase.listCandidates();
|
||||
|
||||
expect(candidates).toEqual(["Another::Empty", "Empty"]);
|
||||
});
|
||||
|
||||
it("deletes only the user-selected empty decks", async () => {
|
||||
const ankiGateway = new FakeManualSyncAnkiGateway();
|
||||
ankiGateway.deckStatsByName.set("Empty", { deckName: "Empty", noteCount: 0 });
|
||||
ankiGateway.deckStatsByName.set("Another::Empty", { deckName: "Another::Empty", noteCount: 0 });
|
||||
const useCase = new CleanupEmptyDecksUseCase(ankiGateway);
|
||||
|
||||
const candidates = await useCase.listCandidates();
|
||||
const result = await useCase.execute(["Empty"], candidates);
|
||||
|
||||
expect(result).toEqual({
|
||||
candidateCount: 2,
|
||||
selectedCount: 1,
|
||||
deletedCount: 1,
|
||||
deletedDeckNames: ["Empty"],
|
||||
skippedDeckNames: [],
|
||||
});
|
||||
expect(ankiGateway.deletedDecks).toEqual([["Empty"]]);
|
||||
});
|
||||
|
||||
it("skips decks that disappeared or became non-empty before deletion", async () => {
|
||||
const ankiGateway = new FakeManualSyncAnkiGateway();
|
||||
ankiGateway.deckStatsByName.set("Empty", { deckName: "Empty", noteCount: 0 });
|
||||
ankiGateway.deckStatsByName.set("Gone", { deckName: "Gone", noteCount: 0 });
|
||||
const useCase = new CleanupEmptyDecksUseCase(ankiGateway);
|
||||
|
||||
const candidates = await useCase.listCandidates();
|
||||
ankiGateway.deckStatsByName.set("Empty", { deckName: "Empty", noteCount: 3 });
|
||||
ankiGateway.deckStatsByName.delete("Gone");
|
||||
const result = await useCase.execute(["Empty", "Gone"], candidates);
|
||||
|
||||
expect(result.candidateCount).toBe(2);
|
||||
expect(result.selectedCount).toBe(2);
|
||||
expect(result.deletedCount).toBe(0);
|
||||
expect(result.deletedDeckNames).toEqual([]);
|
||||
expect(result.skippedDeckNames).toEqual(["Empty", "Gone"]);
|
||||
expect(ankiGateway.deletedDecks).toEqual([]);
|
||||
});
|
||||
});
|
||||
58
src/application/use-cases/CleanupEmptyDecksUseCase.ts
Normal file
58
src/application/use-cases/CleanupEmptyDecksUseCase.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { AnkiGateway } from "@/application/ports/AnkiGateway";
|
||||
|
||||
import type { CleanupEmptyDecksResult } from "./cleanupResetTypes";
|
||||
|
||||
export class CleanupEmptyDecksUseCase {
|
||||
constructor(private readonly ankiGateway: AnkiGateway) {}
|
||||
|
||||
async listCandidates(): Promise<string[]> {
|
||||
const deckNames = await this.ankiGateway.listDeckNames();
|
||||
const stats = await this.ankiGateway.getDeckStats(deckNames);
|
||||
|
||||
return stats
|
||||
.filter((stat) => stat.noteCount === 0)
|
||||
.map((stat) => stat.deckName)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
async execute(selectedDeckNames: string[], candidateDeckNames: string[]): Promise<CleanupEmptyDecksResult> {
|
||||
const uniqueSelectedDeckNames = Array.from(new Set(selectedDeckNames));
|
||||
if (uniqueSelectedDeckNames.length === 0) {
|
||||
return {
|
||||
candidateCount: candidateDeckNames.length,
|
||||
selectedCount: 0,
|
||||
deletedCount: 0,
|
||||
deletedDeckNames: [],
|
||||
skippedDeckNames: [],
|
||||
};
|
||||
}
|
||||
|
||||
const [currentDeckNames, currentDeckStats] = await Promise.all([
|
||||
this.ankiGateway.listDeckNames(),
|
||||
this.ankiGateway.getDeckStats(uniqueSelectedDeckNames),
|
||||
]);
|
||||
|
||||
const currentDeckNameSet = new Set(currentDeckNames);
|
||||
const currentDeckStatsByName = new Map(currentDeckStats.map((stat) => [stat.deckName, stat.noteCount]));
|
||||
const deletableDeckNames = uniqueSelectedDeckNames.filter((deckName) => currentDeckNameSet.has(deckName) && (currentDeckStatsByName.get(deckName) ?? 0) === 0);
|
||||
const skippedDeckNames = uniqueSelectedDeckNames.filter((deckName) => !currentDeckNameSet.has(deckName) || (currentDeckStatsByName.get(deckName) ?? 0) > 0);
|
||||
const deletedDeckNames: string[] = [];
|
||||
|
||||
for (const deckName of deletableDeckNames) {
|
||||
try {
|
||||
await this.ankiGateway.deleteDecks([deckName]);
|
||||
deletedDeckNames.push(deckName);
|
||||
} catch {
|
||||
skippedDeckNames.push(deckName);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
candidateCount: candidateDeckNames.length,
|
||||
selectedCount: uniqueSelectedDeckNames.length,
|
||||
deletedCount: deletedDeckNames.length,
|
||||
deletedDeckNames,
|
||||
skippedDeckNames,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { PluginSettings } from "@/application/config/PluginSettings";
|
||||
import { createDeckRulesFingerprint } from "@/application/services/FileIndexerService";
|
||||
import { ManualSyncService } from "@/application/services/ManualSyncService";
|
||||
import { RenderConfigService } from "@/application/services/RenderConfigService";
|
||||
import type { CardState } from "@/domain/manual-sync/entities/PluginState";
|
||||
import { hashString } from "@/domain/shared/hash";
|
||||
import { createModule3Settings, FakeManualSyncAnkiGateway, FakeManualSyncVaultGateway, InMemoryPluginStateRepository } from "@/test-support/manualSyncFakes";
|
||||
|
||||
import { ClearCurrentFileSyncedCardsUseCase } from "./ClearCurrentFileSyncedCardsUseCase";
|
||||
|
||||
describe("ClearCurrentFileSyncedCardsUseCase", () => {
|
||||
it("deletes notes, removes markers, deletes local records, and allows later sync to recreate cards", async () => {
|
||||
const settings = createModule3Settings();
|
||||
const filePath = "notes/example.md";
|
||||
const content = [
|
||||
"#### Prompt",
|
||||
"Answer",
|
||||
"<!-- AHS:card=ahs_1 note=41 -->",
|
||||
"",
|
||||
"#### Prompt 2",
|
||||
"Answer 2",
|
||||
"<!-- AHS:card=ahs_2 -->",
|
||||
].join("\n");
|
||||
const vaultGateway = new FakeManualSyncVaultGateway({
|
||||
[filePath]: content,
|
||||
});
|
||||
const stateRepository = new InMemoryPluginStateRepository({
|
||||
files: {
|
||||
[filePath]: {
|
||||
filePath,
|
||||
fileHash: hashString(content),
|
||||
fileStamp: `1:${content.length}`,
|
||||
deckRulesFingerprint: createDeckRulesFingerprint(settings),
|
||||
lastIndexedAt: 1,
|
||||
cardIds: ["ahs_1", "ahs_2"],
|
||||
},
|
||||
},
|
||||
cards: {
|
||||
ahs_1: createStoredSyncedCard(settings, {
|
||||
cardId: "ahs_1",
|
||||
noteId: 41,
|
||||
filePath,
|
||||
bodyMarkdown: "Answer",
|
||||
rawBlockText: ["#### Prompt", "Answer"].join("\n"),
|
||||
contentEndLine: 2,
|
||||
blockEndLine: 4,
|
||||
markerLine: 3,
|
||||
}),
|
||||
ahs_2: createStoredSyncedCard(settings, {
|
||||
cardId: "ahs_2",
|
||||
noteId: undefined,
|
||||
filePath,
|
||||
heading: "Prompt 2",
|
||||
bodyMarkdown: "Answer 2",
|
||||
rawBlockText: ["#### Prompt 2", "Answer 2"].join("\n"),
|
||||
blockStartLine: 5,
|
||||
bodyStartLine: 6,
|
||||
contentEndLine: 6,
|
||||
blockEndLine: 7,
|
||||
markerLine: 7,
|
||||
}),
|
||||
},
|
||||
pendingWriteBack: [
|
||||
{
|
||||
filePath,
|
||||
cardId: "ahs_1",
|
||||
noteId: 41,
|
||||
expectedFileHash: hashString(content),
|
||||
targetMarker: "<!-- AHS:card=ahs_1 note=41 -->",
|
||||
rawBlockHash: hashString(["#### Prompt", "Answer"].join("\n")),
|
||||
},
|
||||
],
|
||||
});
|
||||
const ankiGateway = new FakeManualSyncAnkiGateway();
|
||||
const useCase = new ClearCurrentFileSyncedCardsUseCase(stateRepository, ankiGateway, vaultGateway);
|
||||
|
||||
const result = await useCase.execute(filePath);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
trackedCards: 2,
|
||||
deletedNotes: 1,
|
||||
removedMarkers: 2,
|
||||
deletedLocalRecords: 2,
|
||||
conflictFiles: [],
|
||||
failureFiles: [],
|
||||
});
|
||||
expect(ankiGateway.deletedNotes).toEqual([[41]]);
|
||||
expect(vaultGateway.getFileContent(filePath)).toBe([
|
||||
"#### Prompt",
|
||||
"Answer",
|
||||
"",
|
||||
"#### Prompt 2",
|
||||
"Answer 2",
|
||||
].join("\n"));
|
||||
expect(stateRepository.savedState?.files[filePath]).toBeUndefined();
|
||||
expect(stateRepository.savedState?.cards.ahs_1).toBeUndefined();
|
||||
expect(stateRepository.savedState?.cards.ahs_2).toBeUndefined();
|
||||
expect(stateRepository.savedState?.pendingWriteBack).toEqual([]);
|
||||
|
||||
const manualSyncService = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 2000);
|
||||
const syncResult = await manualSyncService.syncFile(filePath, settings);
|
||||
|
||||
expect(syncResult.created).toBe(2);
|
||||
expect(ankiGateway.addedNotes).toHaveLength(2);
|
||||
expect(vaultGateway.getFileContent(filePath)).toContain("note=9001");
|
||||
expect(vaultGateway.getFileContent(filePath)).toContain("note=9002");
|
||||
});
|
||||
|
||||
it("cleans local records and markers without deleting Anki notes when noteId is missing", async () => {
|
||||
const settings = createModule3Settings();
|
||||
const filePath = "notes/local-only.md";
|
||||
const content = [
|
||||
"#### Prompt",
|
||||
"Answer",
|
||||
"<!-- AHS:card=ahs_local -->",
|
||||
].join("\n");
|
||||
const vaultGateway = new FakeManualSyncVaultGateway({
|
||||
[filePath]: content,
|
||||
});
|
||||
const stateRepository = new InMemoryPluginStateRepository({
|
||||
files: {
|
||||
[filePath]: {
|
||||
filePath,
|
||||
fileHash: hashString(content),
|
||||
fileStamp: `1:${content.length}`,
|
||||
deckRulesFingerprint: createDeckRulesFingerprint(settings),
|
||||
lastIndexedAt: 1,
|
||||
cardIds: ["ahs_local"],
|
||||
},
|
||||
},
|
||||
cards: {
|
||||
ahs_local: createStoredSyncedCard(settings, {
|
||||
cardId: "ahs_local",
|
||||
noteId: undefined,
|
||||
filePath,
|
||||
markerLine: 3,
|
||||
}),
|
||||
},
|
||||
pendingWriteBack: [],
|
||||
});
|
||||
const ankiGateway = new FakeManualSyncAnkiGateway();
|
||||
const useCase = new ClearCurrentFileSyncedCardsUseCase(stateRepository, ankiGateway, vaultGateway);
|
||||
|
||||
const result = await useCase.execute(filePath);
|
||||
|
||||
expect(result.deletedNotes).toBe(0);
|
||||
expect(result.removedMarkers).toBe(1);
|
||||
expect(result.deletedLocalRecords).toBe(1);
|
||||
expect(ankiGateway.deletedNotes).toEqual([]);
|
||||
expect(vaultGateway.getFileContent(filePath)).toBe(["#### Prompt", "Answer"].join("\n"));
|
||||
});
|
||||
|
||||
it("returns an empty result when the file has no tracked synced cards", async () => {
|
||||
const stateRepository = new InMemoryPluginStateRepository();
|
||||
const ankiGateway = new FakeManualSyncAnkiGateway();
|
||||
const vaultGateway = new FakeManualSyncVaultGateway();
|
||||
const useCase = new ClearCurrentFileSyncedCardsUseCase(stateRepository, ankiGateway, vaultGateway);
|
||||
|
||||
const result = await useCase.execute("notes/missing.md");
|
||||
|
||||
expect(result).toEqual({
|
||||
trackedCards: 0,
|
||||
deletedNotes: 0,
|
||||
removedMarkers: 0,
|
||||
deletedLocalRecords: 0,
|
||||
conflictFiles: [],
|
||||
failureFiles: [],
|
||||
});
|
||||
expect(ankiGateway.deletedNotes).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports marker write conflicts, clears file state, and keeps sanitized local cards so a later sync can recreate them", async () => {
|
||||
const settings = createModule3Settings();
|
||||
const filePath = "notes/conflict.md";
|
||||
const content = [
|
||||
"#### Prompt",
|
||||
"Answer",
|
||||
"<!-- AHS:card=ahs_conflict note=42 -->",
|
||||
].join("\n");
|
||||
const vaultGateway = new FakeManualSyncVaultGateway({
|
||||
[filePath]: content,
|
||||
});
|
||||
vaultGateway.conflictPaths.add(filePath);
|
||||
const stateRepository = new InMemoryPluginStateRepository({
|
||||
files: {
|
||||
[filePath]: {
|
||||
filePath,
|
||||
fileHash: hashString(content),
|
||||
fileStamp: `1:${content.length}`,
|
||||
deckRulesFingerprint: createDeckRulesFingerprint(settings),
|
||||
lastIndexedAt: 1,
|
||||
cardIds: ["ahs_conflict"],
|
||||
},
|
||||
},
|
||||
cards: {
|
||||
ahs_conflict: createStoredSyncedCard(settings, {
|
||||
cardId: "ahs_conflict",
|
||||
noteId: 42,
|
||||
filePath,
|
||||
markerLine: 3,
|
||||
}),
|
||||
},
|
||||
pendingWriteBack: [
|
||||
{
|
||||
filePath,
|
||||
cardId: "ahs_conflict",
|
||||
noteId: 42,
|
||||
expectedFileHash: hashString(content),
|
||||
targetMarker: "<!-- AHS:card=ahs_conflict note=42 -->",
|
||||
rawBlockHash: hashString(["#### Prompt", "Answer"].join("\n")),
|
||||
},
|
||||
],
|
||||
});
|
||||
const ankiGateway = new FakeManualSyncAnkiGateway();
|
||||
const useCase = new ClearCurrentFileSyncedCardsUseCase(stateRepository, ankiGateway, vaultGateway);
|
||||
|
||||
const result = await useCase.execute(filePath);
|
||||
|
||||
expect(result.deletedNotes).toBe(1);
|
||||
expect(result.removedMarkers).toBe(0);
|
||||
expect(result.deletedLocalRecords).toBe(0);
|
||||
expect(result.conflictFiles).toEqual([filePath]);
|
||||
expect(ankiGateway.deletedNotes).toEqual([[42]]);
|
||||
expect(stateRepository.savedState?.files[filePath]).toBeUndefined();
|
||||
expect(stateRepository.savedState?.cards.ahs_conflict?.noteId).toBeUndefined();
|
||||
expect(stateRepository.savedState?.pendingWriteBack).toEqual([]);
|
||||
expect(vaultGateway.getFileContent(filePath)).toContain("note=42");
|
||||
|
||||
vaultGateway.conflictPaths.delete(filePath);
|
||||
const manualSyncService = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 3000);
|
||||
const syncResult = await manualSyncService.syncFile(filePath, settings);
|
||||
|
||||
expect(syncResult.created).toBe(1);
|
||||
expect(ankiGateway.addedNotes).toHaveLength(1);
|
||||
expect(vaultGateway.getFileContent(filePath)).toContain("note=9001");
|
||||
expect(vaultGateway.getFileContent(filePath)).not.toContain("note=42");
|
||||
});
|
||||
});
|
||||
|
||||
function createStoredSyncedCard(settings: PluginSettings, overrides: Partial<CardState> = {}): CardState {
|
||||
const heading = overrides.heading ?? "Prompt";
|
||||
const bodyMarkdown = overrides.bodyMarkdown ?? "Answer";
|
||||
const rawBlockText = overrides.rawBlockText ?? [`#### ${heading}`, bodyMarkdown].join("\n");
|
||||
const rawBlockHash = overrides.rawBlockHash ?? hashString(rawBlockText);
|
||||
const indexedCard = {
|
||||
cardId: overrides.cardId ?? "ahs_known",
|
||||
noteId: overrides.noteId,
|
||||
markerNoteId: overrides.noteId,
|
||||
filePath: overrides.filePath ?? "notes/example.md",
|
||||
cardType: overrides.cardType ?? "basic",
|
||||
heading,
|
||||
headingLevel: overrides.headingLevel ?? 4,
|
||||
bodyMarkdown,
|
||||
blockStartOffset: overrides.blockStartOffset ?? 0,
|
||||
blockEndOffset: overrides.blockEndOffset ?? rawBlockText.length,
|
||||
blockStartLine: overrides.blockStartLine ?? 1,
|
||||
bodyStartLine: overrides.bodyStartLine ?? 2,
|
||||
blockEndLine: overrides.blockEndLine ?? 3,
|
||||
contentEndLine: overrides.contentEndLine ?? 2,
|
||||
markerLine: overrides.markerLine,
|
||||
rawBlockText,
|
||||
rawBlockHash,
|
||||
deckHint: overrides.deckHint,
|
||||
deckHintSource: overrides.deckHintSource,
|
||||
deckWarnings: overrides.deckWarnings ?? [],
|
||||
tagsHint: overrides.tagsHint ?? [],
|
||||
markerState: overrides.noteId ? "card-and-note" as const : "card-only" as const,
|
||||
};
|
||||
const renderPlan = new RenderConfigService().resolve(indexedCard, settings);
|
||||
|
||||
return {
|
||||
cardId: indexedCard.cardId,
|
||||
noteId: indexedCard.noteId,
|
||||
filePath: indexedCard.filePath,
|
||||
heading: indexedCard.heading,
|
||||
headingLevel: indexedCard.headingLevel,
|
||||
bodyMarkdown: indexedCard.bodyMarkdown,
|
||||
cardType: indexedCard.cardType,
|
||||
blockStartOffset: indexedCard.blockStartOffset,
|
||||
blockEndOffset: indexedCard.blockEndOffset,
|
||||
blockStartLine: indexedCard.blockStartLine,
|
||||
bodyStartLine: indexedCard.bodyStartLine,
|
||||
blockEndLine: indexedCard.blockEndLine,
|
||||
contentEndLine: indexedCard.contentEndLine,
|
||||
markerLine: indexedCard.markerLine,
|
||||
rawBlockText: indexedCard.rawBlockText,
|
||||
rawBlockHash: indexedCard.rawBlockHash,
|
||||
renderConfigHash: overrides.renderConfigHash ?? renderPlan.renderConfigHash,
|
||||
deck: overrides.deck ?? renderPlan.deck,
|
||||
deckHint: indexedCard.deckHint,
|
||||
deckHintSource: indexedCard.deckHintSource,
|
||||
deckWarnings: [...indexedCard.deckWarnings],
|
||||
tagsHint: indexedCard.tagsHint,
|
||||
lastSyncedAt: overrides.lastSyncedAt ?? 1,
|
||||
orphan: overrides.orphan ?? false,
|
||||
};
|
||||
}
|
||||
105
src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.ts
Normal file
105
src/application/use-cases/ClearCurrentFileSyncedCardsUseCase.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import type { AnkiGateway } from "@/application/ports/AnkiGateway";
|
||||
import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway";
|
||||
import type { PluginStateRepository } from "@/application/ports/PluginStateRepository";
|
||||
import { MarkdownMarkerRemovalService } from "@/application/services/MarkdownMarkerRemovalService";
|
||||
import type { CardState, PluginState } from "@/domain/manual-sync/entities/PluginState";
|
||||
|
||||
import type { ClearCurrentFileSyncedCardsResult } from "./cleanupResetTypes";
|
||||
|
||||
export class ClearCurrentFileSyncedCardsUseCase {
|
||||
constructor(
|
||||
private readonly pluginStateRepository: PluginStateRepository,
|
||||
private readonly ankiGateway: AnkiGateway,
|
||||
vaultGateway: ManualSyncVaultGateway,
|
||||
private readonly markdownMarkerRemovalService = new MarkdownMarkerRemovalService(vaultGateway),
|
||||
) {}
|
||||
|
||||
async hasTrackedCards(filePath: string): Promise<boolean> {
|
||||
const state = await this.pluginStateRepository.load();
|
||||
return collectTrackedCards(state, filePath).length > 0;
|
||||
}
|
||||
|
||||
async execute(filePath: string): Promise<ClearCurrentFileSyncedCardsResult> {
|
||||
const state = await this.pluginStateRepository.load();
|
||||
const trackedCards = collectTrackedCards(state, filePath);
|
||||
|
||||
if (trackedCards.length === 0) {
|
||||
return {
|
||||
trackedCards: 0,
|
||||
deletedNotes: 0,
|
||||
removedMarkers: 0,
|
||||
deletedLocalRecords: 0,
|
||||
conflictFiles: [],
|
||||
failureFiles: [],
|
||||
};
|
||||
}
|
||||
|
||||
const noteIds = Array.from(new Set(trackedCards.flatMap((card) => typeof card.noteId === "number" ? [card.noteId] : [])));
|
||||
if (noteIds.length > 0) {
|
||||
await this.ankiGateway.deleteNotes(noteIds);
|
||||
}
|
||||
|
||||
const markerRemovalResult = await this.markdownMarkerRemovalService.remove(
|
||||
filePath,
|
||||
trackedCards.map((card) => ({ cardId: card.cardId })),
|
||||
);
|
||||
|
||||
const nextState = buildNextState(state, filePath, trackedCards, markerRemovalResult.conflictFiles.length > 0 || markerRemovalResult.failureFiles.length > 0);
|
||||
await this.pluginStateRepository.save(nextState);
|
||||
|
||||
return {
|
||||
trackedCards: trackedCards.length,
|
||||
deletedNotes: noteIds.length,
|
||||
removedMarkers: markerRemovalResult.removedMarkers,
|
||||
deletedLocalRecords: markerRemovalResult.conflictFiles.length === 0 && markerRemovalResult.failureFiles.length === 0 ? trackedCards.length : 0,
|
||||
conflictFiles: markerRemovalResult.conflictFiles,
|
||||
failureFiles: markerRemovalResult.failureFiles,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function collectTrackedCards(state: PluginState, filePath: string): CardState[] {
|
||||
const trackedCardIds = new Set<string>();
|
||||
|
||||
for (const cardId of state.files[filePath]?.cardIds ?? []) {
|
||||
if (state.cards[cardId]) {
|
||||
trackedCardIds.add(cardId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const card of Object.values(state.cards)) {
|
||||
if (card.filePath === filePath) {
|
||||
trackedCardIds.add(card.cardId);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(trackedCardIds)
|
||||
.map((cardId) => state.cards[cardId])
|
||||
.filter((card): card is CardState => Boolean(card));
|
||||
}
|
||||
|
||||
function buildNextState(state: PluginState, filePath: string, trackedCards: CardState[], keepSanitizedCards: boolean): PluginState {
|
||||
const trackedCardIds = new Set(trackedCards.map((card) => card.cardId));
|
||||
const nextCards = { ...state.cards };
|
||||
|
||||
for (const trackedCard of trackedCards) {
|
||||
if (!keepSanitizedCards) {
|
||||
delete nextCards[trackedCard.cardId];
|
||||
continue;
|
||||
}
|
||||
|
||||
nextCards[trackedCard.cardId] = {
|
||||
...trackedCard,
|
||||
noteId: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const nextFiles = { ...state.files };
|
||||
delete nextFiles[filePath];
|
||||
|
||||
return {
|
||||
files: nextFiles,
|
||||
cards: nextCards,
|
||||
pendingWriteBack: state.pendingWriteBack.filter((pending) => pending.filePath !== filePath && !trackedCardIds.has(pending.cardId)),
|
||||
};
|
||||
}
|
||||
|
|
@ -117,10 +117,18 @@ class FakeAnkiGateway implements AnkiGateway {
|
|||
return Object.keys(this.modelDetailsByName);
|
||||
}
|
||||
|
||||
async listDeckNames(): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getModelDetails(modelName: string) {
|
||||
return this.modelDetailsByName[modelName] ?? { fieldNames: ["Front", "Back"], isCloze: false };
|
||||
}
|
||||
|
||||
async getDeckStats(): Promise<Array<{ deckName: string; noteCount: number }>> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]> {
|
||||
return noteIds.flatMap((noteId) => {
|
||||
const summary = this.noteSummariesById.get(noteId);
|
||||
|
|
@ -138,6 +146,8 @@ class FakeAnkiGateway implements AnkiGateway {
|
|||
return Promise.all(inputs.map((input) => this.addNote(input)));
|
||||
}
|
||||
|
||||
async deleteNotes(): Promise<void> {}
|
||||
|
||||
async updateNote(input: { noteId: number; deckName: string; fields: Record<string, string> }): Promise<void> {
|
||||
this.updatedNotes.push(input);
|
||||
}
|
||||
|
|
@ -150,6 +160,8 @@ class FakeAnkiGateway implements AnkiGateway {
|
|||
|
||||
async changeDecks(): Promise<void> {}
|
||||
|
||||
async deleteDecks(): Promise<void> {}
|
||||
|
||||
async storeMedia(asset: { fileName: string }): Promise<void> {
|
||||
this.storedMedia.push(asset.fileName);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,10 @@ class FakeAnkiGateway implements AnkiGateway {
|
|||
return ["Basic", "Cloze"];
|
||||
}
|
||||
|
||||
async listDeckNames(): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getModelDetails(modelName: string) {
|
||||
return modelName === "Cloze"
|
||||
? { fieldNames: ["Text", "Extra"], isCloze: true }
|
||||
|
|
@ -110,6 +114,10 @@ class FakeAnkiGateway implements AnkiGateway {
|
|||
return [];
|
||||
}
|
||||
|
||||
async getDeckStats(): Promise<Array<{ deckName: string; noteCount: number }>> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async addNote(input: { deckName: string; modelName: string; fields: Record<string, string> }): Promise<number> {
|
||||
this.addCalls.push(input);
|
||||
return 500 + this.addCalls.length;
|
||||
|
|
@ -119,6 +127,8 @@ class FakeAnkiGateway implements AnkiGateway {
|
|||
return Promise.all(inputs.map((input) => this.addNote(input)));
|
||||
}
|
||||
|
||||
async deleteNotes(): Promise<void> {}
|
||||
|
||||
async updateNote(input: { noteId: number; deckName: string; fields: Record<string, string> }): Promise<void> {
|
||||
this.updateCalls.push(input);
|
||||
}
|
||||
|
|
@ -131,6 +141,8 @@ class FakeAnkiGateway implements AnkiGateway {
|
|||
|
||||
async changeDecks(): Promise<void> {}
|
||||
|
||||
async deleteDecks(): Promise<void> {}
|
||||
|
||||
async storeMedia(asset: { fileName: string }): Promise<void> {
|
||||
this.storedMedia.push(asset.fileName);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,10 @@ class FakeAnkiGateway implements AnkiGateway {
|
|||
return ["Basic", "Cloze"];
|
||||
}
|
||||
|
||||
async listDeckNames(): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getModelDetails(modelName: string) {
|
||||
return modelName === "Cloze"
|
||||
? { fieldNames: ["Text", "Extra"], isCloze: true }
|
||||
|
|
@ -102,6 +106,10 @@ class FakeAnkiGateway implements AnkiGateway {
|
|||
return [];
|
||||
}
|
||||
|
||||
async getDeckStats(): Promise<Array<{ deckName: string; noteCount: number }>> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async addNote(input: { deckName: string; modelName: string; fields: Record<string, string> }): Promise<number> {
|
||||
this.addCalls.push(input);
|
||||
return 200 + this.addCalls.length;
|
||||
|
|
@ -111,6 +119,8 @@ class FakeAnkiGateway implements AnkiGateway {
|
|||
return Promise.all(inputs.map((input) => this.addNote(input)));
|
||||
}
|
||||
|
||||
async deleteNotes(): Promise<void> {}
|
||||
|
||||
async updateNote(input: { noteId: number; deckName: string; fields: Record<string, string> }): Promise<void> {
|
||||
this.updateCalls.push(input);
|
||||
}
|
||||
|
|
@ -123,6 +133,8 @@ class FakeAnkiGateway implements AnkiGateway {
|
|||
|
||||
async changeDecks(): Promise<void> {}
|
||||
|
||||
async deleteDecks(): Promise<void> {}
|
||||
|
||||
async storeMedia(): Promise<void> {}
|
||||
|
||||
async storeMediaFiles(): Promise<void> {}
|
||||
|
|
|
|||
16
src/application/use-cases/cleanupResetTypes.ts
Normal file
16
src/application/use-cases/cleanupResetTypes.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export interface ClearCurrentFileSyncedCardsResult {
|
||||
trackedCards: number;
|
||||
deletedNotes: number;
|
||||
removedMarkers: number;
|
||||
deletedLocalRecords: number;
|
||||
conflictFiles: string[];
|
||||
failureFiles: Array<{ filePath: string; message: string }>;
|
||||
}
|
||||
|
||||
export interface CleanupEmptyDecksResult {
|
||||
candidateCount: number;
|
||||
selectedCount: number;
|
||||
deletedCount: number;
|
||||
deletedDeckNames: string[];
|
||||
skippedDeckNames: string[];
|
||||
}
|
||||
|
|
@ -131,6 +131,31 @@ describe("CardIndexingService", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("prefers a known card with cleared noteId over a stale marker noteId", () => {
|
||||
const service = new CardIndexingService();
|
||||
const indexedFile = service.index(
|
||||
{
|
||||
path: "notes/example.md",
|
||||
basename: "example",
|
||||
content: ["#### Prompt", "Answer", "<!-- AHS:card=ahs_known note=42 -->"].join("\n"),
|
||||
},
|
||||
{
|
||||
qaHeadingLevel: 4,
|
||||
clozeHeadingLevel: 5,
|
||||
fileStamp: "1:1",
|
||||
knownCards: [createKnownCardState({ cardId: "ahs_known", noteId: undefined })],
|
||||
pendingWriteBack: [],
|
||||
},
|
||||
);
|
||||
|
||||
expect(indexedFile.cards[0]).toMatchObject({
|
||||
cardId: "ahs_known",
|
||||
noteId: undefined,
|
||||
markerNoteId: 42,
|
||||
markerState: "card-and-note",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects misplaced or multiple markers inside a single heading block", () => {
|
||||
const service = new CardIndexingService();
|
||||
|
||||
|
|
@ -256,7 +281,7 @@ function createKnownCardState(overrides: Partial<CardState> = {}): CardState {
|
|||
|
||||
return {
|
||||
cardId: overrides.cardId ?? "ahs_known",
|
||||
noteId: overrides.noteId ?? 42,
|
||||
noteId: Object.prototype.hasOwnProperty.call(overrides, "noteId") ? overrides.noteId : 42,
|
||||
filePath: overrides.filePath ?? "notes/example.md",
|
||||
heading: overrides.heading ?? "Prompt",
|
||||
headingLevel: overrides.headingLevel ?? 4,
|
||||
|
|
|
|||
|
|
@ -131,9 +131,23 @@ export class CardIndexingService {
|
|||
const pending = pendingByCardId.get(markerCardId);
|
||||
const known = knownCardsById.get(markerCardId);
|
||||
|
||||
if (pending && pending.noteId !== undefined) {
|
||||
return {
|
||||
cardId: markerCardId,
|
||||
noteId: pending?.noteId ?? known?.noteId ?? markerNoteId,
|
||||
noteId: pending.noteId,
|
||||
};
|
||||
}
|
||||
|
||||
if (known) {
|
||||
return {
|
||||
cardId: markerCardId,
|
||||
noteId: known.noteId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
cardId: markerCardId,
|
||||
noteId: markerNoteId,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
50
src/domain/manual-sync/services/CardMarkerRemovalService.ts
Normal file
50
src/domain/manual-sync/services/CardMarkerRemovalService.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { CardMarkerService } from "@/domain/manual-sync/services/CardMarkerService";
|
||||
|
||||
export interface MarkerRemovalTarget {
|
||||
cardId: string;
|
||||
}
|
||||
|
||||
export interface MarkerRemovalApplyResult {
|
||||
nextContent: string;
|
||||
removedMarkers: number;
|
||||
}
|
||||
|
||||
export class CardMarkerRemovalService {
|
||||
constructor(private readonly markerService = new CardMarkerService()) {}
|
||||
|
||||
apply(sourceContent: string, targets: MarkerRemovalTarget[]): MarkerRemovalApplyResult {
|
||||
if (targets.length === 0) {
|
||||
return {
|
||||
nextContent: sourceContent,
|
||||
removedMarkers: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const targetCardIds = new Set(targets.map((target) => target.cardId));
|
||||
const lineEnding = sourceContent.includes("\r\n") ? "\r\n" : "\n";
|
||||
const lines = sourceContent.split(/\r?\n/);
|
||||
const nextLines: string[] = [];
|
||||
let removedMarkers = 0;
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
if (!this.markerService.isCandidate(line)) {
|
||||
nextLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const marker = this.markerService.parse(line, index + 1);
|
||||
if (!marker || !targetCardIds.has(marker.cardId)) {
|
||||
nextLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
removedMarkers += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
nextContent: nextLines.join(lineEnding),
|
||||
removedMarkers,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,25 @@ describe("AnkiConnectGateway", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("loads deck names from AnkiConnect", async () => {
|
||||
requestUrlMock.mockResolvedValue({
|
||||
json: {
|
||||
error: null,
|
||||
result: ["Default", "Scoped::Deck"],
|
||||
},
|
||||
});
|
||||
|
||||
const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765");
|
||||
const deckNames = await gateway.listDeckNames();
|
||||
|
||||
expect(deckNames).toEqual(["Default", "Scoped::Deck"]);
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
action: "deckNames",
|
||||
version: 6,
|
||||
params: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("loads model fields and detects cloze templates", async () => {
|
||||
requestUrlMock
|
||||
.mockResolvedValueOnce({
|
||||
|
|
@ -88,6 +107,33 @@ describe("AnkiConnectGateway", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("maps deck stats into empty-deck detection shape", async () => {
|
||||
requestUrlMock.mockResolvedValue({
|
||||
json: {
|
||||
error: null,
|
||||
result: {
|
||||
Empty: { total_in_deck: 0 },
|
||||
Busy: { total_in_deck: 3 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765");
|
||||
const stats = await gateway.getDeckStats(["Empty", "Busy"]);
|
||||
|
||||
expect(stats).toEqual([
|
||||
{ deckName: "Empty", noteCount: 0 },
|
||||
{ deckName: "Busy", noteCount: 3 },
|
||||
]);
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
action: "getDeckStats",
|
||||
version: 6,
|
||||
params: {
|
||||
decks: ["Empty", "Busy"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("batches add note calls through AnkiConnect multi", async () => {
|
||||
requestUrlMock.mockResolvedValue({
|
||||
json: {
|
||||
|
|
@ -231,4 +277,40 @@ describe("AnkiConnectGateway", () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes notes and empty decks through direct actions", async () => {
|
||||
requestUrlMock
|
||||
.mockResolvedValueOnce({
|
||||
json: {
|
||||
error: null,
|
||||
result: null,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
json: {
|
||||
error: null,
|
||||
result: null,
|
||||
},
|
||||
});
|
||||
|
||||
const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765");
|
||||
await gateway.deleteNotes([1, 2]);
|
||||
await gateway.deleteDecks(["Empty", "Empty", "Scoped::Deck"]);
|
||||
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
|
||||
action: "deleteNotes",
|
||||
version: 6,
|
||||
params: {
|
||||
notes: [1, 2],
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(requestUrlMock.mock.calls[1][0].body)).toEqual({
|
||||
action: "deleteDecks",
|
||||
version: 6,
|
||||
params: {
|
||||
decks: ["Empty", "Scoped::Deck"],
|
||||
cardsToo: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { requestUrl } from "obsidian";
|
||||
|
||||
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
|
||||
import type { AddAnkiNoteInput, AnkiGateway, AnkiNoteSummary, ChangeDeckInput, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway";
|
||||
import type { AddAnkiNoteInput, AnkiGateway, AnkiNoteSummary, ChangeDeckInput, DeckStat, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway";
|
||||
import type { MediaAsset } from "@/domain/card/entities/RenderedFields";
|
||||
|
||||
interface AnkiResponse<T> {
|
||||
|
|
@ -15,6 +15,10 @@ interface NoteInfo {
|
|||
noteId?: number;
|
||||
}
|
||||
|
||||
interface RawDeckStats {
|
||||
total_in_deck?: number;
|
||||
}
|
||||
|
||||
type ModelTemplates = Record<string, unknown>;
|
||||
|
||||
export class AnkiConnectGateway implements AnkiGateway {
|
||||
|
|
@ -36,6 +40,10 @@ export class AnkiConnectGateway implements AnkiGateway {
|
|||
return this.invoke<string[]>("modelNames", {});
|
||||
}
|
||||
|
||||
async listDeckNames(): Promise<string[]> {
|
||||
return this.invoke<string[]>("deckNames", {});
|
||||
}
|
||||
|
||||
async getModelDetails(modelName: string): Promise<NoteModelDetails> {
|
||||
const fieldNames = await this.invoke<string[]>("modelFieldNames", { modelName });
|
||||
let isCloze = modelName.toLowerCase().includes("cloze");
|
||||
|
|
@ -53,6 +61,21 @@ export class AnkiConnectGateway implements AnkiGateway {
|
|||
};
|
||||
}
|
||||
|
||||
async getDeckStats(deckNames: string[]): Promise<DeckStat[]> {
|
||||
if (deckNames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rawStats = await this.invoke<Record<string, RawDeckStats>>("getDeckStats", {
|
||||
decks: deckNames,
|
||||
});
|
||||
|
||||
return deckNames.map((deckName) => ({
|
||||
deckName,
|
||||
noteCount: rawStats[deckName]?.total_in_deck ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]> {
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
|
|
@ -108,6 +131,16 @@ export class AnkiConnectGateway implements AnkiGateway {
|
|||
})));
|
||||
}
|
||||
|
||||
async deleteNotes(noteIds: number[]): Promise<void> {
|
||||
if (noteIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.invoke("deleteNotes", {
|
||||
notes: noteIds,
|
||||
});
|
||||
}
|
||||
|
||||
async updateNote(input: UpdateAnkiNoteInput): Promise<void> {
|
||||
await this.invoke("updateNoteFields", {
|
||||
note: {
|
||||
|
|
@ -151,6 +184,18 @@ export class AnkiConnectGateway implements AnkiGateway {
|
|||
})));
|
||||
}
|
||||
|
||||
async deleteDecks(deckNames: string[]): Promise<void> {
|
||||
const uniqueDeckNames = Array.from(new Set(deckNames));
|
||||
if (uniqueDeckNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.invoke("deleteDecks", {
|
||||
decks: uniqueDeckNames,
|
||||
cardsToo: false,
|
||||
});
|
||||
}
|
||||
|
||||
async storeMedia(asset: MediaAsset): Promise<void> {
|
||||
await this.invoke("storeMediaFile", {
|
||||
filename: asset.fileName,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { Plugin } from "obsidian";
|
|||
import { DEFAULT_SETTINGS, type PluginSettings } from "@/application/config/PluginSettings";
|
||||
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
|
||||
import { DeckTemplateInsertionService } from "@/application/services/DeckTemplateInsertionService";
|
||||
import { CleanupEmptyDecksUseCase } from "@/application/use-cases/CleanupEmptyDecksUseCase";
|
||||
import { ClearCurrentFileSyncedCardsUseCase } from "@/application/use-cases/ClearCurrentFileSyncedCardsUseCase";
|
||||
import { ManualSyncCurrentFileUseCase } from "@/application/use-cases/ManualSyncCurrentFileUseCase";
|
||||
import { RebuildCardIndexUseCase } from "@/application/use-cases/RebuildCardIndexUseCase";
|
||||
import { ManualSyncVaultUseCase } from "@/application/use-cases/ManualSyncVaultUseCase";
|
||||
|
|
@ -12,6 +14,7 @@ import { ObsidianVaultGateway } from "@/infrastructure/obsidian/ObsidianVaultGat
|
|||
import { DataJsonPluginConfigRepository, type PluginDataSnapshot } from "@/infrastructure/persistence/DataJsonPluginConfigRepository";
|
||||
import { DataJsonPluginStateRepository } from "@/infrastructure/persistence/DataJsonPluginStateRepository";
|
||||
import { registerCommands } from "@/presentation/commands/registerCommands";
|
||||
import { EmptyDeckSelectionModal } from "@/presentation/modals/EmptyDeckSelectionModal";
|
||||
import { NoticeService } from "@/presentation/notices/NoticeService";
|
||||
import { AnkiHeadingSyncSettingTab } from "@/presentation/settings/PluginSettingTab";
|
||||
import { CurrentFileOutOfScopeError, ManualSyncService } from "@/application/services/ManualSyncService";
|
||||
|
|
@ -28,6 +31,8 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
|
|||
private syncCurrentFileUseCase?: ManualSyncCurrentFileUseCase;
|
||||
private syncVaultUseCase?: ManualSyncVaultUseCase;
|
||||
private rebuildCardIndexUseCase?: RebuildCardIndexUseCase;
|
||||
private clearCurrentFileSyncedCardsUseCase?: ClearCurrentFileSyncedCardsUseCase;
|
||||
private cleanupEmptyDecksUseCase?: CleanupEmptyDecksUseCase;
|
||||
private pluginConfigRepository?: DataJsonPluginConfigRepository;
|
||||
private vaultGateway?: ManualSyncVaultGateway;
|
||||
|
||||
|
|
@ -50,6 +55,8 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
|
|||
this.syncCurrentFileUseCase = new ManualSyncCurrentFileUseCase(manualSyncService);
|
||||
this.syncVaultUseCase = new ManualSyncVaultUseCase(manualSyncService);
|
||||
this.rebuildCardIndexUseCase = new RebuildCardIndexUseCase(manualSyncService);
|
||||
this.clearCurrentFileSyncedCardsUseCase = new ClearCurrentFileSyncedCardsUseCase(pluginStateRepository, this.ankiGateway, vaultGateway);
|
||||
this.cleanupEmptyDecksUseCase = new CleanupEmptyDecksUseCase(this.ankiGateway);
|
||||
|
||||
registerCommands(this);
|
||||
this.addSettingTab(new AnkiHeadingSyncSettingTab(this));
|
||||
|
|
@ -185,4 +192,59 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
|
|||
this.noticeService.error(error instanceof Error ? error.message : "Deck template insertion failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async runClearCurrentFileSyncedCards(): Promise<void> {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
|
||||
if (!activeFile || activeFile.extension.toLowerCase() !== "md") {
|
||||
this.noticeService.error("No active Markdown file is available for reset.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.clearCurrentFileSyncedCardsUseCase) {
|
||||
this.noticeService.error("Clear-current-file use case is not initialized.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const hasTrackedCards = await this.clearCurrentFileSyncedCardsUseCase.hasTrackedCards(activeFile.path);
|
||||
if (!hasTrackedCards) {
|
||||
this.noticeService.info("当前文件没有已同步卡片");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.clearCurrentFileSyncedCardsUseCase.execute(activeFile.path);
|
||||
this.noticeService.showClearCurrentFileSummary("当前文件已同步卡片清空完成", result);
|
||||
} catch (error) {
|
||||
console.error("Clear current file synced cards failed.", error);
|
||||
this.noticeService.error(error instanceof Error ? error.message : "Clear current file synced cards failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async runCleanupEmptyDecks(): Promise<void> {
|
||||
if (!this.cleanupEmptyDecksUseCase) {
|
||||
this.noticeService.error("Empty-deck cleanup use case is not initialized.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const candidateDeckNames = await this.cleanupEmptyDecksUseCase.listCandidates();
|
||||
if (candidateDeckNames.length === 0) {
|
||||
this.noticeService.info("未发现可清理的空牌组");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedDeckNames = await new EmptyDeckSelectionModal(this.app, candidateDeckNames).openAndGetSelection();
|
||||
if (selectedDeckNames === null) {
|
||||
this.noticeService.info("已取消空牌组清理");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.cleanupEmptyDecksUseCase.execute(selectedDeckNames, candidateDeckNames);
|
||||
this.noticeService.showCleanupEmptyDecksSummary("空牌组清理完成", result);
|
||||
} catch (error) {
|
||||
console.error("Cleanup empty decks failed.", error);
|
||||
this.noticeService.error(error instanceof Error ? error.message : "Cleanup empty decks failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,4 +24,20 @@ export function registerCommands(plugin: AnkiHeadingSyncPlugin): void {
|
|||
void plugin.runRebuildCardIndex();
|
||||
},
|
||||
});
|
||||
|
||||
plugin.addCommand({
|
||||
id: "clear-current-file-synced-cards",
|
||||
name: "清空当前文件已同步卡片",
|
||||
callback: () => {
|
||||
void plugin.runClearCurrentFileSyncedCards();
|
||||
},
|
||||
});
|
||||
|
||||
plugin.addCommand({
|
||||
id: "cleanup-empty-decks",
|
||||
name: "清理空牌组",
|
||||
callback: () => {
|
||||
void plugin.runCleanupEmptyDecks();
|
||||
},
|
||||
});
|
||||
}
|
||||
69
src/presentation/modals/EmptyDeckSelectionModal.ts
Normal file
69
src/presentation/modals/EmptyDeckSelectionModal.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { Modal, Setting, type App } from "obsidian";
|
||||
|
||||
export class EmptyDeckSelectionModal extends Modal {
|
||||
private readonly selectedDeckNames = new Set<string>();
|
||||
private resolver?: (value: string[] | null) => void;
|
||||
private resolvedValue: string[] | null = null;
|
||||
|
||||
constructor(app: App, private readonly candidateDeckNames: string[]) {
|
||||
super(app);
|
||||
}
|
||||
|
||||
openAndGetSelection(): Promise<string[] | null> {
|
||||
return new Promise((resolve) => {
|
||||
this.resolver = resolve;
|
||||
this.open();
|
||||
});
|
||||
}
|
||||
|
||||
override onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.createEl("h2", { text: "清理空牌组" });
|
||||
contentEl.createEl("p", { text: "勾选要删除的空牌组。只有删除时仍为空的牌组会被真正删除。" });
|
||||
|
||||
for (const deckName of this.candidateDeckNames) {
|
||||
new Setting(contentEl)
|
||||
.setName(deckName)
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(false).onChange((value) => {
|
||||
if (value) {
|
||||
this.selectedDeckNames.add(deckName);
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedDeckNames.delete(deckName);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
new Setting(contentEl)
|
||||
.addButton((button) => {
|
||||
button.setButtonText("取消").onClick(() => {
|
||||
this.finish(null);
|
||||
});
|
||||
})
|
||||
.addButton((button) => {
|
||||
button.setButtonText("删除所选空牌组").setCta().onClick(() => {
|
||||
this.finish(Array.from(this.selectedDeckNames));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
override onClose(): void {
|
||||
const resolve = this.resolver;
|
||||
const resolvedValue = this.resolvedValue;
|
||||
|
||||
this.contentEl.empty();
|
||||
this.resolver = undefined;
|
||||
this.resolvedValue = null;
|
||||
this.selectedDeckNames.clear();
|
||||
|
||||
resolve?.(resolvedValue);
|
||||
}
|
||||
|
||||
private finish(value: string[] | null): void {
|
||||
this.resolvedValue = value;
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { Notice } from "obsidian";
|
||||
|
||||
import type { ClearCurrentFileSyncedCardsResult, CleanupEmptyDecksResult } from "@/application/use-cases/cleanupResetTypes";
|
||||
import type { ManualSyncResult } from "@/application/use-cases/manualSyncTypes";
|
||||
|
||||
export class NoticeService {
|
||||
|
|
@ -36,4 +37,25 @@ export class NoticeService {
|
|||
this.info(warning.message);
|
||||
}
|
||||
}
|
||||
|
||||
showClearCurrentFileSummary(prefix: string, result: ClearCurrentFileSyncedCardsResult): void {
|
||||
const summary = `${prefix}: tracked ${result.trackedCards}, deleted notes ${result.deletedNotes}, removed markers ${result.removedMarkers}, deleted local records ${result.deletedLocalRecords}.`;
|
||||
const conflicts = result.conflictFiles.length > 0
|
||||
? ` Marker removal conflicts: ${result.conflictFiles.join(", ")}.`
|
||||
: "";
|
||||
const failures = result.failureFiles.length > 0
|
||||
? ` Failures: ${result.failureFiles.map((entry) => `${entry.filePath} (${entry.message})`).join(", ")}.`
|
||||
: "";
|
||||
|
||||
this.info(`${summary}${conflicts}${failures}`);
|
||||
}
|
||||
|
||||
showCleanupEmptyDecksSummary(prefix: string, result: CleanupEmptyDecksResult): void {
|
||||
const summary = `${prefix}: candidates ${result.candidateCount}, selected ${result.selectedCount}, deleted ${result.deletedCount}, skipped ${result.skippedDeckNames.length}.`;
|
||||
const skipped = result.skippedDeckNames.length > 0
|
||||
? ` Skipped decks: ${result.skippedDeckNames.join(", ")}.`
|
||||
: "";
|
||||
|
||||
this.info(`${summary}${skipped}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { PluginSettings } from "@/application/config/PluginSettings";
|
||||
import { DEFAULT_SETTINGS } from "@/application/config/PluginSettings";
|
||||
import type { FolderTreeNode } from "@/application/dto/FolderTreeNode";
|
||||
import type { AddAnkiNoteInput, AnkiGateway, AnkiNoteSummary, ChangeDeckInput, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway";
|
||||
import type { AddAnkiNoteInput, AnkiGateway, AnkiNoteSummary, ChangeDeckInput, DeckStat, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway";
|
||||
import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway";
|
||||
import type { PluginStateRepository } from "@/application/ports/PluginStateRepository";
|
||||
import { MarkdownWriteConflictError } from "@/application/ports/VaultGateway";
|
||||
|
|
@ -137,10 +137,13 @@ export class FakeManualSyncVaultGateway implements ManualSyncVaultGateway {
|
|||
export class FakeManualSyncAnkiGateway implements AnkiGateway {
|
||||
public ensuredDecks: string[][] = [];
|
||||
public addedNotes: AddAnkiNoteInput[] = [];
|
||||
public deletedNotes: number[][] = [];
|
||||
public updatedNotes: UpdateAnkiNoteInput[] = [];
|
||||
public changedDecks: ChangeDeckInput[] = [];
|
||||
public deletedDecks: string[][] = [];
|
||||
public storedMedia: MediaAsset[] = [];
|
||||
public noteSummariesById = new Map<number, AnkiNoteSummary>();
|
||||
public deckStatsByName = new Map<string, DeckStat>();
|
||||
public modelDetailsByName: Record<string, NoteModelDetails> = {
|
||||
Basic: { fieldNames: ["Front", "Back"], isCloze: false },
|
||||
Cloze: { fieldNames: ["Text", "Extra"], isCloze: true },
|
||||
|
|
@ -160,10 +163,18 @@ export class FakeManualSyncAnkiGateway implements AnkiGateway {
|
|||
return Object.keys(this.modelDetailsByName);
|
||||
}
|
||||
|
||||
async listDeckNames(): Promise<string[]> {
|
||||
return Array.from(this.deckStatsByName.keys());
|
||||
}
|
||||
|
||||
async getModelDetails(modelName: string): Promise<NoteModelDetails> {
|
||||
return this.modelDetailsByName[modelName] ?? { fieldNames: ["Front", "Back"], isCloze: false };
|
||||
}
|
||||
|
||||
async getDeckStats(deckNames: string[]): Promise<DeckStat[]> {
|
||||
return deckNames.map((deckName) => this.deckStatsByName.get(deckName) ?? { deckName, noteCount: 0 });
|
||||
}
|
||||
|
||||
async getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]> {
|
||||
return noteIds.flatMap((noteId) => {
|
||||
const summary = this.noteSummariesById.get(noteId);
|
||||
|
|
@ -184,6 +195,10 @@ export class FakeManualSyncAnkiGateway implements AnkiGateway {
|
|||
});
|
||||
}
|
||||
|
||||
async deleteNotes(noteIds: number[]): Promise<void> {
|
||||
this.deletedNotes.push(noteIds);
|
||||
}
|
||||
|
||||
async updateNote(input: UpdateAnkiNoteInput): Promise<void> {
|
||||
await this.updateNotes([input]);
|
||||
}
|
||||
|
|
@ -196,6 +211,10 @@ export class FakeManualSyncAnkiGateway implements AnkiGateway {
|
|||
this.changedDecks.push(...inputs);
|
||||
}
|
||||
|
||||
async deleteDecks(deckNames: string[]): Promise<void> {
|
||||
this.deletedDecks.push(deckNames);
|
||||
}
|
||||
|
||||
async storeMedia(asset: MediaAsset): Promise<void> {
|
||||
await this.storeMediaFiles([asset]);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue