Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions plugins/recent-typora/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules/
npm-debug.log*
.DS_Store
Thumbs.db
*.log
10 changes: 10 additions & 0 deletions plugins/recent-typora/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Changelog

## 1.0.0 - 2026-07-07

Recent Typora插件首个发布版本。

### 新增

- 只读访问 Typora 的 history.data, 按最近打开顺序列出 Typora 的最近打开的文件和文件夹
- 自动查找 Typora 路径,使用 Typora 打开插件列出的文件或文件夹
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
78 changes: 78 additions & 0 deletions plugins/recent-typora/README.md
Original file line number Diff line number Diff line change
@@ -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 加载此目录。

Binary file added plugins/recent-typora/folder-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added plugins/recent-typora/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added plugins/recent-typora/markdown-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions plugins/recent-typora/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions plugins/recent-typora/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
18 changes: 18 additions & 0 deletions plugins/recent-typora/plugin.json
Original file line number Diff line number Diff line change
@@ -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"]
}
]
}
194 changes: 194 additions & 0 deletions plugins/recent-typora/preload.js
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading