从0开始开发一个picgo插件

banner:https://www.pixiv.net/artworks/92048077

前言

嘛,正如标题所言,我确实打算写一个picgo插件。

并开此贴为证,(那如果写了个bug岂不是贻笑大方),记录一些过程(破事水)。

PicGo: 一个用于快速上传图片并获取图片 URL 链接的工具

简单来说, picgo 可以把图片上传到各个图床,获取外链,方便在Markdown、BBS等各种场合使用。

为什么要使用图床

图床就是储存图片的网站。现在的图片动辄上几 MB ,使用图床能够有效提高图片的加载速度。有些论坛对于图片使用有大小限制,像很多 discuz 论坛个性签名的 gif 就只有用图床才能做到。此外,图床还能对图片进行备份。博客是可以长期存在的东西,但图床就不一定了。在本地、 github 、图床三端存储,可以把图片丢失的概率降到最低。

使用流程

没有 picgo 时:

  • 下载图片到本地
  • 如果图片太大,用 squoosh || tinypng 压缩
  • 下载压缩后的图片到本地
  • 上传图床
  • 复制图床链接
  • Markdown 中插入链接

有了 picgo 之后:

  • 下载图片到本地
  • picgo上传并压缩
  • Markdown 中插入链接

可以看到直接砍掉了中间几步,大幅提高工作 摸鱼 效率。

旧事

大概也是去年的这个时候,我根据 插件开发指南 写了一个 Ucloud 的 uploader 。如果以二零年九月为一条线,向前向后可以分出两个我:前一个我,什么都不懂;后一个我,也什么都不懂。

If the authors of computer programming books wrote arithmetic textbooks

呃,为什么上面的图是 Rabbit 而不是 Bunny Girl

总的来说,这工具挺好用。没想到的是,发布到 npm 的一年的时间里,竟然还有几个人去下载。那为什么 github 只有三个 star

npm-stat.com

而个人在使用中,觉得还有一点不足:当图片在剪切板中或者用图片的 url 上传时,只上传到了图床,如果要保存到本地,还得手动操作,略显麻烦。于是就想再开发一个插件。于是就找了找以前的项目。于是就看不懂自己之前写的了。

开发

由于本人智商孱弱,只得跟着文档重新一步步来。

确定需求,明确插件的生命周期

需求上面提了:在剪切板中或者用图片的 url 上传时,能够自动保存到自定义的文件夹。而整个上传的生命周期(Lifecycle)如下:

理论上,我们可以从3个生命周期钩子中任选一个来注册我们的插件,但是考虑到其他插件,情况会复杂一点。比如我有一个压缩图片的 Transformer ,那到底是保存压缩后的文件还是压缩前的。虽然可以让用户自定义,但一般来说,我们对保存在本地的图片的大小是宽容的,追求清晰,所以可以注册一个 beforeTransformPlugin 。

准备环境

  • 安装 node 。
  • 安装 picgo 。 npm install picgo -g
  • 试运行。 picgo -h

使用插件模板

为了方便开发者快速开发picgo的插件,PicGo官方提供了插件模板。然后就傻瓜式操作了。

1
2
3
4
5
6
7
8
9
10
11
PS D:\repo> picgo init plugin picgo-plugin-store
[PicGo INFO]: Template files are downloading...
[PicGo SUCCESS]: Template files are downloaded!
? Plugin name: store # 这里不要加picgo-plugin-前缀
? Plugin description: To store img which not exists locally
? author: xizeyoupan
? Choose modules you want to develop: beforeTransformPlugins
? Your plugin is just used in CLI? No
? Use TS or JS? (Use arrow keys)
> Yes, use TS Project(recommended)
Yes, use JS Project

此处我要说两句, TS 是 JS 的超集,TS 相对于 JS 增加了许多功能,picgo 就是用 ts 写的,而且上面也提示了 ts 是 recommended 的,所以,我们这里就直接选 js 吧,当然是因为这种 fragments 用 ts 完全是杀鸡用牛刀,才不是因为我一点都不会用 ts 呢,不是!

1
2
3
4
5
6
7
8
9
10
11
12
? Use TS or JS? js
? Your plugin has some shortcut for GUI? No
[PicGo SUCCESS]:
Generate template files successfully!
Please cd D:\repo\picgo-plugin-store, and then

npm install

# or

yarn
[PicGo SUCCESS]: Done!

接着按提示 cd && install 。

代码编写

初体验

模板的代码已经为我们搭好了简单的框架,我们只要把逻辑在 handle 中实现就行了。

1
2
3
4
5
6
7
8
9
10
11
12
const handle = ctx => {
console.log(ctx)
}

module.exports = (ctx) => {
const register = () => {
ctx.helper.beforeTransformPlugins.register('store', { handle })
}
return {
register
}
}

接下来我们先测试一下。由于我们要开发 GUI 插件,这里一步到位了,所以 cd 到PicGo默认配置文件所在的目录下,输入:

1
npm install D:\repo\picgo-plugin-store

然后重启 PicGo ,就能看到自己的插件了。

我们在 handle 中加几行代码:

1
2
3
4
5
6
const handle = ctx => {
ctx.emit('test', {
title: 'this is input',
body: JSON.stringify(ctx.input),
})
}

然后再重复执行

1
npm install D:\repo\picgo-plugin-store

并重启,试着上传一张图片,就能在弹窗中看到输入的数组了。话说回来,这样调试起来还是挺费劲的,代码更新后就要 install ,没有断点,没有热重载。在官方文档看了一下,好像并没有调试的详细步骤。如果想要调试 GUI ,可能还要 clone PicGo-electron 。下面是一种在 PicGo-Core 中能断点的方法:

  • 在你插件的代码中新建个文件,比如src/test.js
  • 写上代码调用picgo。
1
2
3
4
5
6
7
8
9
// 系统全局安装路径,用 npm config get prefix 查看,在其下的 node_modules 里。
const PicGo = require('C:\\Users\\myusername\\AppData\\Roaming\\npm\\node_modules\\picgo')

// 配置文件的路径,这里我直接使用 PicGo-electron 的了。
const picgo = new PicGo('C:\\Users\\myusername\\AppData\\Roaming\\picgo\\data.json')

// 执行上传
picgo.upload(['https://www.baidu.com/img/PCtm_d9c8750bed0b3c7d089fa7d55720d6cf.png',
'C:\\Users\\myusername\\Desktop\\20210821110935.png', 'C:\\Users\\myusername\\Desktop\\temp\\20210821110935.png'])
  • package.json中修改 test script :
1
2
3
"scripts": {
"test": "node src/test.js",
}

这样就能在 IDE 中 debug 了。

开始上路

Transformer的作用是把input输入的内容(比如path)转化成Uploader可以上传的内容。

那input数组的每个输入元素到底是什么呢?经过几次上面方法的测试,我们可以得出结论:对于本地图片,就是本地路径;对于剪贴板,先把图片保存在本地临时文件夹中,因此是一个本地临时文件的路径。然后再通过回调,在整个生命周期结束,或发生错误时删除图片。

关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 这个函数就对图片在剪贴板
async upload (input?: any[]): Promise<IImgInfo[] | Error> {
if (this.configPath === '') {
this.log.error('The configuration file only supports JSON format.')
return []
}
// upload from clipboard
if (input === undefined || input.length === 0) {
try {
const { imgPath, isExistFile } = await getClipboardImage(this)
// 这里的 imgPath 就是临时图片文件的路径
if (imgPath === 'no image') {
throw new Error('image not found in clipboard')
} else {
this.once('failed', () => {
if (!isExistFile) {
// 删除 picgo 生成的图片文件,例如 `~/.picgo/20200621205720.png`
fs.remove(imgPath).catch((e) => { this.log.error(e) })
}
})
this.once('finished', () => {
if (!isExistFile) {
fs.remove(imgPath).catch((e) => { this.log.error(e) })
}
})
const { output } = await this.lifecycle.start([imgPath]) // 从这里才真正进入生命周期,因此我们在插件中拿到的 path 就是临时图片文件的路径
return output
}
} catch (e) {
this.emit(IBuildInEvent.FAILED, e)
throw e
}
} else {
// upload from path
const { output } = await this.lifecycle.start(input)
return output
}
}

那如果从 url 上传图片呢?再读读源码就知道,官方的默认实现是在 path 这个 Transformer 中,从网上拿数据。

1
2
3
4
5
if (isUrl(item)) {
info = await getURLFile(item) // 在这个函数中进行请求
} else {
info = await getFSFile(item)
}

考虑到我们插件的生命周期在 Transformer 之前,所以我们应该提前先拿到数据,保存在本地。而这样一来图片就会被下载两次,所以我们可以修改 input ,使之变为本地文件的路径。如果是本地上传的文件,我们只要拷贝一份到目标目录就行。如果本地文件不需要重复存储,只要把插件配置的目标路径设为本地文件所在的路径即可。

写bug

主要逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const storeHandle = async (ctx) => {
const config = ctx.getConfig('picgo-plugin-store')

ctx.input = await Promise.all(ctx.input.map(async (item, index) => {
let fileName = ''
let filePath = config.storePath

// ...

if (isUrl(item)) {
item = await downloadFile(item, filePath, fileName, ctx)
} else {
item = copyFile(item, filePath, fileName)
}

return item
}))
}

copyFile返回的就是item,而downloadFile会返回一个带文件名的,和配置的路径一致的绝对路径。对于默认命名规则,剪贴板是时间戳, url 则是其最后一个路径。有时我们想使用自己的名字。于是我试图这么做:

1
2
3
4
5
6
if (config.setEachImgName) {
fileName = await guiApi.showInputBox({
title: '输入文件名',
placeholder: '不用加后缀'
})
}

但是很遗憾,作者并没有给插件的 handle 方法提供 guiApi ,所以我们自定义名称这条路就行不通了。。。吗?

看看设置,很容易找到一个上传前重命名。

那再看看代码,看我们能不能拿到这个名字

1
2
3
4
5
6
7
8
9
10
11
12
13
picgo.helper.beforeUploadPlugins.register('renameFn', {
handle: async (ctx: IPicGo) => {
...
await Promise.all(ctx.output.map(async (item, index) => {
...
if (rename) {
...
name = await waitForRename(window, window.webContents.id)
}
item.fileName = name || fileName
}))
}
})

从中我们可以看到几个关键点:

  • 作者也是注册了一个插件。
  • 改名插件的生命周期在我们自己插件的后面。
  • 改名通过修改 output 中 item 的 fileName 来实现。

那我们就可以再注册一个 afterUploadPlugin ,在上传完成后对目标目录内的图片进行重命名。

1
2
3
4
5
6
7
8
9
10
11
12
const renameHandle = ctx => {
const config = ctx.getConfig('picgo-plugin-store')
if (!config.autoRename) return
if (ctx.input.length !== ctx.output.length) return

let filePath = config.storePath

for (let i = 0; i < ctx.input.length; i++) {
const fileName = path.basename(ctx.input[i])
fs.renameSync(path.join(filePath, fileName), path.join(filePath, ctx.output[i].fileName))
}
}

这样,我们就完成了基本的代码逻辑。

部署

官方的插件模板已经为我们准备好了.travis.yml,我们只需要配置好NPM_TOKEN就能在推送到 github 时直接用 Travis 发布到 npm 上了。 所以我直接把.travis.yml删了,换上了 github action。 嘛,github 官方的稳定性应该比 Travis 好一点吧。。

配置Action

新建.github/workflows/npm-publish.yml,写入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
on: push

jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/setup-node@v1
with:
node-version: 14
- run: npm install
- uses: JS-DevTools/npm-publish@v1
with:
token: ${{ secrets.NPM_TOKEN }}

为了下面自动打 tag ,我们修改package.json中的 version 为 0.0.0 。接着就是一套提交流程:

1
2
3
4
5
6
git init
git remote add origin https://github.com/xizeyoupan/picgo-plugin-store.git
git branch -M master
git add .
git commit -m feature:first-commit
npm run patch # 这边会升级版本并commit

如果你在项目中设置了NPM_TOKEN(可用 npm cli login 后在 .npmrc 文件中找到。),那么应该就能看到 npm 上自动发布的包啦~

后续维护

如果修改了本地代码,只需:

1
2
3
git add .
git commit -m feature:some-features
npm run patch

就又能提交代码并自动发布到 npm 上啦!

后记

没想到简单的一个项目写的还挺长。看了一下 picgo 可以追溯到2018年。 怪不得还在用已经 Deprecated 一万年的 request 代码中也可能会有点历史遗留问题,比如看起来就很鸡肋的 base64 Transformer。当我看到这段代码时表情是疑惑的:

1
2
3
4
5
6
7
8
9
import { IPicGo } from '../../types'
const handle = async (ctx: IPicGo): Promise<IPicGo> => {
ctx.output.push(...ctx.input)
return ctx
}

export default {
handle
}

又仔细看了看文档,读了一下源码:

才明白默认实现没用到这个,对照代码确实base64 将直接接收一个Uploader可支持的output数组,也明白了 buffer 和 base64 二选一原来是对 Transformer 来说的,因此对于 Uploader 理论上要把两个都实现一下。写到这里我突然想起来我之前写的 ucloud-uploader 好像只实现了 buffer ,还有一个图片名是中文时 encodeURI 的 bug 没修。

但因为这几天已经码了不少字,所以明天再改吧。