insile_OpenWords/src/main.ts
2025-06-21 15:56:06 +08:00

527 lines
23 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 { MarkdownView, Notice, Plugin, TFile, normalizePath } from 'obsidian';
import { supermemo, SuperMemoGrade } from 'supermemo';
import { OpenWordsSettingTab } from './settings';
import { LearningTypeModal } from './modals/LearningTypeModal';
import { CardInfo } from './card';
import posTagger from 'wink-pos-tagger';
// 插件设置
interface OpenWordsSettings {
folderPath: string; // 用户指定的单词文件夹路径
indexPath: string; // 用户指定的索引文件夹路径
enableWords1: boolean;
enableWords2: boolean;
enableWords3: boolean;
enableWords4: boolean;
enableWords5: boolean;
enableWords6: boolean;
enableWords7: boolean;
enableWords8: boolean;
randomRatio: number;
maxEfactorForLink: number;
}
// 默认设置
const DEFAULT_SETTINGS: OpenWordsSettings = {
folderPath: normalizePath(""),
indexPath: normalizePath("索引"),
enableWords1: false,
enableWords2: false,
enableWords3: false,
enableWords4: false,
enableWords5: false,
enableWords6: false,
enableWords7: false,
enableWords8: false,
randomRatio: 0.7,
maxEfactorForLink: 2.6,
}
// 插件主类
export default class OpenWords extends Plugin {
settings: OpenWordsSettings; // 插件设置
settingsSnapshot: OpenWordsSettings; // 插件设置快照
statusBarItem: HTMLElement; // 状态栏元素
tagger: posTagger = new posTagger(); // 词性标注器实例
allCards: Map<string, CardInfo> = new Map(); // 所有单词
masterCards: Map<string, CardInfo> = new Map(); // 掌握单词
enabledCards: Map<string, CardInfo> = new Map(); // 启用单词
newCards: Map<string, CardInfo> = new Map(); // 新单词
dueCards: Map<string, CardInfo> = new Map(); // 旧单词
// 插件加载时执行的操作
async onload() {
// 加载插件设置参数, 状态栏, 设置选项卡, 左侧工具栏按钮
await this.loadSettings();
this.statusBarItem = this.addStatusBarItem();
this.addSettingTab(new OpenWordsSettingTab(this.app, this));
this.addRibbonIcon('slack', 'OpenWords', async () => {
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: '添加双链',
editorCallback: () => {
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.masterCards.delete(file.basename);
this.enabledCards.delete(file.basename);
this.newCards.delete(file.basename);
this.dueCards.delete(file.basename);
this.updateStatusBar()
}
}));
// 监听单词文件缓存修改事件
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 masterCards = this.masterCards;
const enabledCards = this.enabledCards;
const newCards = this.newCards;
const dueCards = this.dueCards;
// 先删除旧的单词元数据
masterCards.delete(file.basename);
enabledCards.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;
const isTagged =
(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 = allCards.get(card.front)?.isMastered;
// 更新所有单词
allCards.set(card.front, card);
// 更新掌握单词
if (isMastered) {
masterCards.set(card.front, card);
}
// 更新启用单词, 新单词, 旧单词
if (isTagged && !isMastered) {
enabledCards.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' : ' ';
await this.syncMetadataToCheckbox(file, newCheckbox, oldCheckbox);
}
this.updateStatusBar()
}
// 扫描所有单词文件
async scanAllNotes() {
this.allCards.clear();
this.masterCards.clear();
this.enabledCards.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.updateStatusBar()
return;
}
await Promise.all(filteredFiles.map(async (file) => {
await this.loadWordMetadata(file);
}));
new Notice(`扫描完成!共 ${this.allCards.size} 个单词`); // 完成后更新消息
}
// 更新单词属性
async updateCard(card: CardInfo, grade: SuperMemoGrade) {
const result = supermemo(card, grade);
const newDate = window.moment().add(result.interval, 'day').format('YYYY-MM-DD');
const file = this.app.vault.getFileByPath(card.path);
if (!file) {
new Notice(`文件 ${card.path} 不存在!`);
return;
}
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}`);
await new Promise(resolve => setTimeout(resolve, 200))
}
// 重置单词属性
async resetCard() {
if (this.enabledCards.size === 0) {
new Notice("没有单词需要重置!");
return;
}
const notice = new Notice('重置中...', 0); // 创建一个持续显示的 Notice
let count = 0; // 计数器
const cardList = Array.from(this.enabledCards.values()); // 将 Map 转换为数组
for (const card of cardList) {
const file = this.app.vault.getFileByPath(card.path);
if (!file) {
new Notice(`文件 ${card.path} 不存在!`);
continue;
}
await this.app.fileManager.processFrontMatter(file, (frontMatter) => {
frontMatter["到期日"] = window.moment().format('YYYY-MM-DD');
frontMatter["间隔"] = 0;
frontMatter["易记因子"] = 250;
frontMatter["重复次数"] = 0;
});
count++; // 增加计数器
notice.setMessage(`重置中... ${count}/${this.enabledCards.size}`); // 更新 Notice 的消息
}
notice.setMessage(`重置完成!共 ${count} 个单词`); // 完成后更新消息
setTimeout(() => notice.hide(), 2000); // 2 秒后自动隐藏 Notice
}
// 建立单词双链
async addDoubleBrackets() {
const unmasteredWords = new Set(Array.from(this.enabledCards.values())
.filter(card => card.efactor <= this.settings.maxEfactorForLink) // 只选择易记因子小于等于设置值的单词
.map(card => card.front)
);
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]; // 获取双链中的别名
// 如果双链中的名称不在单词表中,去除双链,保留别名或名称
if (!unmasteredWords.has(innerWord)) {
return alias; // 如果有别名,保留别名;否则保留名称
}
// 如果双链中的名称在单词表中,保持原样
return word;
} else { // 如果不是双链格式
const doc = this.tagger.tagSentence(word.toLowerCase());
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);
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.allCards.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')+'\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 instanceof TFile) {
await this.app.vault.modify(existingFile, 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 instanceof TFile) {
const newWord = `- [${newCheckbox}] [[${file.basename}]]`;
const oldWord = `- [${oldCheckbox}] [[${file.basename}]]`;
await this.app.vault.process(sourceFile, (data) => data.replace(oldWord, newWord));
}
}
}
}
updateStatusBar(){
this.statusBarItem.setText(`${this.newCards.size} + ${this.dueCards.size} + ${this.allCards.size-this.enabledCards.size} = ${this.allCards.size}`);
}
onunload() {
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}