mirror of
https://github.com/insile/OpenWords.git
synced 2026-07-22 05:48:39 +00:00
2025/5/25
This commit is contained in:
parent
e38950e283
commit
357bba6fdb
17 changed files with 1305 additions and 1237 deletions
2
LICENSE
2
LICENSE
|
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 1984
|
||||
Copyright (c) 2025 项阳
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# OpenWords
|
||||
|
||||
**OpenWords** 是一个用于背单词和单词管理的与 [OpenText](https://opentext.net.cn) 配套的 Obsidian 插件, 安装后插件入口是左侧工具栏上的一个按钮再加上两个绑定命令, 主要功能有生成索引, 背单词, 默写单词, 添加双链等, 其中单词文件使用 OpenText 中的单词库, 而背单词的核心逻辑是 SuperMemo 2 间隔重复算法. 插件完全依赖单词文件及其属性, 没有表格, Json 等外部存储, 启动时会读取所有单词缓存, 更改时会即时写入相应的单词文件并更新缓存. 插件没有设计过多的搜索功能, 提倡使用 Obsidian 内置的搜索功能. 如果您认为本插件有很大帮助, 欢迎微信赞赏支持
|
||||
**OpenWords** 是一个用于背单词和单词管理的与 [OpenText](https://opentext.net.cn) 配套的 Obsidian 插件, 安装后插件入口是左侧工具栏上的一个按钮再加上两个绑定命令, 主要功能有生成索引, 背单词, 默写单词, 添加双链等, 其中单词文件使用 OpenText 中的单词库, 而背单词的核心逻辑是 SuperMemo 2 间隔重复算法. 插件完全依赖单词文件及其属性, 没有表格, Json 等外部存储, 启动时会读取所有单词缓存, 更改时会即时写入相应的单词文件并更新缓存. 插件没有设计过多的搜索功能, 提倡使用 Obsidian 内置的搜索功能. 如果您认为本插件有很大帮助, 欢迎微信赞赏支持. 目前等待 Obsidian Bases 推出后进行功能更新
|
||||
|
||||

|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const context = await esbuild.context({
|
|||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["main.ts"],
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "openwords",
|
||||
"name": "OpenWords",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"minAppVersion": "1.8.10",
|
||||
"description": "用于英语学习中背单词与单词管理的插件",
|
||||
"author": "insile",
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "openwords",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openwords",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.13",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "openwords",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"description": "用于英语学习中背单词与单词管理的插件",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
9
src/card.ts
Normal file
9
src/card.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import {SuperMemoItem} from 'supermemo';
|
||||
|
||||
// 单词卡片信息
|
||||
export interface CardInfo extends SuperMemoItem {
|
||||
front: string;
|
||||
dueDate: string;
|
||||
path: string;
|
||||
isMastered: boolean;
|
||||
}
|
||||
33
src/inputsuggest.ts
Normal file
33
src/inputsuggest.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { App, TAbstractFile, TFolder, AbstractInputSuggest } from 'obsidian';
|
||||
|
||||
|
||||
export class FolderSuggest extends AbstractInputSuggest<string> {
|
||||
inputEl: HTMLInputElement;
|
||||
constructor(app: App, inputEl: HTMLInputElement) {
|
||||
super(app, inputEl);
|
||||
this.inputEl = inputEl;
|
||||
}
|
||||
|
||||
getSuggestions(query: string): string[] {
|
||||
const filesAndFolders: TAbstractFile[] = this.app.vault.getAllLoadedFiles();
|
||||
const folderSuggestions: string[] = [];
|
||||
|
||||
filesAndFolders.forEach(file => {
|
||||
if (file instanceof TFolder && file.path.toLowerCase().includes(query.toLowerCase())) {
|
||||
folderSuggestions.push(file.path);
|
||||
}
|
||||
});
|
||||
|
||||
return folderSuggestions.sort();
|
||||
}
|
||||
|
||||
renderSuggestion(value: string, el: HTMLElement): void {
|
||||
el.createDiv({ text: value });
|
||||
}
|
||||
|
||||
selectSuggestion(value: string): void {
|
||||
this.inputEl.value = value;
|
||||
this.inputEl.trigger('input');
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
516
src/main.ts
Normal file
516
src/main.ts
Normal file
|
|
@ -0,0 +1,516 @@
|
|||
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';
|
||||
import moment from 'moment';
|
||||
|
||||
|
||||
// 插件设置
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
// 插件主类
|
||||
export default class OpenWords extends Plugin {
|
||||
settings: OpenWordsSettings; // 插件设置
|
||||
settingsSnapshot: OpenWordsSettings; // 插件设置快照
|
||||
statusBarItem: HTMLElement; // 状态栏元素
|
||||
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(); // 存储旧单词的元数据
|
||||
tagger = new posTagger(); // 词性标注器实例
|
||||
|
||||
// 插件加载时执行的操作
|
||||
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.enabledCards.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.enabledCards.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 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 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 = allCards.get(card.front)?.isMastered;
|
||||
allCards.set(card.front, card);
|
||||
|
||||
// 再添加新的单词元数据
|
||||
if (isEnabled && !isMastered) {
|
||||
enabledCards.set(card.front, card);
|
||||
if (frontMatter["重复次数"] === 0) {
|
||||
newCards.set(card.front, card);
|
||||
} else {
|
||||
dueCards.set(card.front, card);
|
||||
}
|
||||
}
|
||||
|
||||
if (isMastered) {
|
||||
masterCards.set(card.front, card);
|
||||
}
|
||||
|
||||
// 如果掌握状态发生变化,更新复选框
|
||||
if (isMastered !== isMasteredOld && isMasteredOld !== undefined) {
|
||||
const newCheckbox = isMastered ? 'x' : ' ';
|
||||
const oldCheckbox = isMasteredOld ? 'x' : ' ';
|
||||
await this.syncMetadataToCheckbox(file, newCheckbox, oldCheckbox);
|
||||
}
|
||||
|
||||
this.statusBarItem.setText(`${this.newCards.size} + ${this.dueCards.size} + ${this.allCards.size-this.enabledCards.size} = ${this.allCards.size}`);
|
||||
}
|
||||
|
||||
// 扫描所有单词文件
|
||||
async scanAllNotes() {
|
||||
this.allCards.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.statusBarItem.setText(`${this.newCards.size} + ${this.dueCards.size} + ${this.allCards.size-this.enabledCards.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.enabledCards.size}/${filteredFiles.length}`); // 更新 Notice 的消息
|
||||
}));
|
||||
notice.setMessage(`扫描完成!共 ${this.newCards.size} + ${this.dueCards.size} + ${this.allCards.size-this.enabledCards.size} = ${this.allCards.size} 个单词`); // 完成后更新消息
|
||||
setTimeout(() => notice.hide(), 2000); // 2 秒后自动隐藏 Notice
|
||||
}
|
||||
|
||||
// 更新单词属性
|
||||
async updateCard(card: CardInfo, grade: SuperMemoGrade) {
|
||||
const result = supermemo(card, grade);
|
||||
const newDate = moment().add(result.interval, 'day').format('YYYY-MM-DD');
|
||||
const file = this.app.vault.getFileByPath(card.path);
|
||||
if (!file) {
|
||||
new Notice(`文件 ${card.path} 不存在!`);
|
||||
return;
|
||||
}
|
||||
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.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["到期日"] = 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 <= 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]; // 获取双链中的别名
|
||||
// 如果双链中的名称不在单词表中,去除双链,保留别名或名称
|
||||
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');
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onunload() {
|
||||
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
}
|
||||
172
src/modals/LearningModal.ts
Normal file
172
src/modals/LearningModal.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { App, Modal, Notice, TFile, Setting, MarkdownRenderer, Component } from 'obsidian';
|
||||
import { SuperMemoGrade } from 'supermemo';
|
||||
import { CardInfo } from '../card';
|
||||
import OpenWords from '../main';
|
||||
import moment from 'moment';
|
||||
|
||||
|
||||
// 背单词模态框
|
||||
export class LearningModal extends Modal {
|
||||
plugin: OpenWords;
|
||||
component: Component;
|
||||
mode: 'new' | 'review';
|
||||
currentCard: CardInfo;
|
||||
|
||||
constructor(app: App, plugin: OpenWords, mode: 'new' | 'review') {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.mode = mode;
|
||||
this.component = new Component();
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
this.component.load()
|
||||
this.pickNextCard();
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
|
||||
if (!this.currentCard) return;
|
||||
|
||||
// 创建卡片容器并固定样式
|
||||
const cardContainer = contentEl.createDiv({ cls: 'openwords-card' });
|
||||
const settingsContainer = contentEl.createDiv({ cls: 'openwords-card-settings' });
|
||||
|
||||
new Setting(settingsContainer)
|
||||
.setName(`评分 0: 回答错误, 完全不会`)
|
||||
.addButton(btn => btn
|
||||
.setButtonText('0')
|
||||
.onClick(() => this.rateCard(0 as SuperMemoGrade, cardContainer)))
|
||||
new Setting(settingsContainer)
|
||||
.setName(`评分 1: 回答错误, 看到正确答案后感觉很熟悉`)
|
||||
.addButton(btn => btn
|
||||
.setButtonText('1')
|
||||
.onClick(() => this.rateCard(1 as SuperMemoGrade, cardContainer)))
|
||||
new Setting(settingsContainer)
|
||||
.setName(`评分 2: 回答错误, 看到正确答案后感觉很容易记住`)
|
||||
.addButton(btn => btn
|
||||
.setButtonText('2')
|
||||
.onClick(() => this.rateCard(2 as SuperMemoGrade, cardContainer)))
|
||||
new Setting(settingsContainer)
|
||||
.setName(`评分 3: 回答正确, 需要花费很大力气才能回忆起来`)
|
||||
.addButton(btn => btn
|
||||
.setButtonText('3')
|
||||
.onClick(() => this.rateCard(3 as SuperMemoGrade, cardContainer)))
|
||||
new Setting(settingsContainer)
|
||||
.setName(`评分 4: 回答正确, 需要经过一番犹豫才做出反应`)
|
||||
.addButton(btn => btn
|
||||
.setButtonText('4')
|
||||
.onClick(() => this.rateCard(4 as SuperMemoGrade, cardContainer)))
|
||||
new Setting(settingsContainer)
|
||||
.setName(`评分 5: 回答正确, 完美响应`)
|
||||
.addButton(btn => btn
|
||||
.setButtonText('5')
|
||||
.onClick(() => this.rateCard(5 as SuperMemoGrade, cardContainer)))
|
||||
this.render(cardContainer);
|
||||
|
||||
// 监听数字键 0-5 的按键事件
|
||||
const handleKeydown = async (event: KeyboardEvent) => {
|
||||
if (event.key >= '0' && event.key <= '5') {
|
||||
const grade = parseInt(event.key) as SuperMemoGrade;
|
||||
await this.rateCard(grade, cardContainer);
|
||||
}
|
||||
};
|
||||
this.plugin.registerDomEvent(contentEl, 'keydown', handleKeydown);
|
||||
|
||||
}
|
||||
|
||||
pickNextCard() {
|
||||
const now = moment(); // 获取当前时间
|
||||
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 => moment(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(cardContainer: HTMLDivElement) {
|
||||
cardContainer.empty()
|
||||
|
||||
// 标记当前是否显示 Markdown 内容
|
||||
let isShowingMarkdown = false;
|
||||
// 初始显示单词
|
||||
const wordContent = cardContainer.createDiv({ cls: 'openwords-card-content' });
|
||||
wordContent.textContent = this.currentCard.front;
|
||||
|
||||
// 添加鼠标悬浮事件切换内容
|
||||
cardContainer.addEventListener('mouseenter', async () => {
|
||||
if (isShowingMarkdown) return; // 如果已经显示 Markdown 内容,则不重复加载
|
||||
|
||||
const file = this.plugin.app.vault.getFileByPath(this.currentCard.path);
|
||||
if (file instanceof TFile) {
|
||||
const markdownContent = await this.plugin.app.vault.cachedRead(file);
|
||||
|
||||
// 清空卡片内容并渲染 Markdown 内容
|
||||
cardContainer.empty();
|
||||
const markdownRenderContainer = cardContainer.createDiv({ cls: 'openwords-card-markdown' });
|
||||
await MarkdownRenderer.render(
|
||||
this.app,
|
||||
markdownContent,
|
||||
markdownRenderContainer,
|
||||
file.path,
|
||||
this.component
|
||||
);
|
||||
|
||||
isShowingMarkdown = true;
|
||||
} else {
|
||||
new Notice('无法加载单词的 Markdown 文件!');
|
||||
}
|
||||
});
|
||||
|
||||
cardContainer.addEventListener('mouseleave', () => {
|
||||
if (!isShowingMarkdown) return; // 如果已经显示单词内容,则不重复加载
|
||||
|
||||
// 切换回显示单词
|
||||
cardContainer.empty();
|
||||
const wordContent = cardContainer.createDiv({ cls: 'openwords-card-content' });
|
||||
wordContent.textContent = this.currentCard.front;
|
||||
isShowingMarkdown = false;
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
async rateCard(grade: SuperMemoGrade, cardContainer: HTMLDivElement) {
|
||||
await this.plugin.updateCard(this.currentCard, grade);
|
||||
this.pickNextCard();
|
||||
this.render(cardContainer);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
this.component.unload();
|
||||
}
|
||||
|
||||
}
|
||||
78
src/modals/LearningTypeModal.ts
Normal file
78
src/modals/LearningTypeModal.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { App, Modal, Notice, Setting } from 'obsidian';
|
||||
import { LearningModal } from './LearningModal';
|
||||
import { WordListModal } from './WordListModal';
|
||||
import { SpellingModal } from './SpellingModal';
|
||||
import OpenWords from '../main';
|
||||
|
||||
|
||||
// 学习模式模态框
|
||||
export class LearningTypeModal extends Modal {
|
||||
plugin: OpenWords;
|
||||
|
||||
constructor(app: App, plugin: OpenWords) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
this.modalEl.addClass('openwords-type-modal'); // 添加自定义样式类
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
|
||||
contentEl.createDiv({ cls: 'openwords-type-title', text: '选择学习模式' });
|
||||
const buttonContainer = contentEl.createDiv({ cls: 'openwords-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(`掌握列表 ( 所有单词, 掌握 ${this.plugin.masterCards.size} )`)
|
||||
.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(async () => {
|
||||
this.close();
|
||||
await this.plugin.addDoubleBrackets();
|
||||
}));
|
||||
}
|
||||
}
|
||||
148
src/modals/SpellingModal.ts
Normal file
148
src/modals/SpellingModal.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { App, Modal, MarkdownRenderer, TFile, Component } from 'obsidian';
|
||||
import { CardInfo } from '../card';
|
||||
import OpenWords from '../main';
|
||||
|
||||
|
||||
// 默写单词模态框
|
||||
export class SpellingModal extends Modal {
|
||||
plugin: OpenWords;
|
||||
component: Component;
|
||||
currentCard: CardInfo | null = null;
|
||||
|
||||
constructor(app: App, plugin: OpenWords) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.component = new Component();
|
||||
}
|
||||
|
||||
async onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
this.component.load()
|
||||
this.modalEl.addClass('openwords-spelling'); // 添加样式类
|
||||
|
||||
const title = contentEl.createDiv({ cls: 'openwords-spelling-title', text: '默写单词' });
|
||||
const wordMeaningContainer = contentEl.createDiv({ cls: 'openwords-spelling-meaning' });
|
||||
const inputContainer = contentEl.createDiv({ cls: 'openwords-spelling-input' });
|
||||
const feedbackContainer = contentEl.createDiv({ cls: 'openwords-spelling-feedback' });
|
||||
|
||||
// 初始化第一个单词
|
||||
await 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('默写单词'); // 恢复标题
|
||||
}
|
||||
};
|
||||
this.plugin.registerDomEvent(contentEl, 'keydown', handleKeydown);
|
||||
this.plugin.registerDomEvent(contentEl, 'keyup', handleKeyup);
|
||||
}
|
||||
|
||||
async pickNextCard(
|
||||
wordMeaningContainer: HTMLElement,
|
||||
inputContainer: HTMLElement,
|
||||
feedbackContainer: HTMLElement
|
||||
) {
|
||||
const cards = Array.from(this.plugin.enabledCards.values());
|
||||
if (cards.length === 0) {
|
||||
feedbackContainer.setText('没有更多单词了!');
|
||||
return;
|
||||
}
|
||||
|
||||
// 随机选择一个单词
|
||||
this.currentCard = cards[Math.floor(Math.random() * cards.length)];
|
||||
|
||||
// 渲染单词的词义
|
||||
const file = this.plugin.app.vault.getFileByPath(this.currentCard.path);
|
||||
if (file instanceof TFile) {
|
||||
const content = await this.plugin.app.vault.cachedRead(file);
|
||||
const match = content.match(/##### 词义\n(?:- .*\n?)*/g);
|
||||
wordMeaningContainer.empty(); // 清空之前的内容
|
||||
if (match) {
|
||||
await MarkdownRenderer.render(
|
||||
this.app,
|
||||
match[0],
|
||||
wordMeaningContainer,
|
||||
file.path,
|
||||
this.component
|
||||
);
|
||||
} 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: 'openwords-spelling-letter',
|
||||
});
|
||||
|
||||
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(async () => {
|
||||
await this.pickNextCard(wordMeaningContainer, inputContainer, feedbackContainer);
|
||||
}, 500);
|
||||
} else {
|
||||
feedbackContainer.setText('错误,请重试!');
|
||||
inputFields.forEach((field) => (field.value = '')); // 清空所有输入框
|
||||
inputFields[0].focus(); // 聚焦第一个输入框
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
this.component.unload();
|
||||
}
|
||||
}
|
||||
144
src/modals/WordListModal.ts
Normal file
144
src/modals/WordListModal.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { App, Modal, Notice, Setting, MarkdownRenderer, TFile, Component } from 'obsidian';
|
||||
import OpenWords from '../main';
|
||||
|
||||
// 单词列表模态框
|
||||
export class WordListModal extends Modal {
|
||||
plugin: OpenWords;
|
||||
component: Component;
|
||||
selectedLetter: string | null = null; // 当前选中的首字母
|
||||
selectedLevel: string | null = null; // 当前选中的级别
|
||||
|
||||
constructor(app: App, plugin: OpenWords) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.component = new Component();
|
||||
}
|
||||
|
||||
async onOpen() {
|
||||
this.modalEl.addClass('openwords-list');
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.createDiv({ cls: 'openwords-list-title', text: '掌握列表' });
|
||||
const filterContainer = contentEl.createDiv({ cls: 'openwords-list-filters' });
|
||||
const listContainer = contentEl.createDiv({ cls: 'openwords-list-container' });
|
||||
this.component.load()
|
||||
// 添加首字母筛选
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// 添加级别筛选
|
||||
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.allCards.values()).filter(card => {
|
||||
|
||||
const file = this.plugin.app.vault.getFileByPath(card.path);
|
||||
if (file instanceof TFile) {
|
||||
const matchesLetter = this.selectedLetter
|
||||
? card.front.startsWith(this.selectedLetter.toLowerCase())
|
||||
: true;
|
||||
const matchesLevel = this.selectedLevel
|
||||
? this.plugin.app.metadataCache.getFileCache(file)?.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: 'openwords-list-item' });
|
||||
// 左侧显示单词
|
||||
const wordText = wordItem.createDiv({ cls: 'openwords-list-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) => {
|
||||
const file = this.plugin.app.vault.getFileByPath(card.path);
|
||||
if (!(file instanceof TFile)) return; // 如果文件不存在,直接返回
|
||||
// 更新单词的掌握状态
|
||||
await this.plugin.app.fileManager.processFrontMatter(
|
||||
file, (frontMatter) => {frontMatter["掌握"] = value;}
|
||||
);
|
||||
new Notice(`单词 "${card.front}" 已更新为 ${value ? '掌握' : '未掌握'}`);
|
||||
});
|
||||
});
|
||||
// 创建 Markdown 预览容器
|
||||
const previewContainer = wordText.createDiv({ cls: 'openwords-list-preview' });
|
||||
|
||||
// 添加鼠标悬停事件以渲染 Markdown 内容
|
||||
wordText.addEventListener('mouseenter', async () => {
|
||||
const file = this.plugin.app.vault.getFileByPath(card.path);
|
||||
if (file instanceof TFile) {
|
||||
const markdownContent = await this.plugin.app.vault.cachedRead(file);
|
||||
previewContainer.empty();
|
||||
await MarkdownRenderer.render(
|
||||
this.app,
|
||||
markdownContent,
|
||||
previewContainer,
|
||||
file.path,
|
||||
this.component
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 鼠标离开时清空预览内容
|
||||
wordText.addEventListener('mouseleave', () => {
|
||||
previewContainer.empty();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
this.component.unload();
|
||||
}
|
||||
}
|
||||
172
src/settings.ts
Normal file
172
src/settings.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { App, Notice, PluginSettingTab, Setting, normalizePath } from 'obsidian';
|
||||
import { FolderSuggest } from './inputsuggest';
|
||||
import OpenWords from './main';
|
||||
|
||||
// 插件设置选项卡
|
||||
export 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 => {
|
||||
const input = text.setValue(this.plugin.settings.folderPath);
|
||||
new FolderSuggest(this.app, input.inputEl); // 添加文件夹路径自动补全支持
|
||||
input.onChange(async (value) => {
|
||||
this.plugin.settingsSnapshot.folderPath = normalizePath(value);
|
||||
});
|
||||
});
|
||||
new Setting(textContainer)
|
||||
.setName('索引文件夹路径')
|
||||
.setDesc('指定存放索引文件的文件夹路径,默认为仓库:索引')
|
||||
.addText(text => {
|
||||
const input = text.setValue(this.plugin.settings.indexPath);
|
||||
new FolderSuggest(this.app, input.inputEl); // 添加文件夹路径自动补全支持
|
||||
input.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('重置'); // 恢复按钮文本
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
58
styles.css
58
styles.css
|
|
@ -21,18 +21,18 @@ If your plugin does not need CSS, delete this file.
|
|||
}
|
||||
|
||||
/* 学习模式模态框 */
|
||||
.type-title {
|
||||
.openwords-type-title {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
.type-modal{
|
||||
.openwords-type-modal{
|
||||
width: 650px; /* 设置模态框宽度 */
|
||||
}
|
||||
|
||||
/* 卡片容器样式 */
|
||||
.anki-card {
|
||||
/* 背单词模态框 */
|
||||
.openwords-card { /* 卡片容器样式 */
|
||||
width: 500px; /* 固定宽度 */
|
||||
height: 300px; /* 固定高度 */
|
||||
display: flex;
|
||||
|
|
@ -45,19 +45,17 @@ If your plugin does not need CSS, delete this file.
|
|||
text-align: center;
|
||||
cursor: pointer; /* 鼠标悬停时显示手型 */
|
||||
}
|
||||
.anki-card:hover {
|
||||
.openwords-card:hover {
|
||||
background-color: var(--background-modifier-hover); /* 鼠标悬停时背景变浅 */
|
||||
}
|
||||
|
||||
/* 单词内容样式 */
|
||||
.anki-card-content {
|
||||
.openwords-card-content { /* 单词内容样式 */
|
||||
font-size: 2.5em; /* 设置单词字体大小 */
|
||||
font-weight: bold; /* 设置单词加粗 */
|
||||
color: var(--text-normal); /* 设置单词颜色 */
|
||||
}
|
||||
|
||||
/* Markdown 内容样式 */
|
||||
.anki-markdown-content {
|
||||
.openwords-card-markdown { /* Markdown 内容样式 */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto; /* 添加垂直滚动条 */
|
||||
|
|
@ -71,56 +69,52 @@ If your plugin does not need CSS, delete this file.
|
|||
user-select: text; /* 允许文本选择 */
|
||||
}
|
||||
|
||||
/* 设置容器样式 */
|
||||
.anki-settings {
|
||||
margin-top: 20px;
|
||||
.openwords-card-settings {
|
||||
margin-top: 20px; /* 设置容器样式 */
|
||||
font-size: 1em; /* 减小字体大小 */
|
||||
}
|
||||
|
||||
.anki-settings .setting-item {
|
||||
.openwords-card-settings .setting-item {
|
||||
padding: 5px 10px; /* 减小内边距 */
|
||||
}
|
||||
|
||||
|
||||
/* 单词列表模态框 */
|
||||
.word-list-modal{
|
||||
.openwords-list{
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.word-list-title {
|
||||
.openwords-list-title {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 1em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.word-list-filters {
|
||||
.openwords-list-filters {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.word-list-container {
|
||||
.openwords-list-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.word-list-filters .setting-item:first-child {
|
||||
.openwords-list-filters .setting-item:first-child {
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--background-modifier-border)
|
||||
}
|
||||
|
||||
/* 单词项容器样式 */
|
||||
.word-list-item {
|
||||
.openwords-list-item { /* 单词项容器样式 */
|
||||
display: flex; /* 设置为水平布局 */
|
||||
justify-content: space-between; /* 左右对齐 */
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
position: relative; /* 为预览容器提供定位上下文 */
|
||||
}
|
||||
|
||||
/* Markdown 预览容器样式 */
|
||||
.word-preview-inline {
|
||||
.openwords-list-preview { /* Markdown 预览容器样式 */
|
||||
font-size: 1em; /* 设置预览内容字体大小 */
|
||||
line-height: 1.5; /* 设置行高 */
|
||||
overflow-y: auto; /* 添加垂直滚动条 */
|
||||
|
|
@ -139,20 +133,18 @@ If your plugin does not need CSS, delete this file.
|
|||
user-select: text; /* 允许文本选择 */
|
||||
}
|
||||
|
||||
/* 鼠标悬停时显示预览内容 */
|
||||
.word-text:hover .word-preview-inline {
|
||||
.openwords-list-text:hover .openwords-list-preview { /* 鼠标悬停时显示预览内容 */
|
||||
display: block; /* 鼠标悬停时显示预览内容 */
|
||||
}
|
||||
|
||||
/* 单词文本样式 */
|
||||
.word-text {
|
||||
.openwords-list-text { /* 单词文本样式 */
|
||||
font-size: 1.1em;
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 默写单词模态框样式 */
|
||||
.spelling-modal {
|
||||
/* 默写单词模态框 */
|
||||
.openwords-spelling {
|
||||
width: auto;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
|
|
@ -161,21 +153,21 @@ If your plugin does not need CSS, delete this file.
|
|||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.spelling-title {
|
||||
.openwords-spelling-title {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
.openwords-spelling-input {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.letter-input {
|
||||
.openwords-spelling-letter {
|
||||
width: 50px !important;
|
||||
height: 50px !important;
|
||||
text-align: center;
|
||||
|
|
@ -185,7 +177,7 @@ If your plugin does not need CSS, delete this file.
|
|||
outline: none;
|
||||
}
|
||||
|
||||
.feedback-container {
|
||||
.openwords-spelling-feedback {
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
font-size: 1em;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"1.0.2": "1.8.10"
|
||||
"1.0.3": "1.8.10"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue