insile_OpenWords/main.ts
2025-05-01 13:51:58 +08:00

1186 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, TFile, Setting, TFolder, MarkdownRenderer, normalizePath } from 'obsidian';
const posTagger = require( 'wink-pos-tagger' );
const dayjs = require("dayjs");
import { supermemo, SuperMemoItem, SuperMemoGrade } from 'supermemo';
// 插件设置
interface OpenWordsSettings {
folderPath: string; // 用户指定的单词文件夹路径
indexPath: string; // 用户指定的索引文件夹路径
enableWords1: boolean;
enableWords2: boolean;
enableWords3: boolean;
enableWords4: boolean;
enableWords5: boolean;
enableWords6: boolean;
enableWords7: boolean;
enableWords8: boolean;
}
// 默认设置
const DEFAULT_SETTINGS: OpenWordsSettings = {
folderPath: normalizePath(""),
indexPath: normalizePath("索引"),
enableWords1: false,
enableWords2: false,
enableWords3: false,
enableWords4: false,
enableWords5: false,
enableWords6: false,
enableWords7: false,
enableWords8: false,
}
// 单词卡片信息
interface CardInfo extends SuperMemoItem {
front: string;
dueDate: string;
path: string;
isMastered: boolean;
}
// 插件主类
export default class OpenWords extends Plugin {
settings: OpenWordsSettings; // 插件设置
settingsSnapshot: OpenWordsSettings; // 插件设置快照
statusBarItem: HTMLElement; // 状态栏元素
alllCards: Map<string, CardInfo> = new Map(); // 存储所有单词的元数据
allCards: Map<string, CardInfo> = new Map(); // 存储作用域单词的元数据
newCards: Map<string, CardInfo> = new Map(); // 存储新单词的元数据
dueCards: Map<string, CardInfo> = new Map(); // 存储旧单词的元数据
tagger = posTagger(); // 词性标注器实例
// 插件加载时执行的操作
async onload() {
// 加载插件设置参数, 状态栏, 设置选项卡, 左侧工具栏按钮
await this.loadSettings();
this.statusBarItem = this.addStatusBarItem();
this.addSettingTab(new OpenWordsSettingTab(this.app, this));
const ribbonIconEl = this.addRibbonIcon('slack', 'OpenWords', async (evt: MouseEvent) => {
new LearningTypeModal(this.app, this).open();
});
// 等待布局完成
this.app.workspace.onLayoutReady(async () => {
// 学习模式命令
this.addCommand({
id: 'LearningTypeModal',
name: '学习模式',
callback: () => {
new LearningTypeModal(this.app, this).open();
}
});
// 添加双链命令
this.addCommand({
id: 'addDoubleBrackets',
name: '添加双链',
callback: () => {
this.addDoubleBrackets();
}
});
// 监听单词文件创建事件
this.registerEvent(this.app.vault.on("create", (file: TFile) => {
if (file.path.endsWith(".md") && file.path.startsWith(this.settings.folderPath)) {
this.loadWordMetadata(file);
}
}));
// 监听单词文件删除事件
this.registerEvent(this.app.vault.on("delete", (file: TFile) => {
if (file.path.endsWith(".md") && file.path.startsWith(this.settings.folderPath)) {
this.allCards.delete(file.basename);
this.newCards.delete(file.basename);
this.dueCards.delete(file.basename);
this.statusBarItem.setText(`${this.newCards.size} + ${this.dueCards.size} = ${this.allCards.size}`);
}
}));
// 监听单词文件缓存修改事件
this.registerEvent(this.app.metadataCache.on("changed", (file) => {
if (file.path.endsWith(".md") && file.path.startsWith(this.settings.folderPath)) {
this.loadWordMetadata(file);
}
}));
// 扫描所有单词文件
await this.scanAllNotes();
});
}
// 加载单词元数据
async loadWordMetadata(file: TFile) {
const allCards = this.allCards;
const newCards = this.newCards;
const dueCards = this.dueCards;
// 先删除旧的单词元数据
allCards.delete(file.basename);
newCards.delete(file.basename);
dueCards.delete(file.basename);
// 从 metadataCache 获取文件的 FrontMatter
const fileCache = this.app.metadataCache.getFileCache(file);
const frontMatter = fileCache?.frontmatter;
// 如果文件没有 FrontMatter直接返回
if (!frontMatter) {
return;
}
const tags: string[] = frontMatter.tags || [];
const isMastered = frontMatter. === true;
console.log(frontMatter)
const isEnabled =
(this.settings.enableWords1 && tags.includes('级别/小学')) ||
(this.settings.enableWords2 && tags.includes('级别/中考')) ||
(this.settings.enableWords3 && tags.includes('级别/高考四级')) ||
(this.settings.enableWords4 && tags.includes('级别/考研')) ||
(this.settings.enableWords5 && tags.includes('级别/六级')) ||
(this.settings.enableWords6 && tags.includes('级别/雅思')) ||
(this.settings.enableWords7 && tags.includes('级别/托福')) ||
(this.settings.enableWords8 && tags.includes('级别/GRE'));
const card: CardInfo = {
front: file.basename,
path: file.path,
dueDate: frontMatter.到期日,
interval: frontMatter.间隔,
efactor: frontMatter.易记因子 * 0.01,
repetition: frontMatter.重复次数,
isMastered: isMastered,
};
const isMasteredOld = this.alllCards.get(card.front)?.isMastered;
this.alllCards.set(card.front, card);
// 再添加新的单词元数据
if (isEnabled && !isMastered) {
allCards.set(card.front, card);
if (frontMatter. === 0) {
newCards.set(card.front, card);
} else {
dueCards.set(card.front, card);
}
}
// 如果掌握状态发生变化,更新复选框
if (isMastered !== isMasteredOld && isMasteredOld !== undefined) {
const newCheckbox = isMastered ? 'x' : ' ';
const oldCheckbox = isMasteredOld ? 'x' : ' ';
console.log(file, newCheckbox, oldCheckbox)
await this.syncMetadataToCheckbox(file, newCheckbox, oldCheckbox);
}
this.statusBarItem.setText(`${this.newCards.size} + ${this.dueCards.size} = ${this.allCards.size}`);
}
// 扫描所有单词文件
async scanAllNotes() {
this.alllCards.clear();
this.allCards.clear();
this.newCards.clear();
this.dueCards.clear();
const files = this.app.vault.getMarkdownFiles();
const filteredFiles = files.filter(file => file.path.startsWith(this.settings.folderPath));
if (filteredFiles.length === 0) {
new Notice('指定的文件夹下没有 Markdown 文件!');
this.statusBarItem.setText(`${this.newCards.size} + ${this.dueCards.size} = ${this.allCards.size}`);
return;
}
const notice = new Notice('缓存中...', 0); // 创建一个持续显示的 Notice
await Promise.all(filteredFiles.map(async (file) => {
await this.loadWordMetadata(file);
notice.setMessage(`缓存中... ${this.allCards.size}/${filteredFiles.length}`); // 更新 Notice 的消息
}));
notice.setMessage(`缓存完成!共 ${this.allCards.size} 个单词`); // 完成后更新消息
setTimeout(() => notice.hide(), 2000); // 2 秒后自动隐藏 Notice
}
// 更新单词属性
async updateCard(card: CardInfo, grade: SuperMemoGrade) {
const result = supermemo(card, grade);
const newDate = dayjs().add(result.interval, 'day').format('YYYY-MM-DD');
const file = this.app.vault.getFileByPath(card.path) as TFile;
this.newCards.delete(card.front);
this.dueCards.delete(card.front);
await this.app.fileManager.processFrontMatter(file, (frontMatter) => {
frontMatter. = newDate;
frontMatter. = result.interval;
frontMatter. = Math.round(result.efactor * 100);
frontMatter. = result.repetition;
});
new Notice(`${card.front} \n易记因子: ${result.efactor.toFixed(2)} \n重复次数: ${result.repetition} \n间隔: ${result.interval} \n到期日: ${newDate}`);
}
// 重置单词属性
async resetCard() {
if (this.allCards.size === 0) {
new Notice("没有单词需要重置!");
return;
}
const notice = new Notice('重置中...', 0); // 创建一个持续显示的 Notice
let count = 0; // 计数器
const cardList = Array.from(this.allCards.values()); // 将 Map 转换为数组
for (const card of cardList) {
const file = this.app.vault.getFileByPath(card.path) as TFile;
await this.app.fileManager.processFrontMatter(file, (frontMatter) => {
frontMatter. = dayjs().format('YYYY-MM-DD');
frontMatter. = 0;
frontMatter. = 250;
frontMatter. = 0;
});
count++; // 增加计数器
notice.setMessage(`重置中... ${count}/${this.allCards.size}`); // 更新 Notice 的消息
};
notice.setMessage(`重置完成!共 ${count} 个单词`); // 完成后更新消息
setTimeout(() => notice.hide(), 2000); // 2 秒后自动隐藏 Notice
}
// 建立单词双链
async addDoubleBrackets() {
const unmasteredWords = new Set(Array.from(this.allCards.values())
.filter(card => card.efactor <= 2.5)
.map(card => card.front)
);
if (unmasteredWords.size === 0) {
new Notice("没有单词需要添加双链!");
return;
}
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (activeView) {
const editor = activeView.editor;
const doc = editor.getDoc();
const content = doc.getValue(); // 获取当前文档内容
const words = content.split(/(\[\[.+?\]\]|\s+|\b)/); // 将文本拆分成单词, 保留双链、空格和分隔符
const updatedWords = words.map((word) => {
const doubleBracketMatch = word.match(/^\[\[(.+?)(\|(.+?))?\]\]$/); // 检查是否是双链格式 [[名称|别名]]
if (doubleBracketMatch) { // 如果是双链格式
const innerWord = doubleBracketMatch[1]; // 获取双链中的名称
const alias = doubleBracketMatch[3]; // 获取双链中的别名
const doc = this.tagger.tagSentence(innerWord);
const lemma = doc[0].lemma
// 如果双链中的名称不在单词表中,去除双链,保留别名或名称
if (!unmasteredWords.has(lemma) && !unmasteredWords.has(innerWord)) {
return alias; // 如果有别名,保留别名;否则保留名称
}
// 如果双链中的名称在单词表中,保持原样
return word;
} else { // 如果不是双链格式
const doc = this.tagger.tagSentence(word);
const lemma = doc[0]?.lemma ?? word.toLowerCase(); // 获取动词的原形
// 检查词干是否在单词表中
if (unmasteredWords.has(lemma)) {
return `[[${lemma}|${word}]]`; // 如果匹配到单词表中的词干,添加双链
}
if (unmasteredWords.has(word)) {
return `[[${word}|${word}]]`; // 如果匹配到单词表中的词干,添加双链
}
return word; // 否则返回原始单词
}
});
const updatedContent = updatedWords.join('');
doc.setValue(updatedContent); // 用更新后的内容替换原文
new Notice('已添加双链!');
}
}
// 生成单词索引
async generateIndex() {
const notice = new Notice('索引中...', 0); // 创建一个持续显示的 Notice
const levels = [
"级别/小学", "级别/中考", "级别/高考四级", "级别/考研",
"级别/六级", "级别/雅思", "级别/托福", "级别/GRE"
];
const wordsDir = this.settings.folderPath; // 单词文件夹路径
const indexDir = this.settings.indexPath; // 索引文件夹路径
// 创建索引文件夹 (如果不存在)
const existingIndexFolder = this.app.vault.getFolderByPath(indexDir);
console.log(indexDir,existingIndexFolder)
if (!existingIndexFolder) {
await this.app.vault.createFolder(indexDir);
}
// 遍历所有单词,记录每个单词的级别
const wordRecords: Record<string, { [letter: string]: string[] }> = {};
for (const level of levels) {
wordRecords[level] = {};
}
const files = this.app.vault.getMarkdownFiles().filter(file => file.path.startsWith(wordsDir));
for (const file of files) {
const frontMatter = this.app.metadataCache.getFileCache(file)?.frontmatter;
if (!frontMatter) continue; // 如果没有 FrontMatter跳过
const tags: string[] = frontMatter.tags || [];
const isMastered = frontMatter?. === true;
for (const level of levels) {
if (tags.includes(level)) {
const letter = file.basename[0].toUpperCase(); // 获取单词首字母
if (!wordRecords[level][letter]) {
wordRecords[level][letter] = [];
}
if (isMastered)
wordRecords[level][letter].push(`- [x] [[${file.basename}]]`);
else {
wordRecords[level][letter].push(`- [ ] [[${file.basename}]]`);
}
}
}
}
const totalWordsList: Record<string, number> = {}; // 记录每个级别的总单词数
// 遍历级别,生成索引
for (const level of levels) {
const levelName = level.split('/').pop(); // 获取级别名称
const levelFolderPath = `${indexDir}/${levelName}`;
const existingLevelFolder = this.app.vault.getFolderByPath(levelFolderPath);
notice.setMessage(`索引中... ${levelName}`); // 更新 Notice 的消息
// 如果级别文件夹不存在,则创建
if (!existingLevelFolder) {
await this.app.vault.createFolder(levelFolderPath);
}
let totalWords = 0; // 统计总单词数
const letterStats: { letter: string; count: number }[] = []; // 记录每个字母的单词数量
for (let i = 65; i <= 90; i++) {
const letter = String.fromCharCode(i);
const words = wordRecords[level][letter] || []; // 如果没有单词,返回空数组
words.sort((a, b) => {
const extractName = (line: string) => {
const match = line.match(/\[\[(.+?)\]\]/);
return match ? match[1] : line;
};
return extractName(a).localeCompare(extractName(b));
}); // 按字母顺序排序
totalWords += words.length; // 累加总单词数
letterStats.push({ letter, count: words.length }); // 添加统计信息
// 创建或覆盖字母索引文件
const letterFilePath = `${levelFolderPath}/${levelName}.${letter}.md`;
const content = words.join('\n');
await this.writeFile(letterFilePath, `##### ${levelName}.${letter} ${words.length}\n${content}`); // 写入索引文件
}
totalWordsList[level] = totalWords; // 记录每个级别的总单词数
// 生成总索引文件
const totalIndexFilePath = `${levelFolderPath}/单词索引.${levelName}.md`;
const totalIndexContent = [
`##### 单词索引.${levelName} ${totalWords}`,
`| 首字母 | 数量 |`,
`| :-------: | --: |`,
...letterStats.map(stat => `| [${stat.letter}](${levelName}.${stat.letter}) | ${stat.count} |`)
].join('\n');
await this.writeFile(totalIndexFilePath, totalIndexContent); // 写入索引文件
}
// 生成全索引
const fullIndexFolderPath = `${indexDir}/全索引`;
notice.setMessage(`索引中... 全索引`); // 更新 Notice 的消息
const existingFullIndexFolder = this.app.vault.getFolderByPath(fullIndexFolderPath);
if (!existingFullIndexFolder) {
await this.app.vault.createFolder(fullIndexFolderPath);
}
// 先收集所有字母到一个新的对象
const allWordsByLetter: Record<string, string[]> = {}; // { A: [...], B: [...], ... }
for (const levelRecords of Object.values(wordRecords)) {
for (const [letter, words] of Object.entries(levelRecords)) {
if (!allWordsByLetter[letter]) {
allWordsByLetter[letter] = [];
}
allWordsByLetter[letter].push(...words);
}
}
let totalWords = 0;
const letterStats: { letter: string; count: number }[] = [];
for (let i = 65; i <= 90; i++) {
const letter = String.fromCharCode(i);
let words = allWordsByLetter[letter] || []; // 如果没有单词,返回空数组
words = Array.from(new Set(words)); // 去重
totalWords += words.length; // 累加总单词数
letterStats.push({ letter, count: words.length });
words.sort((a, b) => {
const extractName = (line: string) => {
const match = line.match(/\[\[(.+?)\]\]/);
return match ? match[1] : line;
};
return extractName(a).localeCompare(extractName(b));
});
// 创建或覆盖字母索引文件
const letterFilePath = `${fullIndexFolderPath}/全索引.${letter}.md`;
const content = words.join('\n');
await this.writeFile(letterFilePath, `##### 全索引.${letter} ${words.length}\n${content}`); // 写入索引文件
}
// 生成总索引文件
const totalIndexFilePath = `${fullIndexFolderPath}/单词索引.全索引.md`;
const totalIndexContent = [
`##### 单词索引.全索引 ${totalWords}`,
`| 首字母 | 数量 |`,
`| :-------: | --: |`,
...letterStats.map(stat => `| [${stat.letter}](全索引.${stat.letter}) | ${stat.count} |`)
].join('\n');
await this.writeFile(totalIndexFilePath, totalIndexContent); // 写入索引文件
const finalIndexPath = `${indexDir}/英语单词.md`;
// 定义表头
const header = [
`##### [[英语单词说明|英语单词]]`,
``,
`| | [n.](单词索引.名词) | [v.](单词索引.动词) | [adj.](单词索引.形容词) | [adv.](单词索引.副词) | [prep.](单词索引.介词) | [pron.](单词索引.代词) | [det.](单词索引.限定词) | [conj.](单词索引.连词) |`,
`| :--------------: | :------------: | :------------: | :---------------: | :--------------: | :---------------: | :---------------: | :---------------: | :---------------: |`,
`| [全索引](单词索引.全索引) | [小学](单词索引.小学) | [中考](单词索引.中考) | [高四](单词索引.高考四级) | [考研](单词索引.考研) | [六级](单词索引.六级) | [雅思](单词索引.雅思) | [托福](单词索引.托福) | [GRE](单词索引.GRE) |`,
`| ${this.alllCards.size} | ${totalWordsList["级别/小学"]} | ${totalWordsList["级别/中考"]} | ${totalWordsList["级别/高考四级"]} | ${totalWordsList["级别/考研"]} | ${totalWordsList["级别/六级"]} | ${totalWordsList["级别/雅思"]} | ${totalWordsList["级别/托福"]} | ${totalWordsList["级别/GRE"]} |`
];
// 定义字母索引行
const alphabetRows = [];
for (let i = 65; i <= 90; i++) {
const letter = String.fromCharCode(i);
alphabetRows.push(
`| [${letter}](全索引.${letter}) | [${letter}](小学.${letter}) | [${letter}](中考.${letter}) | [${letter}](高考四级.${letter}) | [${letter}](考研.${letter}) | [${letter}](六级.${letter}) | [${letter}](雅思.${letter}) | [${letter}](托福.${letter}) | [${letter}](GRE.${letter}) |`
);
}
// 合并表头和字母索引行
const finalContent = [...header, ...alphabetRows].join('\n');
await this.writeFile(finalIndexPath, finalContent); // 写入索引文件
notice.setMessage("索引生成完成!");
setTimeout(() => notice.hide(), 2000); // 2 秒后自动隐藏 Notice
}
// 覆盖写入文件
async writeFile(filePath: string, content: string) {
const existingFile = this.app.vault.getFileByPath(filePath);
if (existingFile) {
await this.app.vault.process(existingFile as TFile, () => content);
} else {
await this.app.vault.create(filePath, content);
}
}
// 监听单词属性变化并同步到复选框状态
async syncMetadataToCheckbox(file: TFile, newCheckbox: string, oldCheckbox: string) {
const resolvedLinks = this.app.metadataCache.resolvedLinks;
const backlinks: Record<string, number> = {};
for (const [sourcePath, links] of Object.entries(resolvedLinks)) {
if (links[file.path]) {
backlinks[sourcePath] = links[file.path];
}
}
for (const [sourcePath, _] of Object.entries(backlinks)) {
if (sourcePath.startsWith(this.settings.indexPath)) {
const sourceFile = this.app.vault.getFileByPath(sourcePath)
if (sourceFile) {
const newWord = `- [${newCheckbox}] [[${file.basename}]]`;
const oldWord = `- [${oldCheckbox}] [[${file.basename}]]`;
await this.app.vault.process(sourceFile as TFile, (data) => data.replace(oldWord, newWord));
}
};
}
}
onunload() {
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
// 插件设置选项卡
class OpenWordsSettingTab extends PluginSettingTab {
plugin: OpenWords;
constructor(app: App, plugin: OpenWords) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
const textContainer = containerEl.createDiv({ cls: 'openwords-text-grid' });
const toggleContainer = containerEl.createDiv({ cls: 'openwords-toggle-grid' });
this.plugin.settingsSnapshot = Object.assign({}, this.plugin.settings); // 创建设置快照
new Setting(textContainer)
.setName('单词文件夹路径')
.setDesc('指定存放单词文件的文件夹路径,默认为仓库:/')
.addText(text => text
.setValue(this.plugin.settings.folderPath)
.onChange(async (value) => {
this.plugin.settingsSnapshot.folderPath = normalizePath(value);
}));
new Setting(textContainer)
.setName('索引文件夹路径')
.setDesc('指定存放索引文件的文件夹路径,默认为仓库:索引')
.addText(text => text
.setValue(this.plugin.settings.indexPath)
.onChange(async (value) => {
this.plugin.settingsSnapshot.indexPath = normalizePath(value);
}));
new Setting(toggleContainer)
.setName('小学')
.setDesc('启用小学单词')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableWords1)
.onChange(async (value) => {
this.plugin.settingsSnapshot.enableWords1 = value;
}));
new Setting(toggleContainer)
.setName('中考')
.setDesc('启用中考单词')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableWords2)
.onChange(async (value) => {
this.plugin.settingsSnapshot.enableWords2 = value;
}));
new Setting(toggleContainer)
.setName('高四')
.setDesc('启用高四单词')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableWords3)
.onChange(async (value) => {
this.plugin.settingsSnapshot.enableWords3 = value;
}));
new Setting(toggleContainer)
.setName('考研')
.setDesc('启用考研单词')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableWords4)
.onChange(async (value) => {
this.plugin.settingsSnapshot.enableWords4 = value;
}));
new Setting(toggleContainer)
.setName('六级')
.setDesc('启用六级单词')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableWords5)
.onChange(async (value) => {
this.plugin.settingsSnapshot.enableWords5 = value;
}));
new Setting(toggleContainer)
.setName('雅思')
.setDesc('启用雅思单词')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableWords6)
.onChange(async (value) => {
this.plugin.settingsSnapshot.enableWords6 = value;
}));
new Setting(toggleContainer)
.setName('托福')
.setDesc('启用托福单词')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableWords7)
.onChange(async (value) => {
this.plugin.settingsSnapshot.enableWords7 = value;
}));
new Setting(toggleContainer)
.setName('GRE')
.setDesc('启用GRE单词')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableWords8)
.onChange(async (value) => {
this.plugin.settingsSnapshot.enableWords8 = value;
}));
new Setting(containerEl)
.setName('应用设置并缓存')
.setDesc('保存设置并重新缓存单词状态')
.addButton(button => button
.setButtonText('应用')
.setCta() // 设置为主要按钮样式
.onClick(async () => {
if (button.disabled) return; // 如果按钮已禁用,直接返回
button.setDisabled(true); // 禁用按钮
button.setButtonText('处理中...'); // 更新按钮文本
try {
this.plugin.settings = {...this.plugin.settingsSnapshot}; // 更新插件设置
await this.plugin.saveSettings(); // 保存设置
await this.plugin.scanAllNotes(); // 重新缓存单词
} catch (error) {
console.error('缓存过程中发生错误:', error);
new Notice('缓存失败,请检查控制台日志!');
} finally {
button.setDisabled(false); // 重新启用按钮
button.setButtonText('应用'); // 恢复按钮文本
}
}));
new Setting(containerEl)
.setName('生成索引')
.setDesc('按级别生成所有单词的首字母索引文件')
.addButton(button => button
.setButtonText('生成')
.onClick(async () => {
if (button.disabled) return; // 如果按钮已禁用,直接返回
button.setDisabled(true); // 禁用按钮
button.setButtonText('处理中...'); // 更新按钮文本
try {
await this.plugin.generateIndex(); // 生成索引文件
} catch (error) {
console.error('生成索引过程中发生错误:', error);
new Notice('生成索引失败,请检查控制台日志!');
} finally {
button.setDisabled(false); // 重新启用按钮
button.setButtonText('生成'); // 恢复按钮文本
}
}));
new Setting(containerEl)
.setName('重置单词属性')
.setDesc('重置作用域单词的易记因子、重复次数、间隔和到期日')
.addButton(button => button
.setButtonText('重置')
.onClick(async () => {
if (button.disabled) return; // 如果按钮已禁用,直接返回
button.setDisabled(true); // 禁用按钮
button.setButtonText('处理中...'); // 更新按钮文本
try {
await this.plugin.resetCard(); // 重置单词属性
} catch (error) {
console.error('重置过程中发生错误:', error);
new Notice('重置失败,请检查控制台日志!');
} finally {
button.setDisabled(false); // 重新启用按钮
button.setButtonText('重置'); // 恢复按钮文本
}
}));
}
}
// 学习模式模态框
class LearningTypeModal extends Modal {
plugin: OpenWords;
constructor(app: App, plugin: OpenWords) {
super(app);
this.plugin = plugin;
}
onOpen() {
this.modalEl.addClass('type-modal'); // 添加自定义样式类
const { contentEl } = this;
contentEl.empty();
const title = contentEl.createDiv({ cls: 'type-title', text: '选择学习模式' });
const buttonContainer = contentEl.createDiv({ cls: 'type-button-grid' });
new Setting(buttonContainer)
.setName(`学习新词 ( 重复次数为零, 剩余 ${this.plugin.newCards.size} )`)
.addButton(btn => btn
.setButtonText("开始")
.onClick(() => {
if (this.plugin.newCards.size > 0) {
this.close();
new LearningModal(this.app, this.plugin, 'new').open();
} else {
new Notice("没有新词可学了!");
}
}));
new Setting(buttonContainer)
.setName(`复习旧词 ( 重复次数不为零, 剩余 ${this.plugin.dueCards.size} )`)
.addButton(btn => btn
.setButtonText("开始")
.onClick(() => {
if (this.plugin.dueCards.size > 0) {
this.close();
new LearningModal(this.app, this.plugin, 'review').open();
} else {
new Notice("没有需要复习的卡片!");
}
}));
new Setting(buttonContainer)
.setName(`掌握列表 ( 所有单词 )`)
.addButton(btn => btn
.setButtonText("开始")
.onClick(() => {
this.close();
new WordListModal(this.app, this.plugin).open();
}));
new Setting(buttonContainer)
.setName('默写单词 ( 作用域单词 )')
.addButton(btn => btn
.setButtonText('开始')
.onClick(() => {
this.close();
new SpellingModal(this.app, this.plugin).open();
}));
new Setting(buttonContainer)
.setName(`添加双链 ( 作用域中易记因子 <= 2.5 )`)
.addButton(btn => btn
.setButtonText("开始")
.onClick(() => {
this.close();
this.plugin.addDoubleBrackets();
}));
}
}
// 背单词模态框
class LearningModal extends Modal {
plugin: OpenWords;
mode: 'new' | 'review';
currentCard: CardInfo;
constructor(app: App, plugin: OpenWords, mode: 'new' | 'review') {
super(app);
this.plugin = plugin;
this.mode = mode;
}
onOpen() {
this.pickNextCard();
this.render();
// 监听数字键 0-5 的按键事件
const handleKeydown = (event: KeyboardEvent) => {
if (event.key >= '0' && event.key <= '5') {
const grade = parseInt(event.key) as SuperMemoGrade;
this.rateCard(grade);
}
};
window.addEventListener('keydown', handleKeydown);
// 在模态框关闭时移除事件监听器
this.onClose = () => {
window.removeEventListener('keydown', handleKeydown);
const { contentEl } = this;
contentEl.empty();
};
}
pickNextCard() {
const now = dayjs(); // 获取当前时间
let pool: CardInfo[];
// 新词池是指所有新词, 旧词池是指所有已过期的旧词
if (this.mode === 'new') {
pool = Array.from(this.plugin.newCards.values()); // 将 Set 转换为数组
if (pool.length === 0) {
this.close();
new Notice("已完成所有新词!");
return;
}
} else {
pool = Array.from(this.plugin.dueCards.values())
.filter(card => dayjs(card.dueDate).isBefore(now)) // 筛选已过期的单词
if (pool.length === 0) {
this.close();
new Notice("没有需要复习的卡片!");
return;
}
}
// 单词调度 70% 纯随机, 30% 从易记因子和重复次数最低的 20% 中随机
const randomMode = Math.random() < 0.7;
if (randomMode) {
this.currentCard = pool[Math.floor(Math.random() * pool.length)];
} else {
const sortedPool = pool.sort((a, b) => {
const efactorDiff = (a.efactor || 1) - (b.efactor || 1); // 易记因子升序
if (efactorDiff !== 0) return efactorDiff;
return a.repetition - b.repetition; // 重复次数升序
});
const halfPool = sortedPool.slice(0, Math.ceil(sortedPool.length / 5)); // 取前 20%
this.currentCard = halfPool[Math.floor(Math.random() * halfPool.length)];
}
}
render() {
const { contentEl } = this;
contentEl.empty();
if (!this.currentCard) return;
// 创建卡片容器并固定样式
const cardContainer = contentEl.createDiv({ cls: 'anki-card' });
// 标记当前是否显示 Markdown 内容
let isShowingMarkdown = false;
// 初始显示单词
const wordContent = cardContainer.createDiv({ cls: 'anki-card-content' });
wordContent.textContent = this.currentCard.front;
// 添加鼠标悬浮事件切换内容
cardContainer.addEventListener('mouseenter', async () => {
if (isShowingMarkdown) return; // 如果已经显示 Markdown 内容,则不重复加载
const file = this.plugin.app.vault.getAbstractFileByPath(this.currentCard.path);
if (file instanceof TFile) {
const markdownContent = await this.plugin.app.vault.read(file);
// 清空卡片内容并渲染 Markdown 内容
cardContainer.empty();
const markdownRenderContainer = cardContainer.createDiv({ cls: 'anki-markdown-content' });
await MarkdownRenderer.render(
this.app,
markdownContent,
markdownRenderContainer,
file.path,
this.plugin
);
isShowingMarkdown = true;
} else {
new Notice('无法加载单词的 Markdown 文件!');
}
});
cardContainer.addEventListener('mouseleave', () => {
if (!isShowingMarkdown) return; // 如果已经显示单词内容,则不重复加载
// 切换回显示单词
cardContainer.empty();
const wordContent = cardContainer.createDiv({ cls: 'anki-card-content' });
wordContent.textContent = this.currentCard.front;
isShowingMarkdown = false;
});
const settingsContainer = contentEl.createDiv({ cls: 'anki-settings' });
new Setting(settingsContainer)
.setName(`评分 0: 回答错误, 完全不会`)
.addButton(btn => btn
.setButtonText('0')
.onClick(() => this.rateCard(0 as SuperMemoGrade)))
new Setting(settingsContainer)
.setName(`评分 1: 回答错误, 看到正确答案后感觉很熟悉`)
.addButton(btn => btn
.setButtonText('1')
.onClick(() => this.rateCard(1 as SuperMemoGrade)))
new Setting(settingsContainer)
.setName(`评分 2: 回答错误, 看到正确答案后感觉很容易记住`)
.addButton(btn => btn
.setButtonText('2')
.onClick(() => this.rateCard(2 as SuperMemoGrade)))
new Setting(settingsContainer)
.setName(`评分 3: 回答正确, 需要花费很大力气才能回忆起来`)
.addButton(btn => btn
.setButtonText('3')
.onClick(() => this.rateCard(3 as SuperMemoGrade)))
new Setting(settingsContainer)
.setName(`评分 4: 回答正确, 需要经过一番犹豫才做出反应`)
.addButton(btn => btn
.setButtonText('4')
.onClick(() => this.rateCard(4 as SuperMemoGrade)))
new Setting(settingsContainer)
.setName(`评分 5: 回答正确, 完美响应`)
.addButton(btn => btn
.setButtonText('5')
.onClick(() => this.rateCard(5 as SuperMemoGrade)))
}
async rateCard(grade: SuperMemoGrade) {
await this.plugin.updateCard(this.currentCard, grade);
this.pickNextCard();
this.render();
}
}
// 单词列表模态框
class WordListModal extends Modal {
plugin: OpenWords;
selectedLetter: string | null = null; // 当前选中的首字母
selectedLevel: string | null = null; // 当前选中的级别
constructor(app: App, plugin: OpenWords) {
super(app);
this.plugin = plugin;
}
async onOpen() {
this.modalEl.addClass('word-list-modal');
const { contentEl } = this;
contentEl.empty();
const title = contentEl.createDiv({ cls: 'word-list-title', text: '掌握列表' });
const filterContainer = contentEl.createDiv({ cls: 'word-list-filters' });
const listContainer = contentEl.createDiv({ cls: 'word-list-container' });
// 添加首字母筛选
const letterFilter = new Setting(filterContainer)
.setName('首字母筛选')
.addDropdown(dropdown => {
dropdown.addOption('', '全部');
for (let i = 65; i <= 90; i++) {
const letter = String.fromCharCode(i);
dropdown.addOption(letter, letter);
}
dropdown.setValue(this.selectedLetter || '');
dropdown.onChange(value => {
this.selectedLetter = value || null;
this.renderWordList(listContainer);
});
});
// 添加级别筛选
const levelFilter = new Setting(filterContainer)
.setName('级别筛选')
.addDropdown(dropdown => {
dropdown.addOption('', '全部');
const levels = [
'级别/小学', '级别/中考', '级别/高考四级', '级别/考研',
'级别/六级', '级别/雅思', '级别/托福', '级别/GRE'
];
levels.forEach(level => dropdown.addOption(level, level.split('/')[1]));
dropdown.setValue(this.selectedLevel || '');
dropdown.onChange(value => {
this.selectedLevel = value || null;
this.renderWordList(listContainer);
});
});
// 初始不渲染单词列表
listContainer.createDiv({ text: '请选择筛选条件以查看单词列表。' });
}
renderWordList(container: HTMLElement) {
container.empty();
// 如果没有选择筛选条件,则不渲染内容
if (!this.selectedLetter && !this.selectedLevel) {
container.createDiv({ text: '请选择筛选条件以查看单词列表。' });
return;
}
// 根据筛选条件过滤单词
const filteredWords = Array.from(this.plugin.alllCards.values()).filter(card => {
const matchesLetter = this.selectedLetter
? card.front.startsWith(this.selectedLetter.toLowerCase())
: true;
const matchesLevel = this.selectedLevel
? this.plugin.app.metadataCache.getFileCache(this.plugin.app.vault.getAbstractFileByPath(card.path) as TFile)?.frontmatter?.tags?.includes(this.selectedLevel)
: true;
return matchesLetter && matchesLevel;
}).sort((a, b) => a.front.localeCompare(b.front)); // 按字母顺序排序
// 渲染单词列表
if (filteredWords.length === 0) {
container.createDiv({ text: '没有符合条件的单词。' });
return;
}
filteredWords.forEach(card => {
const wordItem = container.createDiv({ cls: 'word-list-item' });
// 左侧显示单词
const wordText = wordItem.createDiv({ cls: 'word-text', text: card.front });
const toggleContainer = wordItem.createDiv({ cls: 'toggle-container' });
new Setting(toggleContainer)
// .setName(card.front)
.addToggle(toggle => {
toggle.setValue(card.isMastered) // 设置初始复选框状态
.onChange(async (value) => {
// 更新单词的掌握状态
await this.plugin.app.fileManager.processFrontMatter(
this.plugin.app.vault.getAbstractFileByPath(card.path) as TFile,
(frontMatter) => {
frontMatter. = value;
}
);
new Notice(`单词 "${card.front}" 已更新为 ${value ? '掌握' : '未掌握'}`);
});
});
// 创建 Markdown 预览容器
const previewContainer = wordText.createDiv({ cls: 'word-preview-inline' });
// 添加鼠标悬停事件以渲染 Markdown 内容
wordText.addEventListener('mouseenter', async () => {
const file = this.plugin.app.vault.getAbstractFileByPath(card.path);
if (file instanceof TFile) {
const markdownContent = await this.plugin.app.vault.read(file);
previewContainer.empty();
await MarkdownRenderer.render(
this.app,
markdownContent,
previewContainer,
file.path,
this.plugin
);
}
});
// 鼠标离开时清空预览内容
wordText.addEventListener('mouseleave', () => {
previewContainer.empty();
});
});
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
// 默写单词模态框
class SpellingModal extends Modal {
plugin: OpenWords;
currentCard: CardInfo | null = null;
constructor(app: App, plugin: OpenWords) {
super(app);
this.plugin = plugin;
}
async onOpen() {
const { contentEl } = this;
contentEl.empty();
this.modalEl.addClass('spelling-modal'); // 添加样式类
const title = contentEl.createDiv({ cls: 'spelling-title', text: '默写单词' });
const wordMeaningContainer = contentEl.createDiv({ cls: 'word-meaning-container' });
const inputContainer = contentEl.createDiv({ cls: 'input-container' });
const feedbackContainer = contentEl.createDiv({ cls: 'feedback-container' });
// 初始化第一个单词
this.pickNextCard(wordMeaningContainer, inputContainer, feedbackContainer);
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Tab') {
event.preventDefault(); // 阻止页面滚动
if (this.currentCard) {
title.setText(`${this.currentCard.front}`); // 显示答案
}
}
};
const handleKeyup = (event: KeyboardEvent) => {
if (event.key === 'Tab') {
event.preventDefault(); // 阻止页面滚动
title.setText('默写单词'); // 恢复标题
}
};
window.addEventListener('keydown', handleKeydown);
window.addEventListener('keyup', handleKeyup);
this.onClose = () => {
window.removeEventListener('keydown', handleKeydown);
window.removeEventListener('keyup', handleKeyup);
contentEl.empty();
};
}
async pickNextCard(
wordMeaningContainer: HTMLElement,
inputContainer: HTMLElement,
feedbackContainer: HTMLElement
) {
const cards = Array.from(this.plugin.allCards.values());
if (cards.length === 0) {
feedbackContainer.setText('没有更多单词了!');
return;
}
// 随机选择一个单词
this.currentCard = cards[Math.floor(Math.random() * cards.length)];
// 渲染单词的词义
const file = this.plugin.app.vault.getAbstractFileByPath(this.currentCard.path);
if (file instanceof TFile) {
const content = await this.plugin.app.vault.read(file);
const match = content.match(/##### 词义\n(?:- .*\n?)*/g);
wordMeaningContainer.empty(); // 清空之前的内容
if (match) {
await MarkdownRenderer.render(
this.app,
match[0],
wordMeaningContainer,
file.path,
this.plugin
);
} else {
wordMeaningContainer.setText('未找到词义');
}
}
// 清空输入容器并生成字母输入框
inputContainer.empty();
feedbackContainer.setText('');
if (this.currentCard) {
const word = this.currentCard.front;
const inputFields: HTMLInputElement[] = [];
// 为每个字母生成一个输入框
for (let i = 0; i < word.length; i++) {
const inputField = inputContainer.createEl('input', {
type: 'text',
cls: 'letter-input',
});
inputFields.push(inputField);
// 自动跳转到下一个输入框
inputField.addEventListener('input', () => {
if (inputField.value.length === 1 && i < word.length - 1) {
inputFields[i + 1].focus();
}
this.checkSpelling(inputFields, word, feedbackContainer, wordMeaningContainer, inputContainer);
});
// 支持使用退格键返回上一个输入框
inputField.addEventListener('keydown', (event) => {
if (event.key === 'Backspace' && inputField.value === '' && i > 0) {
inputFields[i - 1].focus();
}
});
}
// 聚焦第一个输入框
inputFields[0].focus();
}
}
checkSpelling(
inputFields: HTMLInputElement[],
word: string,
feedbackContainer: HTMLElement,
wordMeaningContainer: HTMLElement,
inputContainer: HTMLElement
) {
const userInput = inputFields.map((field) => field.value).join('');
if (userInput.length === word.length) {
if (userInput.toLowerCase() === word.toLowerCase()) {
feedbackContainer.setText('正确!');
setTimeout(() => {
this.pickNextCard(wordMeaningContainer, inputContainer, feedbackContainer);
}, 500);
} else {
feedbackContainer.setText('错误,请重试!');
inputFields.forEach((field) => (field.value = '')); // 清空所有输入框
inputFields[0].focus(); // 聚焦第一个输入框
}
}
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}