From 357bba6fdba8550666a8d33f746315dfd198b083 Mon Sep 17 00:00:00 2001 From: insile <39241051+insile@users.noreply.github.com> Date: Sun, 25 May 2025 23:50:42 +0800 Subject: [PATCH] 2025/5/25 --- LICENSE | 2 +- README.md | 2 +- esbuild.config.mjs | 2 +- main.ts | 1196 ------------------------------- manifest.json | 2 +- package-lock.json | 4 +- package.json | 2 +- src/card.ts | 9 + src/inputsuggest.ts | 33 + src/main.ts | 516 +++++++++++++ src/modals/LearningModal.ts | 172 +++++ src/modals/LearningTypeModal.ts | 78 ++ src/modals/SpellingModal.ts | 148 ++++ src/modals/WordListModal.ts | 144 ++++ src/settings.ts | 172 +++++ styles.css | 58 +- versions.json | 2 +- 17 files changed, 1305 insertions(+), 1237 deletions(-) delete mode 100644 main.ts create mode 100644 src/card.ts create mode 100644 src/inputsuggest.ts create mode 100644 src/main.ts create mode 100644 src/modals/LearningModal.ts create mode 100644 src/modals/LearningTypeModal.ts create mode 100644 src/modals/SpellingModal.ts create mode 100644 src/modals/WordListModal.ts create mode 100644 src/settings.ts diff --git a/LICENSE b/LICENSE index c6f5dc4..3ede3a8 100644 --- a/LICENSE +++ b/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 diff --git a/README.md b/README.md index e892084..c447a05 100644 --- a/README.md +++ b/README.md @@ -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 推出后进行功能更新 ![p7](README/p7.png) diff --git a/esbuild.config.mjs b/esbuild.config.mjs index a5de8b8..309f17b 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -15,7 +15,7 @@ const context = await esbuild.context({ banner: { js: banner, }, - entryPoints: ["main.ts"], + entryPoints: ["src/main.ts"], bundle: true, external: [ "obsidian", diff --git a/main.ts b/main.ts deleted file mode 100644 index a65ce33..0000000 --- a/main.ts +++ /dev/null @@ -1,1196 +0,0 @@ -import { App, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, TFile, Setting, MarkdownRenderer, normalizePath } from 'obsidian'; -import { supermemo, SuperMemoItem, SuperMemoGrade } from 'supermemo'; -import posTagger from 'wink-pos-tagger'; -import dayjs from "dayjs"; - - -// 插件设置 -interface OpenWordsSettings { - folderPath: string; // 用户指定的单词文件夹路径 - indexPath: string; // 用户指定的索引文件夹路径 - enableWords1: boolean; - enableWords2: boolean; - enableWords3: boolean; - enableWords4: boolean; - enableWords5: boolean; - enableWords6: boolean; - enableWords7: boolean; - enableWords8: boolean; -} - -// 默认设置 -const DEFAULT_SETTINGS: OpenWordsSettings = { - folderPath: normalizePath(""), - indexPath: normalizePath("索引"), - enableWords1: false, - enableWords2: false, - enableWords3: false, - enableWords4: false, - enableWords5: false, - enableWords6: false, - enableWords7: false, - enableWords8: false, -} - -// 单词卡片信息 -interface CardInfo extends SuperMemoItem { - front: string; - dueDate: string; - path: string; - isMastered: boolean; -} - -// 插件主类 -export default class OpenWords extends Plugin { - settings: OpenWordsSettings; // 插件设置 - settingsSnapshot: OpenWordsSettings; // 插件设置快照 - statusBarItem: HTMLElement; // 状态栏元素 - allCards: Map = new Map(); // 存储所有单词的元数据 - enabledCards: Map = new Map(); // 存储作用域单词的元数据 - newCards: Map = new Map(); // 存储新单词的元数据 - dueCards: Map = 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: '添加双链', - callback: () => { - this.addDoubleBrackets(); - } - }); - // 监听单词文件创建事件 - this.registerEvent(this.app.vault.on("create", (file: TFile) => { - if (file.path.endsWith(".md") && file.path.startsWith(this.settings.folderPath)) { - this.loadWordMetadata(file); - } - })); - // 监听单词文件删除事件 - this.registerEvent(this.app.vault.on("delete", (file: TFile) => { - if (file.path.endsWith(".md") && file.path.startsWith(this.settings.folderPath)) { - this.allCards.delete(file.basename); - this.enabledCards.delete(file.basename); - this.newCards.delete(file.basename); - this.dueCards.delete(file.basename); - this.statusBarItem.setText(`${this.newCards.size} + ${this.dueCards.size} = ${this.enabledCards.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 enabledCards = this.enabledCards; - const newCards = this.newCards; - const dueCards = this.dueCards; - - // 先删除旧的单词元数据 - 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 !== 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.enabledCards.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.enabledCards.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.enabledCards.size} 个单词`); // 完成后更新消息 - setTimeout(() => notice.hide(), 2000); // 2 秒后自动隐藏 Notice - } - - // 更新单词属性 - async updateCard(card: CardInfo, grade: SuperMemoGrade) { - const result = supermemo(card, grade); - const newDate = dayjs().add(result.interval, 'day').format('YYYY-MM-DD'); - const file = this.app.vault.getFileByPath(card.path); - 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["到期日"] = dayjs().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 = {}; - 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 = {}; // 记录每个级别的总单词数 - // 遍历级别,生成索引 - 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 = {}; // { 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.process(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 = {}; - 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); - } -} - -// 插件设置选项卡 -class OpenWordsSettingTab extends PluginSettingTab { - plugin: OpenWords; - - constructor(app: App, plugin: OpenWords) { - super(app, plugin); - this.plugin = plugin; - } - - display(): void { - const {containerEl} = this; - containerEl.empty(); - const textContainer = containerEl.createDiv({ cls: 'openwords-text-grid' }); - const toggleContainer = containerEl.createDiv({ cls: 'openwords-toggle-grid' }); - this.plugin.settingsSnapshot = Object.assign({}, this.plugin.settings); // 创建设置快照 - new Setting(textContainer) - .setName('单词文件夹路径') - .setDesc('指定存放单词文件的文件夹路径,默认为仓库:/') - .addText(text => text - .setValue(this.plugin.settings.folderPath) - .onChange(async (value) => { - this.plugin.settingsSnapshot.folderPath = normalizePath(value); - })); - new Setting(textContainer) - .setName('索引文件夹路径') - .setDesc('指定存放索引文件的文件夹路径,默认为仓库:索引') - .addText(text => text - .setValue(this.plugin.settings.indexPath) - .onChange(async (value) => { - this.plugin.settingsSnapshot.indexPath = normalizePath(value); - })); - new Setting(toggleContainer) - .setName('小学') - .setDesc('启用小学单词') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.enableWords1) - .onChange(async (value) => { - this.plugin.settingsSnapshot.enableWords1 = value; - })); - new Setting(toggleContainer) - .setName('中考') - .setDesc('启用中考单词') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.enableWords2) - .onChange(async (value) => { - this.plugin.settingsSnapshot.enableWords2 = value; - })); - new Setting(toggleContainer) - .setName('高四') - .setDesc('启用高四单词') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.enableWords3) - .onChange(async (value) => { - this.plugin.settingsSnapshot.enableWords3 = value; - })); - new Setting(toggleContainer) - .setName('考研') - .setDesc('启用考研单词') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.enableWords4) - .onChange(async (value) => { - this.plugin.settingsSnapshot.enableWords4 = value; - })); - new Setting(toggleContainer) - .setName('六级') - .setDesc('启用六级单词') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.enableWords5) - .onChange(async (value) => { - this.plugin.settingsSnapshot.enableWords5 = value; - })); - new Setting(toggleContainer) - .setName('雅思') - .setDesc('启用雅思单词') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.enableWords6) - .onChange(async (value) => { - this.plugin.settingsSnapshot.enableWords6 = value; - })); - new Setting(toggleContainer) - .setName('托福') - .setDesc('启用托福单词') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.enableWords7) - .onChange(async (value) => { - this.plugin.settingsSnapshot.enableWords7 = value; - })); - new Setting(toggleContainer) - .setName('GRE') - .setDesc('启用GRE单词') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.enableWords8) - .onChange(async (value) => { - this.plugin.settingsSnapshot.enableWords8 = value; - })); - new Setting(containerEl) - .setName('应用设置并缓存') - .setDesc('保存设置并重新缓存单词状态') - .addButton(button => button - .setButtonText('应用') - .setCta() // 设置为主要按钮样式 - .onClick(async () => { - if (button.disabled) return; // 如果按钮已禁用,直接返回 - - button.setDisabled(true); // 禁用按钮 - button.setButtonText('处理中...'); // 更新按钮文本 - - try { - this.plugin.settings = {...this.plugin.settingsSnapshot}; // 更新插件设置 - await this.plugin.saveSettings(); // 保存设置 - await this.plugin.scanAllNotes(); // 重新缓存单词 - - } catch (error) { - console.error('缓存过程中发生错误:', error); - new Notice('缓存失败,请检查控制台日志!'); - } finally { - button.setDisabled(false); // 重新启用按钮 - button.setButtonText('应用'); // 恢复按钮文本 - } - })); - new Setting(containerEl) - .setName('生成索引') - .setDesc('按级别生成所有单词的首字母索引文件') - .addButton(button => button - .setButtonText('生成') - .onClick(async () => { - if (button.disabled) return; // 如果按钮已禁用,直接返回 - - button.setDisabled(true); // 禁用按钮 - button.setButtonText('处理中...'); // 更新按钮文本 - - try { - await this.plugin.generateIndex(); // 生成索引文件 - } catch (error) { - console.error('生成索引过程中发生错误:', error); - new Notice('生成索引失败,请检查控制台日志!'); - } finally { - button.setDisabled(false); // 重新启用按钮 - button.setButtonText('生成'); // 恢复按钮文本 - } - })); - new Setting(containerEl) - .setName('重置单词属性') - .setDesc('重置作用域单词的易记因子、重复次数、间隔和到期日') - .addButton(button => button - .setButtonText('重置') - .onClick(async () => { - if (button.disabled) return; // 如果按钮已禁用,直接返回 - - button.setDisabled(true); // 禁用按钮 - button.setButtonText('处理中...'); // 更新按钮文本 - - try { - await this.plugin.resetCard(); // 重置单词属性 - } catch (error) { - console.error('重置过程中发生错误:', error); - new Notice('重置失败,请检查控制台日志!'); - } finally { - button.setDisabled(false); // 重新启用按钮 - button.setButtonText('重置'); // 恢复按钮文本 - } - })); - } -} - -// 学习模式模态框 -class LearningTypeModal extends Modal { - plugin: OpenWords; - - constructor(app: App, plugin: OpenWords) { - super(app); - this.plugin = plugin; - } - - onOpen() { - this.modalEl.addClass('type-modal'); // 添加自定义样式类 - const { contentEl } = this; - contentEl.empty(); - - contentEl.createDiv({ cls: 'type-title', text: '选择学习模式' }); - const buttonContainer = contentEl.createDiv({ cls: 'type-button-grid' }); - - new Setting(buttonContainer) - .setName(`学习新词 ( 重复次数为零, 剩余 ${this.plugin.newCards.size} )`) - .addButton(btn => btn - .setButtonText("开始") - .onClick(() => { - if (this.plugin.newCards.size > 0) { - this.close(); - new LearningModal(this.app, this.plugin, 'new').open(); - } else { - new Notice("没有新词可学了!"); - } - })); - - new Setting(buttonContainer) - .setName(`复习旧词 ( 重复次数不为零, 剩余 ${this.plugin.dueCards.size} )`) - .addButton(btn => btn - .setButtonText("开始") - .onClick(() => { - if (this.plugin.dueCards.size > 0) { - this.close(); - new LearningModal(this.app, this.plugin, 'review').open(); - } else { - new Notice("没有需要复习的卡片!"); - } - })); - - new Setting(buttonContainer) - .setName(`掌握列表 ( 所有单词 )`) - .addButton(btn => btn - .setButtonText("开始") - .onClick(() => { - this.close(); - new WordListModal(this.app, this.plugin).open(); - })); - - new Setting(buttonContainer) - .setName('默写单词 ( 作用域单词 )') - .addButton(btn => btn - .setButtonText('开始') - .onClick(() => { - this.close(); - new SpellingModal(this.app, this.plugin).open(); - })); - - new Setting(buttonContainer) - .setName(`添加双链 ( 作用域中易记因子 <= 2.5 )`) - .addButton(btn => btn - .setButtonText("开始") - .onClick(async () => { - this.close(); - await this.plugin.addDoubleBrackets(); - })); - } -} - -// 背单词模态框 -class LearningModal extends Modal { - plugin: OpenWords; - mode: 'new' | 'review'; - currentCard: CardInfo; - - constructor(app: App, plugin: OpenWords, mode: 'new' | 'review') { - super(app); - this.plugin = plugin; - this.mode = mode; - } - - onOpen() { - this.pickNextCard(); - this.render(); - - // 监听数字键 0-5 的按键事件 - const handleKeydown = async (event: KeyboardEvent) => { - if (event.key >= '0' && event.key <= '5') { - const grade = parseInt(event.key) as SuperMemoGrade; - await this.rateCard(grade); - } - }; - - window.addEventListener('keydown', handleKeydown); - - // 在模态框关闭时移除事件监听器 - this.onClose = () => { - window.removeEventListener('keydown', handleKeydown); - const { contentEl } = this; - contentEl.empty(); - }; - } - - pickNextCard() { - const now = dayjs(); // 获取当前时间 - let pool: CardInfo[]; - - // 新词池是指所有新词, 旧词池是指所有已过期的旧词 - if (this.mode === 'new') { - pool = Array.from(this.plugin.newCards.values()); // 将 Set 转换为数组 - if (pool.length === 0) { - this.close(); - new Notice("已完成所有新词!"); - return; - } - } else { - pool = Array.from(this.plugin.dueCards.values()) - .filter(card => dayjs(card.dueDate).isBefore(now)) // 筛选已过期的单词 - if (pool.length === 0) { - this.close(); - new Notice("没有需要复习的卡片!"); - return; - } - } - // 单词调度 70% 纯随机, 30% 从易记因子和重复次数最低的 20% 中随机 - const randomMode = Math.random() < 0.7; - if (randomMode) { - this.currentCard = pool[Math.floor(Math.random() * pool.length)]; - } else { - const sortedPool = pool.sort((a, b) => { - const efactorDiff = (a.efactor || 1) - (b.efactor || 1); // 易记因子升序 - if (efactorDiff !== 0) return efactorDiff; - return a.repetition - b.repetition; // 重复次数升序 - }); - const halfPool = sortedPool.slice(0, Math.ceil(sortedPool.length / 5)); // 取前 20% - this.currentCard = halfPool[Math.floor(Math.random() * halfPool.length)]; - } - } - - render() { - const { contentEl } = this; - contentEl.empty(); - - if (!this.currentCard) return; - - // 创建卡片容器并固定样式 - const cardContainer = contentEl.createDiv({ cls: 'anki-card' }); - - // 标记当前是否显示 Markdown 内容 - let isShowingMarkdown = false; - // 初始显示单词 - const wordContent = cardContainer.createDiv({ cls: 'anki-card-content' }); - wordContent.textContent = this.currentCard.front; - - // 添加鼠标悬浮事件切换内容 - cardContainer.addEventListener('mouseenter', async () => { - if (isShowingMarkdown) return; // 如果已经显示 Markdown 内容,则不重复加载 - - const file = this.plugin.app.vault.getFileByPath(this.currentCard.path); - if (file instanceof TFile) { - const markdownContent = await this.plugin.app.vault.read(file); - - // 清空卡片内容并渲染 Markdown 内容 - cardContainer.empty(); - const markdownRenderContainer = cardContainer.createDiv({ cls: 'anki-markdown-content' }); - await MarkdownRenderer.render( - this.app, - markdownContent, - markdownRenderContainer, - file.path, - this.plugin - ); - - isShowingMarkdown = true; - } else { - new Notice('无法加载单词的 Markdown 文件!'); - } - }); - - cardContainer.addEventListener('mouseleave', () => { - if (!isShowingMarkdown) return; // 如果已经显示单词内容,则不重复加载 - - // 切换回显示单词 - cardContainer.empty(); - const wordContent = cardContainer.createDiv({ cls: 'anki-card-content' }); - wordContent.textContent = this.currentCard.front; - isShowingMarkdown = false; - }); - - const settingsContainer = contentEl.createDiv({ cls: 'anki-settings' }); - - new Setting(settingsContainer) - .setName(`评分 0: 回答错误, 完全不会`) - .addButton(btn => btn - .setButtonText('0') - .onClick(() => this.rateCard(0 as SuperMemoGrade))) - new Setting(settingsContainer) - .setName(`评分 1: 回答错误, 看到正确答案后感觉很熟悉`) - .addButton(btn => btn - .setButtonText('1') - .onClick(() => this.rateCard(1 as SuperMemoGrade))) - new Setting(settingsContainer) - .setName(`评分 2: 回答错误, 看到正确答案后感觉很容易记住`) - .addButton(btn => btn - .setButtonText('2') - .onClick(() => this.rateCard(2 as SuperMemoGrade))) - new Setting(settingsContainer) - .setName(`评分 3: 回答正确, 需要花费很大力气才能回忆起来`) - .addButton(btn => btn - .setButtonText('3') - .onClick(() => this.rateCard(3 as SuperMemoGrade))) - new Setting(settingsContainer) - .setName(`评分 4: 回答正确, 需要经过一番犹豫才做出反应`) - .addButton(btn => btn - .setButtonText('4') - .onClick(() => this.rateCard(4 as SuperMemoGrade))) - new Setting(settingsContainer) - .setName(`评分 5: 回答正确, 完美响应`) - .addButton(btn => btn - .setButtonText('5') - .onClick(() => this.rateCard(5 as SuperMemoGrade))) - } - - async rateCard(grade: SuperMemoGrade) { - await this.plugin.updateCard(this.currentCard, grade); - this.pickNextCard(); - this.render(); - } - -} - -// 单词列表模态框 -class WordListModal extends Modal { - plugin: OpenWords; - selectedLetter: string | null = null; // 当前选中的首字母 - selectedLevel: string | null = null; // 当前选中的级别 - - constructor(app: App, plugin: OpenWords) { - super(app); - this.plugin = plugin; - } - - async onOpen() { - this.modalEl.addClass('word-list-modal'); - const { contentEl } = this; - contentEl.empty(); - contentEl.createDiv({ cls: 'word-list-title', text: '掌握列表' }); - const filterContainer = contentEl.createDiv({ cls: 'word-list-filters' }); - const listContainer = contentEl.createDiv({ cls: 'word-list-container' }); - - // 添加首字母筛选 - 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: 'word-list-item' }); - // 左侧显示单词 - const wordText = wordItem.createDiv({ cls: 'word-text', text: card.front }); - const toggleContainer = wordItem.createDiv({ cls: 'toggle-container' }); - - new Setting(toggleContainer) - // .setName(card.front) - .addToggle(toggle => { - toggle.setValue(card.isMastered) // 设置初始复选框状态 - .onChange(async (value) => { - 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: 'word-preview-inline' }); - - // 添加鼠标悬停事件以渲染 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.read(file); - previewContainer.empty(); - await MarkdownRenderer.render( - this.app, - markdownContent, - previewContainer, - file.path, - this.plugin - ); - } - }); - - // 鼠标离开时清空预览内容 - wordText.addEventListener('mouseleave', () => { - previewContainer.empty(); - }); - }); - } - - onClose() { - const { contentEl } = this; - contentEl.empty(); - } -} - -// 默写单词模态框 -class SpellingModal extends Modal { - plugin: OpenWords; - currentCard: CardInfo | null = null; - - constructor(app: App, plugin: OpenWords) { - super(app); - this.plugin = plugin; - } - - async onOpen() { - const { contentEl } = this; - contentEl.empty(); - this.modalEl.addClass('spelling-modal'); // 添加样式类 - - const title = contentEl.createDiv({ cls: 'spelling-title', text: '默写单词' }); - const wordMeaningContainer = contentEl.createDiv({ cls: 'word-meaning-container' }); - const inputContainer = contentEl.createDiv({ cls: 'input-container' }); - const feedbackContainer = contentEl.createDiv({ cls: 'feedback-container' }); - - // 初始化第一个单词 - 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('默写单词'); // 恢复标题 - } - }; - window.addEventListener('keydown', handleKeydown); - window.addEventListener('keyup', handleKeyup); - this.onClose = () => { - window.removeEventListener('keydown', handleKeydown); - window.removeEventListener('keyup', handleKeyup); - contentEl.empty(); - }; - } - - async pickNextCard( - wordMeaningContainer: HTMLElement, - inputContainer: HTMLElement, - feedbackContainer: HTMLElement - ) { - const cards = Array.from(this.plugin.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.read(file); - const match = content.match(/##### 词义\n(?:- .*\n?)*/g); - wordMeaningContainer.empty(); // 清空之前的内容 - if (match) { - await MarkdownRenderer.render( - this.app, - match[0], - wordMeaningContainer, - file.path, - this.plugin - ); - } else { - wordMeaningContainer.setText('未找到词义'); - } - } - - // 清空输入容器并生成字母输入框 - inputContainer.empty(); - feedbackContainer.setText(''); - if (this.currentCard) { - const word = this.currentCard.front; - const inputFields: HTMLInputElement[] = []; - - // 为每个字母生成一个输入框 - for (let i = 0; i < word.length; i++) { - const inputField = inputContainer.createEl('input', { - type: 'text', - cls: 'letter-input', - }); - - inputFields.push(inputField); - - // 自动跳转到下一个输入框 - inputField.addEventListener('input', () => { - if (inputField.value.length === 1 && i < word.length - 1) { - inputFields[i + 1].focus(); - } - this.checkSpelling(inputFields, word, feedbackContainer, wordMeaningContainer, inputContainer); - }); - - // 支持使用退格键返回上一个输入框 - inputField.addEventListener('keydown', (event) => { - if (event.key === 'Backspace' && inputField.value === '' && i > 0) { - inputFields[i - 1].focus(); - } - }); - } - - // 聚焦第一个输入框 - inputFields[0].focus(); - } - } - - checkSpelling( - inputFields: HTMLInputElement[], - word: string, - feedbackContainer: HTMLElement, - wordMeaningContainer: HTMLElement, - inputContainer: HTMLElement - ) { - const userInput = inputFields.map((field) => field.value).join(''); - if (userInput.length === word.length) { - if (userInput.toLowerCase() === word.toLowerCase()) { - feedbackContainer.setText('正确!'); - setTimeout(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(); - } -} diff --git a/manifest.json b/manifest.json index bbda9a7..2ea89d5 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "openwords", "name": "OpenWords", - "version": "1.0.2", + "version": "1.0.3", "minAppVersion": "1.8.10", "description": "用于英语学习中背单词与单词管理的插件", "author": "insile", diff --git a/package-lock.json b/package-lock.json index 0ac3b60..2b246e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6b1c42b..b7c3a60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openwords", - "version": "1.0.2", + "version": "1.0.3", "description": "用于英语学习中背单词与单词管理的插件", "main": "main.js", "scripts": { diff --git a/src/card.ts b/src/card.ts new file mode 100644 index 0000000..b527d3d --- /dev/null +++ b/src/card.ts @@ -0,0 +1,9 @@ +import {SuperMemoItem} from 'supermemo'; + +// 单词卡片信息 +export interface CardInfo extends SuperMemoItem { + front: string; + dueDate: string; + path: string; + isMastered: boolean; +} diff --git a/src/inputsuggest.ts b/src/inputsuggest.ts new file mode 100644 index 0000000..67c2b77 --- /dev/null +++ b/src/inputsuggest.ts @@ -0,0 +1,33 @@ +import { App, TAbstractFile, TFolder, AbstractInputSuggest } from 'obsidian'; + + +export class FolderSuggest extends AbstractInputSuggest { + 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(); + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..27f2612 --- /dev/null +++ b/src/main.ts @@ -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 = new Map(); // 存储所有单词的元数据 + masterCards: Map = new Map(); // 存储作用域单词的元数据 + enabledCards: Map = new Map(); // 存储作用域单词的元数据 + newCards: Map = new Map(); // 存储新单词的元数据 + dueCards: Map = 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 = {}; + 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 = {}; // 记录每个级别的总单词数 + // 遍历级别,生成索引 + 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 = {}; // { 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 = {}; + 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); + } +} diff --git a/src/modals/LearningModal.ts b/src/modals/LearningModal.ts new file mode 100644 index 0000000..1c9c2c4 --- /dev/null +++ b/src/modals/LearningModal.ts @@ -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(); + } + +} diff --git a/src/modals/LearningTypeModal.ts b/src/modals/LearningTypeModal.ts new file mode 100644 index 0000000..4ed300b --- /dev/null +++ b/src/modals/LearningTypeModal.ts @@ -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(); + })); + } +} diff --git a/src/modals/SpellingModal.ts b/src/modals/SpellingModal.ts new file mode 100644 index 0000000..1fe8efa --- /dev/null +++ b/src/modals/SpellingModal.ts @@ -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(); + } +} diff --git a/src/modals/WordListModal.ts b/src/modals/WordListModal.ts new file mode 100644 index 0000000..397170e --- /dev/null +++ b/src/modals/WordListModal.ts @@ -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(); + } +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..935c53d --- /dev/null +++ b/src/settings.ts @@ -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('重置'); // 恢复按钮文本 + } + })); + } +} diff --git a/styles.css b/styles.css index 5ca24dd..7609428 100644 --- a/styles.css +++ b/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; diff --git a/versions.json b/versions.json index 2f5eb3b..28e3f1b 100644 --- a/versions.json +++ b/versions.json @@ -1,3 +1,3 @@ { - "1.0.2": "1.8.10" + "1.0.3": "1.8.10" }