diff --git a/plugins/recent-typora/.gitignore b/plugins/recent-typora/.gitignore new file mode 100644 index 00000000..65381bd5 --- /dev/null +++ b/plugins/recent-typora/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +npm-debug.log* +.DS_Store +Thumbs.db +*.log diff --git a/plugins/recent-typora/CHANGELOG.md b/plugins/recent-typora/CHANGELOG.md new file mode 100644 index 00000000..26b2082b --- /dev/null +++ b/plugins/recent-typora/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## 1.0.0 - 2026-07-07 + +Recent Typora插件首个发布版本。 + +### 新增 + +- 只读访问 Typora 的 history.data, 按最近打开顺序列出 Typora 的最近打开的文件和文件夹 +- 自动查找 Typora 路径,使用 Typora 打开插件列出的文件或文件夹 diff --git a/plugins/recent-typora/README.assets/screen_photo.png b/plugins/recent-typora/README.assets/screen_photo.png new file mode 100644 index 00000000..33baf067 Binary files /dev/null and b/plugins/recent-typora/README.assets/screen_photo.png differ diff --git a/plugins/recent-typora/README.md b/plugins/recent-typora/README.md new file mode 100644 index 00000000..a9a1ee56 --- /dev/null +++ b/plugins/recent-typora/README.md @@ -0,0 +1,78 @@ +# Recent Typora + +一个模仿 uTools 的 QuickTypora 插件的 ZTools 插件,用于读取 Typora 的最近打开的文件和文件夹,并按最近打开时间顺序列出,支持windows,linux和macos。 + +QuickTypora 插件地址:https://www.u-tools.cn/plugins/detail/QuickTypora/ + +此插件内容完全由codex生成,已在windows 10上得到测试。linux和macos上尚未得到测试。 + +## 截图 + +![screen_photo](README.assets/screen_photo.png) + +## 使用方法 + +1. 输入 `rt`、`typo` 或 `recent typora`,回车进入插件。 +2. 在列表中选择最近文件或文件夹,回车或鼠标单击后,将自动使用 Typora 打开所选文件或文件夹。 +3. 可在插件顶部的搜索框中按名称或路径筛选。 + +修改代码或配置后,如果效果没有更新,请从系统托盘彻底退出并重新启动 ZTools。ZTools 可能会保留已加载的 Preload 进程。 + +## 功能 + +- 只读访问 Typora 文件夹下的`history.data`,获取最近使用 Typora 打开的文件和文件夹列表,按最近打开时间从新到旧排序 +- 在插件内部按名称或完整路径搜索 +- 使用不同图标区分 Markdown 文件与文件夹 +- 选择任一项目后,显式使用 Typora 打开 + +## Typora的history.data位置 + +插件默认查找: + +- Windows:`%APPDATA%\Typora\history.data` +- Linux:`${XDG_CONFIG_HOME:-~/.config}/Typora/history.data` +- macOS:`~/Library/Application Support/abnerworks.Typora/history.data` +- macOS 兼容路径:`~/Library/Application Support/Typora/history.data` + +如果历史文件位于其他位置,可在启动 ZTools 前设置: + +```text +TYPORA_HISTORY_PATH=history.data 的完整路径 +``` + +目前确认`history.data`的数据格式是“UTF-8 JSON 转十六进制文本”。解析器同时兼容 Base64 和明文 JSON。文件记录通常使用毫秒时间戳,文件夹记录可能使用 ISO 日期字符串,插件会统一转换后排序。 + +## 插件如何启动 Typora + +- Windows:依次检查常见的系统级和用户级安装目录。 +- Linux:执行 `typora "目标路径"`,因此 `typora` 需要位于 `PATH` 中。 +- macOS:执行 `open -a Typora "目标路径"`。 + +对于便携版或自定义安装,可设置: + +```text +TYPORA_EXECUTABLE=Typora 可执行文件的完整路径 +``` + +macOS 使用该变量时,应指向应用内部的可执行文件,而不只是 `Typora.app` 目录。 + +## 项目文件 + +- `plugin.json`:插件信息、功能入口和触发指令 +- `preload.js`:历史解析、列表模式和 Typora 启动逻辑 +- `test.js`:解析、排序、图标、路径和启动命令测试 +- `logo.png`:插件入口图标 +- `folder-icon.png`:文件夹列表图标 +- `markdown-icon.png`:Markdown 文件列表图标 + +## 安装依赖与测试 + +本插件不需要编译,也没有 `dev` 或 `build` 步骤。 + +```bash +npm install +npm test +``` + +`npm test` 只负责验证代码;实际运行需要由 ZTools 加载此目录。 + diff --git a/plugins/recent-typora/folder-icon.png b/plugins/recent-typora/folder-icon.png new file mode 100644 index 00000000..cb0b3cc8 Binary files /dev/null and b/plugins/recent-typora/folder-icon.png differ diff --git a/plugins/recent-typora/logo.png b/plugins/recent-typora/logo.png new file mode 100644 index 00000000..ab701495 Binary files /dev/null and b/plugins/recent-typora/logo.png differ diff --git a/plugins/recent-typora/markdown-icon.png b/plugins/recent-typora/markdown-icon.png new file mode 100644 index 00000000..8223e643 Binary files /dev/null and b/plugins/recent-typora/markdown-icon.png differ diff --git a/plugins/recent-typora/package-lock.json b/plugins/recent-typora/package-lock.json new file mode 100644 index 00000000..252e5401 --- /dev/null +++ b/plugins/recent-typora/package-lock.json @@ -0,0 +1,22 @@ +{ + "name": "recent-typora", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "recent-typora", + "version": "1.0.0", + "devDependencies": { + "@ztools-center/ztools-api-types": "^1.0.1" + } + }, + "node_modules/@ztools-center/ztools-api-types": { + "version": "1.0.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/@ztools-center/ztools-api-types/-/ztools-api-types-1.0.3.tgz", + "integrity": "sha512-dv1eOAIasAupqKaQL/gESk1i2+RebdM/1gvZhrvH2D/bo3enCUsAGJ8nrHnlCLBSOGB81eC/SU0IH8BNsUlmvA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/plugins/recent-typora/package.json b/plugins/recent-typora/package.json new file mode 100644 index 00000000..9dd37b5b --- /dev/null +++ b/plugins/recent-typora/package.json @@ -0,0 +1,12 @@ +{ + "name": "recent-typora", + "version": "1.0.0", + "private": true, + "description": "按时间顺序列出 Typora 最近打开的文件和文件夹。", + "scripts": { + "test": "node test.js" + }, + "devDependencies": { + "@ztools-center/ztools-api-types": "^1.0.1" + } +} diff --git a/plugins/recent-typora/plugin.json b/plugins/recent-typora/plugin.json new file mode 100644 index 00000000..ccab5af7 --- /dev/null +++ b/plugins/recent-typora/plugin.json @@ -0,0 +1,18 @@ +{ + "$schema": "node_modules/@ztools-center/ztools-api-types/resource/ztools.schema.json", + "name": "recent-typora", + "title": "Recent Typora", + "description": "按最近打开顺序列出 Typora 的文件和文件夹", + "author": "轩辕真人", + "version": "1.0.0", + "preload": "preload.js", + "logo": "logo.png", + "features": [ + { + "code": "recent-typora", + "explain": "查看 Typora 最近打开的文件和文件夹", + "icon": "logo.png", + "cmds": ["rt", "typo", "recent typora"] + } + ] +} diff --git a/plugins/recent-typora/preload.js b/plugins/recent-typora/preload.js new file mode 100644 index 00000000..2d6fb085 --- /dev/null +++ b/plugins/recent-typora/preload.js @@ -0,0 +1,194 @@ +const fs = require('fs') +const os = require('os') +const path = require('path') +const { spawn } = require('child_process') + +function getHistoryCandidates(platform = process.platform, env = process.env, home = os.homedir()) { + const candidates = [] + const pathApi = platform === 'win32' ? path.win32 : path.posix + if (env.TYPORA_HISTORY_PATH) candidates.push(pathApi.resolve(env.TYPORA_HISTORY_PATH)) + + if (platform === 'win32') { + candidates.push(pathApi.join(env.APPDATA || pathApi.join(home, 'AppData', 'Roaming'), 'Typora', 'history.data')) + } else if (platform === 'darwin') { + candidates.push( + pathApi.join(home, 'Library', 'Application Support', 'abnerworks.Typora', 'history.data'), + pathApi.join(home, 'Library', 'Application Support', 'Typora', 'history.data') + ) + } else { + candidates.push(pathApi.join(env.XDG_CONFIG_HOME || pathApi.join(home, '.config'), 'Typora', 'history.data')) + } + return [...new Set(candidates)] +} + +function decodeHistory(raw) { + const source = raw.replace(/^\uFEFF/, '').trim() + if (!source) throw new Error('history.data 为空') + + if (source.length % 2 === 0 && /^[0-9a-f]+$/i.test(source)) { + return Buffer.from(source, 'hex').toString('utf8') + } + if (/^[a-z0-9+/]+={0,2}$/i.test(source) && source.length % 4 === 0) { + const decoded = Buffer.from(source, 'base64').toString('utf8') + if (/^\s*[\[{]/.test(decoded)) return decoded + } + return source +} + +function normalizeDate(value) { + if (typeof value === 'number') return Number.isFinite(value) ? value : 0 + if (typeof value !== 'string' || !value.trim()) return 0 + + const source = value.trim() + if (/^\d+(?:\.\d+)?$/.test(source)) return Number(source) + const timestamp = Date.parse(source) + return Number.isFinite(timestamp) ? timestamp : 0 +} + +function parseHistory(raw) { + const data = JSON.parse(decodeHistory(raw)) + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new Error('history.data 解析结果不是有效的 JSON 对象') + } + const records = [] + + for (const [key, type] of [['recentFolder', 'folder'], ['recentDocument', 'file']]) { + const items = Array.isArray(data[key]) ? data[key] : [] + for (const item of items) { + if (!item || typeof item.path !== 'string' || !item.path.trim()) continue + records.push({ + name: typeof item.name === 'string' && item.name.trim() ? item.name.trim() : (path.win32.basename(item.path) || item.path), + path: item.path, + date: normalizeDate(item.date), + type + }) + } + } + + records.sort((a, b) => b.date - a.date) + + const seen = new Set() + return records.filter((item) => { + const key = (process.platform === 'win32' || process.platform === 'darwin') ? item.path.toLowerCase() : item.path + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +function readRecentItems() { + const candidates = getHistoryCandidates() + const historyPath = candidates.find((candidate) => fs.existsSync(candidate)) + if (!historyPath) throw new Error(`未找到 Typora 历史文件:${candidates.join(';')}`) + return parseHistory(fs.readFileSync(historyPath, 'utf8')) +} + +function toListResults(items, searchWord = '') { + const keyword = String(searchWord || '').trim().toLowerCase() + return items + .map((item) => ({ + title: item.name, + description: item.path, + icon: item.type === 'folder' ? 'folder-icon.png' : 'markdown-icon.png', + path: item.path, + itemType: item.type + })) + .filter((item) => !keyword || item.title.toLowerCase().includes(keyword) || item.path.toLowerCase().includes(keyword)) +} + +let cachedItems = null + +function loadList(searchWord = '', forceRefresh = false) { + try { + if (forceRefresh || !cachedItems) { + cachedItems = readRecentItems() + } + return cachedItems.length + ? toListResults(cachedItems, searchWord) + : [{ title: '暂无最近记录', description: 'Typora 的 history.data 中没有文件或文件夹' }] + } catch (error) { + console.error('[recent-typora]', error) + return [{ title: '无法读取 Typora 最近记录', description: error.message }] + } +} + +function getTyporaLaunch(target, platform = process.platform, env = process.env, home = os.homedir()) { + if (env.TYPORA_EXECUTABLE) return { command: env.TYPORA_EXECUTABLE, args: [target] } + + if (platform === 'darwin') { + return { command: 'open', args: ['-a', 'Typora', target] } + } + if (platform === 'linux') { + return { command: 'typora', args: [target] } + } + + const candidates = [ + env.ProgramW6432 && path.win32.join(env.ProgramW6432, 'Typora', 'Typora.exe'), + env.ProgramFiles && path.win32.join(env.ProgramFiles, 'Typora', 'Typora.exe'), + env['ProgramFiles(x86)'] && path.win32.join(env['ProgramFiles(x86)'], 'Typora', 'Typora.exe'), + env.LOCALAPPDATA && path.win32.join(env.LOCALAPPDATA, 'Programs', 'Typora', 'Typora.exe') + ].filter(Boolean) + const executable = candidates.find((candidate) => fs.existsSync(candidate)) || 'Typora.exe' + return { command: executable, args: [target] } +} + +function openInTypora(target, onError = console.error) { + try { + const launch = getTyporaLaunch(target) + const child = spawn(launch.command, launch.args, { + detached: true, + stdio: 'ignore', + windowsHide: true + }) + child.once('error', onError) + child.unref() + } catch (error) { + onError(error) + } +} + +function createListFeature(ztools) { + return { + mode: 'list', + args: { + enter: (action, callbackSetList) => { + cachedItems = null + callbackSetList(loadList('', true)) + }, + search: (action, searchWord, callbackSetList) => callbackSetList(loadList(searchWord)), + select: (action, itemData) => { + if (!itemData || !itemData.path) return + if (!fs.existsSync(itemData.path)) { + ztools.showNotification(`路径已不存在:${itemData.path}`) + return + } + openInTypora(itemData.path, (error) => { + console.error('[recent-typora] 无法启动 Typora', error) + ztools.showNotification('无法启动 Typora,请检查安装路径') + }) + ztools.hideMainWindow() + ztools.outPlugin() + }, + placeholder: '搜索最近打开的文件或文件夹' + } + } +} + +if (typeof window !== 'undefined' && window.ztools) { + window.exports = { + 'recent-typora': createListFeature(window.ztools) + } +} + +module.exports = { + createListFeature, + decodeHistory, + getHistoryCandidates, + getTyporaLaunch, + loadList, + normalizeDate, + openInTypora, + parseHistory, + readRecentItems, + toListResults +} diff --git a/plugins/recent-typora/test.js b/plugins/recent-typora/test.js new file mode 100644 index 00000000..d2a5bd04 --- /dev/null +++ b/plugins/recent-typora/test.js @@ -0,0 +1,115 @@ +const assert = require('assert') +const fs = require('fs') +const os = require('os') +const path = require('path') +const { + createListFeature, + decodeHistory, + getHistoryCandidates, + getTyporaLaunch, + normalizeDate, + parseHistory, + toListResults +} = require('./preload') + +const fixture = { + recentFolder: [{ name: 'folder', path: '/tmp/folder', date: '1970-01-01T00:00:00.020Z' }], + recentDocument: [{ name: 'file.md', path: '/tmp/file.md', date: 30 }] +} +const json = JSON.stringify(fixture) + +assert.strictEqual(decodeHistory(Buffer.from(json).toString('hex')), json) +assert.strictEqual(decodeHistory(Buffer.from(json).toString('base64')), json) +assert.strictEqual(decodeHistory(json), json) + +const parsed = parseHistory(Buffer.from(json).toString('hex')) +assert.deepStrictEqual(parsed.map((item) => item.name), ['file.md', 'folder']) +assert.strictEqual(parsed[1].date, 20) + +for (const invalidJsonValue of ['null', '[]', '"invalid"', '-1']) { + assert.throws( + () => parseHistory(invalidJsonValue), + { message: 'history.data 解析结果不是有效的 JSON 对象' } + ) +} + +const roots = parseHistory(JSON.stringify({ + recentFolder: [{ path: '/' }, { path: 'C:\\' }] +})) +assert.deepStrictEqual(roots.map((item) => item.name), ['/', 'C:\\']) +const crossPlatformPaths = parseHistory(JSON.stringify({ + recentDocument: [ + { path: 'C:\\Users\\test\\notes\\windows.md' }, + { path: '/home/test/notes/posix.md' } + ] +})) +assert.deepStrictEqual(crossPlatformPaths.map((item) => item.name), ['windows.md', 'posix.md']) +const duplicatedCaseItems = parseHistory(JSON.stringify({ + recentDocument: [ + { path: '/tmp/Notes.md', date: 2 }, + { path: '/tmp/notes.md', date: 1 } + ] +})) +if (process.platform === 'win32' || process.platform === 'darwin') { + assert.deepStrictEqual(duplicatedCaseItems.map((item) => item.path), ['/tmp/Notes.md']) +} else { + assert.deepStrictEqual(duplicatedCaseItems.map((item) => item.path), ['/tmp/Notes.md', '/tmp/notes.md']) +} + +assert.strictEqual(normalizeDate('2026-07-07T05:45:41.246Z'), 1783403141246) +assert.strictEqual(toListResults(parsed)[0].description, '/tmp/file.md') +assert.strictEqual(toListResults(parsed)[0].icon, 'markdown-icon.png') +assert.strictEqual(toListResults(parsed)[1].icon, 'folder-icon.png') +assert.deepStrictEqual(toListResults(parsed, 'folder').map((item) => item.title), ['folder']) +assert.strictEqual(toListResults(parsed, null).length, 2) + +assert.ok(getHistoryCandidates('win32', { APPDATA: 'C:\\Data' }, 'C:\\Users\\test')[0].endsWith('Typora\\history.data')) +assert.ok(getHistoryCandidates('linux', {}, '/home/test')[0].endsWith('.config/Typora/history.data')) +assert.ok(getHistoryCandidates('darwin', {}, '/Users/test')[0].includes('abnerworks.Typora')) +assert.deepStrictEqual(getTyporaLaunch('/notes', 'linux', {}), { command: 'typora', args: ['/notes'] }) +assert.deepStrictEqual(getTyporaLaunch('/notes', 'darwin', {}), { command: 'open', args: ['-a', 'Typora', '/notes'] }) +assert.deepStrictEqual(getTyporaLaunch('C:\\notes', 'win32', { TYPORA_EXECUTABLE: 'D:\\Typora.exe' }), { + command: 'D:\\Typora.exe', + args: ['C:\\notes'] +}) + +const feature = createListFeature({}) +assert.strictEqual(feature.mode, 'list') +assert.strictEqual(typeof feature.args.enter, 'function') +assert.strictEqual(typeof feature.args.search, 'function') +assert.strictEqual(typeof feature.args.select, 'function') + +const originalHistoryPath = process.env.TYPORA_HISTORY_PATH +const cacheTestDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'recent-typora-')) +const cacheTestHistory = path.join(cacheTestDirectory, 'history.data') +try { + process.env.TYPORA_HISTORY_PATH = cacheTestHistory + fs.writeFileSync(cacheTestHistory, JSON.stringify({ + recentDocument: [{ name: 'before.md', path: '/tmp/before.md', date: 1 }] + })) + + const cachedFeature = createListFeature({}) + let cacheTestResults + cachedFeature.args.enter(null, (items) => { cacheTestResults = items }) + assert.deepStrictEqual(cacheTestResults.map((item) => item.title), ['before.md']) + + fs.writeFileSync(cacheTestHistory, JSON.stringify({ + recentDocument: [{ name: 'after.md', path: '/tmp/after.md', date: 2 }] + })) + cachedFeature.args.search(null, 'before', (items) => { cacheTestResults = items }) + assert.deepStrictEqual(cacheTestResults.map((item) => item.title), ['before.md']) + + cachedFeature.args.enter(null, (items) => { cacheTestResults = items }) + assert.deepStrictEqual(cacheTestResults.map((item) => item.title), ['after.md']) +} finally { + if (originalHistoryPath === undefined) delete process.env.TYPORA_HISTORY_PATH + else process.env.TYPORA_HISTORY_PATH = originalHistoryPath + fs.rmSync(cacheTestDirectory, { recursive: true, force: true }) +} + +for (const asset of ['logo.png', 'folder-icon.png', 'markdown-icon.png']) { + assert.ok(fs.existsSync(asset), `缺少资源文件:${asset}`) +} + + +console.log('All tests passed.')