2026/3/19

This commit is contained in:
项阳 2026-03-19 17:44:10 +08:00
parent 39de9cfc20
commit 664c98c454
13 changed files with 277 additions and 198 deletions

View file

@ -86,6 +86,10 @@
![p10](README/10.gif)
为当前活动页面选中的单词添加双链, 命令调用或者右键菜单
![p13](README/13.gif)
### 恢复默认设置
重置所有设置选项为默认值, 请谨慎使用

BIN
README/13.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View file

@ -1,7 +1,7 @@
{
"id": "openwords",
"name": "OpenWords",
"version": "1.0.10",
"version": "1.0.11",
"minAppVersion": "1.10.0",
"description": "用于英语学习中背单词与单词管理的插件",
"author": "insile",

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "openwords",
"version": "1.0.8",
"version": "1.0.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openwords",
"version": "1.0.8",
"version": "1.0.11",
"license": "MIT",
"dependencies": {
"obsidian": "latest",

View file

@ -1,6 +1,6 @@
{
"name": "openwords",
"version": "1.0.10",
"version": "1.0.11",
"description": "用于英语学习中背单词与单词管理的插件",
"main": "main.js",
"type": "module",

View file

@ -5,13 +5,14 @@ import { MainView, MAIN_VIEW, PageType } from './views/MainView';
import { CardInfo } from './utils/Card';
import { supermemo, SuperMemoGrade } from 'supermemo';
import posTagger from 'wink-pos-tagger';
import { asArray, asNumber, asDateString } from 'utils/Converters';
// 插件主类
export default class OpenWords extends Plugin {
settings: OpenWordsSettings; // 插件设置
settingsSnapshot: OpenWordsSettings; // 插件设置快照
tagger: posTagger = new posTagger(); // 词性标注器实例
tagger: posTagger = new posTagger(); // 词性标注器实例
allCards: Map<string, CardInfo> = new Map(); // 所有单词
masterCards: Map<string, CardInfo> = new Map(); // 掌握单词
enabledCards: Map<string, CardInfo> = new Map(); // 启用单词
@ -26,6 +27,7 @@ export default class OpenWords extends Plugin {
// 加载插件设置参数, 设置选项卡, 左侧工具栏按钮, 注册主视图
await this.loadSettings();
this.addSettingTab(new OpenWordsSettingTab(this.app, this));
// eslint-disable-next-line obsidianmd/ui/sentence-case
this.addRibbonIcon('slack', 'OpenWords', async () => { await this.activateView(); });
this.registerView(MAIN_VIEW, (leaf) => new MainView(leaf, this));
@ -35,18 +37,43 @@ export default class OpenWords extends Plugin {
this.addCommand({
id: 'LearningTypeModal',
name: '学习视图',
callback: () => {
this.activateView();
callback: async () => {
await this.activateView();
}
});
// 添加双链命令
this.addCommand({
id: 'addDoubleBrackets',
name: '添加双链',
editorCallback: () => {
this.addDoubleBrackets();
name: '文档双链',
editorCallback: async () => {
await this.addDoubleBrackets();
}
});
// 添加单词双链命令
this.addCommand({
id: 'addDoubleBracketsWord',
name: '单词双链',
editorCallback: async () => {
await this.autoDoubleLinkWord();
}
});
// 注册编辑器菜单
this.registerEvent(
this.app.workspace.on("editor-menu", (menu, editor, view) => {
const selection = editor.getSelection().trim();
if (!selection) return;
menu.addItem((item) => {
item
.setTitle(`链接 "${selection}" 到单词库`)
.setIcon("link")
.onClick(async () => {
// 执行上面提到的搜索和替换逻辑
await this.autoDoubleLinkWord();
});
});
})
);
// 注册文件监听器
this.registerFileWatchers();
// 扫描所有单词文件, 更新状态栏
@ -75,13 +102,13 @@ export default class OpenWords extends Plugin {
// 加载单词元数据
async loadWordMetadata(file: TFile, single = true) {
const allCards = this.allCards;
const masterCards = this.masterCards;
const masterCards = this.masterCards;
const enabledCards = this.enabledCards;
const newCards = this.newCards;
const dueCards = this.dueCards;
// 先删除旧的单词元数据
masterCards.delete(file.basename);
masterCards.delete(file.basename);
enabledCards.delete(file.basename);
newCards.delete(file.basename);
dueCards.delete(file.basename);
@ -95,45 +122,31 @@ export default class OpenWords extends Plugin {
allCards.delete(file.basename);
if (single) { this.updateStatusBar(); }
return;
}
}
const tags: string[] = frontMatter.tags || [];
// 解析 FrontMatter 中的单词属性
const tags: string[] = asArray(frontMatter.tags);
const isMastered = frontMatter["掌握"] === true;
const isTagged = tags.some(tag => this.settings.enabledTags.includes(tag));
const card: CardInfo = {
front: file.basename,
path: file.path,
dueDate: frontMatter["到期日"],
interval: frontMatter["间隔"],
efactor: frontMatter["易记因子"] * 0.01,
repetition: frontMatter["重复次数"],
dueDate: asDateString(frontMatter["到期日"]),
interval: asNumber(frontMatter["间隔"], 0),
efactor: asNumber(frontMatter["易记因子"], 250) * 0.01,
repetition: asNumber(frontMatter["重复次数"], 0),
isMastered: isMastered,
};
if (
!card.front ||
!card.path ||
card.dueDate === undefined ||
card.interval === undefined ||
card.efactor === undefined ||
card.repetition === undefined ||
Number.isNaN(card.efactor) ||
Number.isNaN(card.interval) ||
Number.isNaN(card.repetition)
) {
allCards.delete(file.basename);
if (single) { this.updateStatusBar(); }
return;
// 更新所有单词
allCards.set(card.front, card);
// 更新掌握单词
if (isMastered) {
masterCards.set(card.front, card);
}
// 更新所有单词
allCards.set(card.front, card);
// 更新掌握单词
if (isMastered) {
masterCards.set(card.front, card);
}
// 更新启用单词, 新单词, 旧单词
if (isTagged && !isMastered) {
enabledCards.set(card.front, card);
@ -151,19 +164,19 @@ export default class OpenWords extends Plugin {
// 扫描所有单词文件
async scanAllNotes() {
this.allCards.clear();
this.masterCards.clear();
this.masterCards.clear();
this.enabledCards.clear();
this.newCards.clear();
this.dueCards.clear();
const normalized = this.settings.folderPath.endsWith("/")
? this.settings.folderPath
: this.settings.folderPath + "/";
const normalized = this.settings.folderPath.endsWith("/")
? this.settings.folderPath
: this.settings.folderPath + "/";
const files = this.app.vault.getMarkdownFiles();
const filteredFiles = files.filter(file => file.path.startsWith(normalized));
if (filteredFiles.length === 0) {
new Notice('指定的文件夹下没有 Markdown 文件!');
this.updateStatusBar()
this.updateStatusBar()
return;
}
@ -186,28 +199,38 @@ export default class OpenWords extends Plugin {
const newDate = window.moment().add(result.interval, 'day').format('YYYY-MM-DD');
const file = this.app.vault.getFileByPath(card.path);
if (!file) {
if (!file) {
new Notice(`文件 ${card.path} 不存在!`);
return;
}
await this.app.fileManager.processFrontMatter(file, (frontMatter) => {
await this.app.fileManager.processFrontMatter(file, (frontMatter: Record<string, unknown>) => {
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}`);
new Notice(`${card.front} \n易记因子: ${result.efactor.toFixed(2)} \n重复次数: ${result.repetition} \n间隔: ${result.interval} \n到期日: ${newDate}`);
// 等待元数据缓存更新(最多等待 1 秒)
let attempts = 0;
while (attempts < 5) {
const updated = this.app.metadataCache.getFileCache(file);
if (updated?.frontmatter?.["到期日"] === newDate) {
break; // 缓存已更新
while (attempts < 20) {
if (mode === 'new') {
if (grade >= 3 && this.dueCards.has(card.front)) {
break; // 以移出新单词池
} else if (grade < 3) {
break;
}
}
await new Promise(resolve => setTimeout(resolve, 200));
else {
if (grade < 3 && this.newCards.has(card.front)) {
break; // 以移出旧单词池
} else if (grade >= 3 && this.dueCards.get(card.front)?.efactor === result.efactor) {
break;
}
}
await new Promise(resolve => setTimeout(resolve, 50));
attempts++;
}
}
@ -218,10 +241,10 @@ export default class OpenWords extends Plugin {
new Notice("没有单词需要重置!");
return;
}
// // 注销监听器,避免重置过程中触发大量缓存更新事件
// this.unregisterFileWatchers();
const notice = new Notice('重置中...', 0); // 创建一个持续显示的 Notice
let count = 0; // 计数器
const cardList = Array.from(this.enabledCards.values()); // 将 Map 转换为数组
@ -229,7 +252,7 @@ export default class OpenWords extends Plugin {
const concurrency = 10; // 并发处理数量
const todayDate = window.moment().format('YYYY-MM-DD'); // 提前计算日期,避免重复计算
let updateFrequency = Math.max(1, Math.floor(total / 50)); // 减少更新频率到 2%
// 创建并发处理任务
const processTasks = async () => {
for (let i = 0; i < cardList.length; i += concurrency) {
@ -237,7 +260,7 @@ export default class OpenWords extends Plugin {
await Promise.all(chunk.map(async (card) => {
const file = this.app.vault.getFileByPath(card.path);
if (file) {
await this.app.fileManager.processFrontMatter(file, (frontMatter) => {
await this.app.fileManager.processFrontMatter(file, (frontMatter: Record<string, unknown>) => {
frontMatter["到期日"] = todayDate;
frontMatter["间隔"] = 0;
frontMatter["易记因子"] = 250;
@ -257,7 +280,8 @@ export default class OpenWords extends Plugin {
await processTasks();
notice.setMessage(`重置完成!共 ${count} 个单词`);
} catch (error) {
new Notice(`重置出错: ${error}`);
const message = error instanceof Error ? error.message : String(error);
new Notice(`重置出错: ${message}`);
console.error('Reset card error:', error);
} finally {
// // 重新注册监听器
@ -268,7 +292,7 @@ export default class OpenWords extends Plugin {
setTimeout(() => notice.hide(), 2000); // 2 秒后自动隐藏 Notice
}
}
// 建立单词双链
async addDoubleBrackets() {
const unmasteredWords = new Set(Array.from(this.enabledCards.values())
@ -307,7 +331,7 @@ export default class OpenWords extends Plugin {
return word; // 否则返回原始单词
}
});
const updatedContent = updatedWords.join('');
const updatedContent = updatedWords.join('');
doc.setValue(updatedContent); // 用更新后的内容替换原文
new Notice('已添加双链!');
@ -317,7 +341,7 @@ export default class OpenWords extends Plugin {
// 生成单词索引 .base 文件
async generateIndex() {
const notice = new Notice('索引中...', 0);
// 从设置读取标签并转换为配置对象(将多级标签的 '/' 替换为 '.'
const levelConfigs = this.settings.enabledTags.map(tag => {
const name = tag.replace(/\//g, '.');
@ -352,10 +376,11 @@ export default class OpenWords extends Plugin {
const frontMatter = this.app.metadataCache.getFileCache(file)?.frontmatter;
if (!frontMatter) return;
const tags: string[] = frontMatter.tags || [];
const tags: string[] = asArray(frontMatter.tags);
for (const level of levelConfigs) {
if (tags.includes(level.tag)) {
levelWordCounts[level.tag] = (levelWordCounts[level.tag] ?? 0) + 1; }
levelWordCounts[level.tag] = (levelWordCounts[level.tag] ?? 0) + 1;
}
}
});
@ -409,8 +434,8 @@ views:
-
-
${Array.from({ length: 26 }, (_, i) => {
const letter = String.fromCharCode(65 + i);
return ` - type: table
const letter = String.fromCharCode(65 + i);
return ` - type: table
name: 总计.${letter}
filters:
and:
@ -422,7 +447,7 @@ ${Array.from({ length: 26 }, (_, i) => {
-
-
- `;
}).join('\n')}
}).join('\n')}
`;
await this.writeFile(allBasePath, allBaseContent);
@ -446,8 +471,8 @@ views:
-
-
${Array.from({ length: 26 }, (_, i) => {
const letter = String.fromCharCode(65 + i);
return ` - type: table
const letter = String.fromCharCode(65 + i);
return ` - type: table
name: ${level.name.split('.').pop()}.${letter}
filters:
and:
@ -459,7 +484,7 @@ ${Array.from({ length: 26 }, (_, i) => {
-
-
- `;
}).join('\n')}
}).join('\n')}
`;
await this.writeFile(basePath, baseContent);
@ -598,12 +623,36 @@ ${tagFilters}
await this.writeFile(basePath, baseContent);
}
// 自动为选中单词添加双链
async autoDoubleLinkWord() {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!activeView) {
new Notice("请在 Markdown 编辑器中使用此命令");
return;
}
const editor = activeView.editor;
const selection = editor.getSelection().trim();
if (!selection) {
new Notice("请先选中一个单词");
return;
}
const word = this.tagger.tagSentence(selection.toLowerCase())[0];
const lemma = word?.lemma ?? selection.toLowerCase();
if (this.allCards.has(lemma)) {
editor.replaceSelection(`[[${lemma}|${selection}]]`);
new Notice(`已成功链接到: ${selection}`);
} else {
// 4. 如果文件不存在,可以提示或提供创建选项
new Notice(`单词库中未找到 "${selection}"`);
}
}
// 更新状态栏
updateStatusBar(){
updateStatusBar() {
this.app.workspace.getLeavesOfType(MAIN_VIEW).forEach(leaf => {
const view = leaf.view;
if (view instanceof MainView) {
void view.updateStatusBar();
view.updateStatusBar();
}
});
}
@ -619,9 +668,9 @@ ${tagFilters}
: this.settings.folderPath + "/";
// 监听单词文件创建事件
const createRef = this.app.vault.on("create", (file: TFile) => {
const createRef = this.app.vault.on("create", async (file: TFile) => {
if (file.path.endsWith(".md") && file.path.startsWith(this.currentFolderPath)) {
this.loadWordMetadata(file);
await this.loadWordMetadata(file);
}
});
this.fileWatcherRefs.push(createRef);
@ -640,9 +689,9 @@ ${tagFilters}
this.fileWatcherRefs.push(deleteRef);
// 监听单词文件缓存修改事件
const changedRef = this.app.metadataCache.on("changed", (file) => {
const changedRef = this.app.metadataCache.on("changed", async (file) => {
if (file.path.endsWith(".md") && file.path.startsWith(this.currentFolderPath)) {
this.loadWordMetadata(file);
await this.loadWordMetadata(file);
}
});
this.fileWatcherRefs.push(changedRef);
@ -662,13 +711,13 @@ ${tagFilters}
this.registerFileWatchers();
}
onunload() {
onunload() {
// 清理文件监听器
this.unregisterFileWatchers();
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial<OpenWordsSettings>);
}
async saveSettings() {

View file

@ -1,4 +1,4 @@
import { App, Notice, PluginSettingTab, Setting, normalizePath } from 'obsidian';
import { App, Notice, PluginSettingTab, Setting, TextComponent, normalizePath } from 'obsidian';
import { FolderSuggest, TagHistorySuggest } from '../utils/InputSuggest';
import { DEFAULT_SETTINGS } from './SettingData';
import OpenWords from '../main';
@ -7,6 +7,7 @@ import OpenWords from '../main';
// 插件设置选项卡
export class OpenWordsSettingTab extends PluginSettingTab {
plugin: OpenWords;
tagTextInput: TextComponent | null = null;
constructor(app: App, plugin: OpenWords) {
super(app, plugin);
@ -14,7 +15,7 @@ export class OpenWordsSettingTab extends PluginSettingTab {
}
display(): void {
const {containerEl} = this;
const { containerEl } = this;
containerEl.empty();
// 初始化设置快照
@ -75,74 +76,74 @@ export class OpenWordsSettingTab extends PluginSettingTab {
}
};
// 添加标签的处理函数
const handleAddTag = (val: string) => {
const value = val.trim();
if (!value) {
new Notice('请输入标签名称!');
return;
}
const { enabledTags, tagHistory } = this.plugin.settingsSnapshot;
if (enabledTags.includes(value)) {
new Notice('该标签已存在!');
return;
}
// 更新数据
enabledTags.push(value);
if (!tagHistory.includes(value)) {
tagHistory.push(value);
}
// 渲染 UI 并清空输入(清空逻辑在回调中处理)
renderTags();
return true; // 表示添加成功
};
// 添加标签输入与历史建议
new Setting(containerEl)
.setName('添加标签')
.setDesc('输入新的标签名称,例如 "级别/小学"')
.addText(text => {
text.setPlaceholder('输入标签名称');
text.onChange(async (value) => {
(text.inputEl as any).currentValue = value;
});
// 使用该引用,不再需要 (text.inputEl as any).currentValue
const inputEl = text.inputEl;
// 初始化建议器
new TagHistorySuggest(
this.app,
text.inputEl,
inputEl,
this.plugin.settingsSnapshot.tagHistory || [],
(selectedValue) => text.setValue(selectedValue),
(selectedValue) => void text.setValue(selectedValue),
async (valueToDelete) => {
const index = this.plugin.settingsSnapshot.tagHistory.indexOf(valueToDelete);
if (index > -1) {
this.plugin.settingsSnapshot.tagHistory.splice(index, 1);
}
const history = this.plugin.settingsSnapshot.tagHistory;
const index = history.indexOf(valueToDelete);
if (index > -1) history.splice(index, 1);
}
);
text.inputEl.addEventListener('keydown', (e) => {
// 监听回车键
inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const value = (text.inputEl as any).value?.trim();
if (value && !this.plugin.settingsSnapshot.enabledTags.includes(value)) {
this.plugin.settingsSnapshot.enabledTags.push(value);
if (!this.plugin.settingsSnapshot.tagHistory.includes(value)) {
this.plugin.settingsSnapshot.tagHistory.push(value);
}
renderTags();
text.setValue('');
} else if (value && this.plugin.settingsSnapshot.enabledTags.includes(value)) {
new Notice('该标签已存在!');
if (handleAddTag(text.getValue())) {
text.setValue(''); // 添加成功后清空
}
}
});
// 存储 text 引用供外部按钮使用(可选,但更优雅)
this.tagTextInput = text;
})
.addButton(button => button
.setButtonText('添加')
.setCta() // 设置为强调色按钮
.onClick(() => {
const inputs = containerEl.querySelectorAll('input[type="text"]');
let tagInput = '';
for (let i = inputs.length - 1; i >= 0; i--) {
const input = inputs[i] as HTMLInputElement;
if (input.placeholder === '输入标签名称') {
tagInput = input.value?.trim() || '';
break;
}
}
if (tagInput && !this.plugin.settingsSnapshot.enabledTags.includes(tagInput)) {
this.plugin.settingsSnapshot.enabledTags.push(tagInput);
if (!this.plugin.settingsSnapshot.tagHistory.includes(tagInput)) {
this.plugin.settingsSnapshot.tagHistory.push(tagInput);
}
renderTags();
for (let i = inputs.length - 1; i >= 0; i--) {
const input = inputs[i] as HTMLInputElement;
if (input.placeholder === '输入标签名称') {
input.value = '';
break;
}
}
} else if (tagInput && this.plugin.settingsSnapshot.enabledTags.includes(tagInput)) {
new Notice('该标签已存在!');
} else {
new Notice('请输入标签名称!');
// 直接利用上文的 text 引用
if (this.tagTextInput && handleAddTag(this.tagTextInput.getValue())) {
this.tagTextInput.setValue('');
}
}));
@ -161,7 +162,7 @@ export class OpenWordsSettingTab extends PluginSettingTab {
button.setDisabled(true);
button.setButtonText('处理中...');
try {
this.plugin.settings = {...this.plugin.settingsSnapshot};
this.plugin.settings = { ...this.plugin.settingsSnapshot };
this.plugin.settings.enabledTags = [...this.plugin.settingsSnapshot.enabledTags];
this.plugin.settings.tagHistory = [...(this.plugin.settingsSnapshot.tagHistory || this.plugin.settingsSnapshot.enabledTags)];
await this.plugin.saveSettings();
@ -178,7 +179,7 @@ export class OpenWordsSettingTab extends PluginSettingTab {
button.setDisabled(false);
button.setButtonText('扫描');
}
}));
}));
new Setting(containerEl)
.setName('生成索引')
@ -198,7 +199,7 @@ export class OpenWordsSettingTab extends PluginSettingTab {
button.setDisabled(false);
button.setButtonText('生成');
}
}));
}));
new Setting(containerEl)
.setName("随机调度概率")
@ -245,7 +246,7 @@ export class OpenWordsSettingTab extends PluginSettingTab {
new Notice('已恢复默认设置');
// 重新渲染设置页以反映默认值
this.display();
this.plugin.settings = {...this.plugin.settingsSnapshot};
this.plugin.settings = { ...this.plugin.settingsSnapshot };
this.plugin.settings.enabledTags = [...this.plugin.settingsSnapshot.enabledTags];
this.plugin.settings.tagHistory = [...(this.plugin.settingsSnapshot.tagHistory || this.plugin.settingsSnapshot.enabledTags)];
await this.plugin.saveSettings();
@ -282,6 +283,6 @@ export class OpenWordsSettingTab extends PluginSettingTab {
button.setDisabled(false);
button.setButtonText('重置单词属性');
}
}));
}));
}
}

13
src/utils/Converters.ts Normal file
View file

@ -0,0 +1,13 @@
export function asArray<T>(value: unknown, defaultValue: T[] = []): T[] {
if (Array.isArray(value)) return value as T[];
if (value !== null && value !== undefined) return [value as T];
return defaultValue;
}
export function asNumber(value: unknown, defaultValue = 0): number {
return typeof value === 'number' ? value : defaultValue;
}
export function asDateString(value: unknown, defaultValue = window.moment().format('YYYY-MM-DD')): string {
return typeof value === 'string' ? value : defaultValue;
}

View file

@ -2,10 +2,11 @@ import { Setting, Notice, MarkdownRenderer, TFile } from 'obsidian';
import { SuperMemoGrade } from 'supermemo';
import { CardInfo } from '../utils/Card';
import { MainView } from './MainView';
import { asArray, asDateString, asNumber } from 'utils/Converters';
export async function LearningPage(this: MainView) {
this.currentLearingCard = this.pickNextLCard();
this.currentLearingCard = await this.pickNextLCard();
if (!this.currentLearingCard) return;
this.viewContainer.empty();
@ -15,9 +16,9 @@ export async function LearningPage(this: MainView) {
const settingsContainer = learningContainer.createDiv({ cls: 'openwords-learning-settings' });
backBtn.textContent = '返回';
backBtn.onclick = () => {
backBtn.onclick = async () => {
this.page = 'type';
this.render();
await this.render();
};
// ESC 快捷键返回
@ -37,7 +38,7 @@ export async function LearningPage(this: MainView) {
}
// 选卡逻辑
export function pickNextLCard(this: MainView) {
export async function pickNextLCard(this: MainView) {
const now = window.moment();
let pool: CardInfo[];
if (this.page === 'new') { // 新词排序:间隔降序,间隔相同则易记因子升序
@ -45,7 +46,7 @@ export function pickNextLCard(this: MainView) {
if (pool.length === 0) {
new Notice("已完成所有新词!");
this.page = 'type';
void this.render();
await this.render();
return null;
}
pool.sort((a, b) => {
@ -62,7 +63,7 @@ export function pickNextLCard(this: MainView) {
if (pool.length === 0) {
new Notice("没有需要复习的卡片!");
this.page = 'type';
void this.render();
await this.render();
return null;
}
pool.sort((a, b) => {
@ -95,14 +96,15 @@ export function renderCard(this: MainView, cardContainer: HTMLDivElement) {
const fileCache = this.plugin.app.metadataCache.getFileCache(file);
const frontMatter = fileCache?.frontmatter;
if (!frontMatter) { return; }
const tags: string[] = (frontMatter.tags || []).map((tag: string) => {
const parts = tag.split('/');
return parts.length > 1 ? parts[1] : tag;
});
const dueDate: string = frontMatter["到期日"];
const interval: string = frontMatter["间隔"];
const efactor: string = frontMatter["易记因子"];
const repetition: string = frontMatter["重复次数"];
const tags: string[] = asArray(frontMatter.tags)
.map((tag: string) => {
const parts = tag.split('/');
return parts.at(-1) ?? tag; // 如果 at(-1) 失败,退回到原标签
});
const dueDate: string = asDateString(frontMatter["到期日"]);
const interval: number = asNumber(frontMatter["间隔"], 0);
const efactor: number = asNumber(frontMatter["易记因子"], 0);
const repetition: number = asNumber(frontMatter["重复次数"], 0);
const metaDiv = cardContainer.createDiv({ cls: 'openwords-card-meta' });
const tagsDiv = metaDiv.createDiv({ cls: 'openwords-card-meta-tags' });
@ -125,8 +127,8 @@ export function renderSettings(this: MainView, cardContainer: HTMLDivElement, se
new Setting(settingsContainer)
.setName(label)
.addButton(btn => {
btn.setButtonText(String(grade+1));
btn.buttonEl.setAttribute('data-grade', String(grade+1));
btn.setButtonText(String(grade + 1));
btn.buttonEl.setAttribute('data-grade', String(grade + 1));
btn.onClick(() => this.rateCard(grade, cardContainer));
});
}
@ -137,7 +139,7 @@ export async function rateCard(this: MainView, grade: SuperMemoGrade, cardContai
if (this.isRating || !this.currentLearingCard) return;
this.isRating = true;
await this.plugin.updateCard(this.currentLearingCard, grade, this.page);
this.currentLearingCard = this.pickNextLCard();
this.currentLearingCard = await this.pickNextLCard();
this.renderCard(cardContainer);
this.isRating = false;
}
@ -146,12 +148,12 @@ export async function rateCard(this: MainView, grade: SuperMemoGrade, cardContai
export function registerRatingKeyEvents(this: MainView, learningContainer: HTMLDivElement, cardContainer: HTMLDivElement) {
const handleKeydown = async (event: KeyboardEvent) => {
if (event.key >= '1' && event.key <= '6') {
if (this.isRating || !this.currentLearingCard) return;
if (this.isRating || !this.currentLearingCard) return;
this.isRating = true;
this.currentRatingKey = event.key;
const btn = learningContainer.querySelector(`button[data-grade="${event.key}"]`);
if (btn) btn.classList.add('active');
const grade = parseInt(event.key)-1 as SuperMemoGrade;
const grade = parseInt(event.key) - 1 as SuperMemoGrade;
await this.plugin.updateCard(this.currentLearingCard, grade, this.page);
}
};
@ -165,7 +167,7 @@ export function registerRatingKeyEvents(this: MainView, learningContainer: HTMLD
if (btn) btn.classList.remove('active');
this.isRating = false;
this.currentRatingKey = null;
this.currentLearingCard = this.pickNextLCard();
this.currentLearingCard = await this.pickNextLCard();
this.renderCard(cardContainer);
}
};
@ -177,7 +179,7 @@ export function registerRatingKeyEvents(this: MainView, learningContainer: HTMLD
export function registerRenderKeyEvents(this: MainView, learningContainer: HTMLDivElement, cardContainer: HTMLDivElement) {
let isShowingMarkdown = false;
const showMarkdown = async () => {
if (isShowingMarkdown || !this.currentLearingCard) return;
if (isShowingMarkdown || !this.currentLearingCard) return;
const file = this.plugin.app.vault.getFileByPath(this.currentLearingCard.path);
if (file instanceof TFile) {
const markdownContent = await this.plugin.app.vault.cachedRead(file);
@ -200,10 +202,10 @@ export function registerRenderKeyEvents(this: MainView, learningContainer: HTMLD
this.renderCard(cardContainer);
isShowingMarkdown = false;
};
// 延迟1秒后注册鼠标事件
setTimeout(() => {
cardContainer.addEventListener('mouseenter', showMarkdown);
cardContainer.addEventListener('mouseenter', () => { void showMarkdown(); });
cardContainer.addEventListener('mouseleave', showWord);
}, 0);

View file

@ -46,6 +46,7 @@ export class MainView extends ItemView {
}
getViewType() { return MAIN_VIEW; }
// eslint-disable-next-line obsidianmd/ui/sentence-case
getDisplayText() { return "OpenWords"; }
getIcon(): string { return "slack"; }
@ -55,7 +56,7 @@ export class MainView extends ItemView {
this.viewContainer = container.createDiv({ cls: 'openwords-view-container' });
this.statusBarEl = container.createDiv({ cls: 'openwords-view-statusbar' });
await this.render();
await this.updateStatusBar();
this.updateStatusBar();
}
async onClose() {
@ -82,9 +83,9 @@ export class MainView extends ItemView {
}
}
async updateStatusBar() {
updateStatusBar() {
if (!this.statusBarEl) return;
// 计算各分类的单词数
const newCount = this.plugin.newCards.size;
const dueTodayCount = Array.from(this.plugin.dueCards.values())
@ -93,43 +94,54 @@ export class MainView extends ItemView {
const masteredCount = this.plugin.masterCards.size;
const disabledCount = this.plugin.allCards.size - this.plugin.enabledCards.size;
const totalCount = this.plugin.allCards.size;
// 清空状态栏
this.statusBarEl.empty();
// 创建第一行:待学习、待复习、今日到期、已掌握、未启用
const row1 = this.statusBarEl.createDiv({ cls: 'openwords-statusbar-row' });
this.createStatItem(row1, '待学习', newCount);
this.createStatItem(row1, '待复习', reviewCount);
this.createStatItem(row1, '今日到期', dueTodayCount);
this.createStatItem(row1, '已掌握', masteredCount);
this.createStatItem(row1, '未启用', disabledCount);
// 创建第二行:总计
const row2 = this.statusBarEl.createDiv({ cls: 'openwords-statusbar-row openwords-statusbar-total' });
this.createStatItem(row2, '总计', totalCount, true);
}
private createStatItem(container: HTMLElement, label: string, count: number, isTotal: boolean = false) {
const item = container.createDiv({ cls: isTotal ? 'openwords-stat-item total' : 'openwords-stat-item' });
item.createSpan({ cls: 'openwords-stat-label', text: label });
item.createSpan({ cls: 'openwords-stat-count', text: count.toString() });
// 添加点击事件,打开对应的视图
if (isTotal) {
item.addEventListener('click', async () => {
// 创建单词状态 .base 文件
await this.plugin.createWordStatusBaseFile();
const basePath = `${this.plugin.settings.indexPath}/英语单词状态.base`;
const file = this.app.vault.getFileByPath(basePath);
if (!file) {
new Notice('单词状态文件不存在,请先生成索引');
return;
}
// 打开 .base 文件
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file);
item.addEventListener('click', () => {
// 使用立即执行的异步函数,并标记为 void
void (async () => {
try {
// 创建单词状态 .base 文件
await this.plugin.createWordStatusBaseFile();
const basePath = `${this.plugin.settings.indexPath}/英语单词状态.base`;
const file = this.app.vault.getFileByPath(basePath);
if (!file) {
new Notice('单词状态文件不存在,请先生成索引');
return;
}
// 打开 .base 文件
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file);
} catch (error) {
new Notice(`打开文件失败: ${String(error)}`);
console.error('Open base file error:', error);
}
})();
});
};
}

View file

@ -4,7 +4,7 @@ import { MainView } from './MainView';
export async function SpellingPage(this: MainView) {
this.currentSpellingCard = this.pickNextSCard();
this.currentSpellingCard = await this.pickNextSCard();
if (!this.currentSpellingCard) return;
this.viewContainer.empty();
@ -16,9 +16,9 @@ export async function SpellingPage(this: MainView) {
const feedbackContainer = spellingContainer.createDiv({ cls: 'openwords-spelling-feedback' });
backBtn.textContent = '返回';
backBtn.onclick = () => {
backBtn.onclick = async () => {
this.page = 'type';
this.render();
await this.render();
};
// ESC 快捷键返回
@ -51,7 +51,7 @@ export async function SpellingPage(this: MainView) {
}
export function pickNextSCard(this: MainView) {
export async function pickNextSCard(this: MainView) {
// 从易记因子最低的50%中随机选择一个单词
this.hasPeeked = false;
this.errorCount = 0;
@ -60,7 +60,7 @@ export function pickNextSCard(this: MainView) {
if (cards.length === 0) {
new Notice("已完成所有单词!");
this.page = 'type';
void this.render();
await this.render();
return null;
}
const sorted = cards.slice().sort((a, b) => a.efactor - b.efactor);
@ -115,7 +115,7 @@ export async function renderInput(
if (inputField.value.length === 1 && i < word.length - 1) {
inputFields[i + 1]?.focus();
}
this.checkSpelling(inputFields, word, feedbackContainer, wordMeaningContainer, inputContainer);
void this.checkSpelling(inputFields, word, feedbackContainer, wordMeaningContainer, inputContainer);
});
inputField.addEventListener('keydown', (event) => {
@ -144,11 +144,10 @@ export async function checkSpelling(
if (!this.plugin.dueCards.has(word)) {
new Notice("当前单词不属于本模式范围, 评分无效并跳过");
setTimeout(async () => {
this.currentSpellingCard = this.pickNextSCard();
if (!this.currentSpellingCard) return;
await this.renderInput(wordMeaningContainer, inputContainer, feedbackContainer);
}, 500);
await new Promise(resolve => setTimeout(resolve, 500));
this.currentSpellingCard = await this.pickNextSCard();
if (!this.currentSpellingCard) return;
await this.renderInput(wordMeaningContainer, inputContainer, feedbackContainer);
return
}
@ -167,18 +166,17 @@ export async function checkSpelling(
// 同步到 frontmatter
const file = this.plugin.app.vault.getFileByPath(this.currentSpellingCard.path);
if (file instanceof TFile) {
await this.plugin.app.fileManager.processFrontMatter(file, (frontMatter) => {
await this.plugin.app.fileManager.processFrontMatter(file, (frontMatter: Record<string, unknown>) => {
frontMatter["易记因子"] = Math.round(efactor * 100);
});
}
new Notice(`${this.currentSpellingCard.front} \n易记因子: ${efactor.toFixed(2)} \n重复次数: ${this.currentSpellingCard.repetition} \n间隔: ${this.currentSpellingCard.interval} \n到期日: ${this.currentSpellingCard.dueDate}`);
}
setTimeout(async () => {
this.currentSpellingCard = this.pickNextSCard();
if (!this.currentSpellingCard) return;
await this.renderInput(wordMeaningContainer, inputContainer, feedbackContainer);
}, 500);
await new Promise(resolve => setTimeout(resolve, 500));
this.currentSpellingCard = await this.pickNextSCard();
if (!this.currentSpellingCard) return;
await this.renderInput(wordMeaningContainer, inputContainer, feedbackContainer);
} else {
feedbackContainer.setText('错误,请重试!');
this.errorCount += 1;

View file

@ -15,10 +15,10 @@ export function TypePage(this: MainView) {
.addButton(btn => {
const button = btn
.setButtonText("开始")
.onClick(() => {
.onClick(async () => {
if (this.plugin.newCards.size > 0) {
this.page = 'new';
void this.render();
await this.render();
} else {
new Notice("没有新词可学了!");
}
@ -32,10 +32,10 @@ export function TypePage(this: MainView) {
.addButton(btn => {
const button = btn
.setButtonText("开始")
.onClick(() => {
.onClick(async () => {
if (this.plugin.dueCards.size > 0) {
this.page = 'old';
void this.render();
await this.render();
} else {
new Notice("没有需要复习的单词!");
}
@ -49,10 +49,10 @@ export function TypePage(this: MainView) {
.addButton(btn => {
const button = btn
.setButtonText('开始')
.onClick(() => {
.onClick(async () => {
if (this.plugin.dueCards.size > 0) {
this.page = 'spelling';
void this.render();
await this.render();
} else {
new Notice("没有需要默写的单词!");
}

View file

@ -1,3 +1,3 @@
{
"1.0.10": "1.10.0"
"1.0.11": "1.10.0"
}