/
siyuanPicgoPostApi.ts
402 lines (352 loc) · 13.6 KB
/
siyuanPicgoPostApi.ts
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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
/*
* GNU GENERAL PUBLIC LICENSE
* Version 3, 29 June 2007
*
* Copyright (C) 2024 Terwer, Inc. <https://terwer.space/>
* Everyone is permitted to copy and distribute verbatim copies
* of this license document, but changing it is not allowed.
*/
import { ILogger, simpleLogger } from "zhi-lib-base"
import { SiyuanPicGoUploadApi } from "./siyuanPicGoUploadApi"
import { hasNodeEnv, IPicGo, isFileOrBlob, win } from "universal-picgo"
import { ParsedImage } from "./models/ParsedImage"
import { ImageItem } from "./models/ImageItem"
import { SIYUAN_PICGO_FILE_MAP_KEY } from "./constants"
import { JsonUtil, StrUtil } from "zhi-common"
import { SiyuanConfig, SiyuanKernelApi } from "zhi-siyuan-api"
import { ImageParser } from "./parser/ImageParser"
import { PicgoPostResult } from "./models/PicgoPostResult"
import { DeviceDetection, DeviceTypeEnum, SiyuanDevice } from "zhi-device"
import { IImgInfo } from "universal-picgo/src"
/**
* Picgo与文章交互的通用方法
*/
class SiyuanPicgoPostApi {
private readonly logger: ILogger
private readonly imageParser: ImageParser
public readonly siyuanApi: SiyuanKernelApi
private readonly siyuanConfig: SiyuanConfig
private readonly isSiyuanOrSiyuanNewWin: boolean
private readonly picgoApi: SiyuanPicGoUploadApi
public cfgUpdating: boolean
constructor(siyuanConfig: SiyuanConfig, isDev?: boolean) {
this.logger = simpleLogger("picgo-post-api", "zhi-siyuan-picgo", isDev)
this.imageParser = new ImageParser(isDev)
this.siyuanConfig = siyuanConfig
this.siyuanApi = new SiyuanKernelApi(siyuanConfig)
this.isSiyuanOrSiyuanNewWin = (() => {
const deviceType = DeviceDetection.getDevice()
// 三种情况,主窗口、挂件、新窗口
const isSiyuanOrSiyuanNewWin =
deviceType === DeviceTypeEnum.DeviceType_Siyuan_MainWin ||
deviceType === DeviceTypeEnum.DeviceType_Siyuan_RendererWin ||
deviceType === DeviceTypeEnum.DeviceType_Siyuan_Widget
this.logger.debug("deviceType=>", deviceType)
this.logger.debug("isSiyuanOrSiyuanNewWin=>", String(isSiyuanOrSiyuanNewWin))
return isSiyuanOrSiyuanNewWin
})()
// 初始化 PicGO
this.picgoApi = new SiyuanPicGoUploadApi(isDev)
this.cfgUpdating = false
this.updateConfig()
}
/**
* 内置 PicGo 上下文
*/
public ctx(): IPicGo {
return this.picgoApi.picgo
}
/**
* 上传图片到PicGO,此方法不会修改元数据
*
* @param input 路径数组,可为空,为空上传剪贴板
*/
public async originalUpload(input?: any[]): Promise<IImgInfo[] | Error> {
return this.picgoApi.upload(input)
}
/**
* 将字符串数组格式的图片信息转换成图片对象数组
*
* @param attrs 文章属性
* @param retImgs 字符串数组格式的图片信息
* @param imageBaseUrl - 本地图片前缀,一般是思源的地址
*/
public async doConvertImagesToImagesItemArray(
attrs: any,
retImgs: ParsedImage[],
imageBaseUrl?: string
): Promise<ImageItem[]> {
const ret = [] as ImageItem[]
for (let i = 0; i < retImgs.length; i++) {
const retImg = retImgs[i]
const originUrl = retImg.url
let imgUrl = retImg.url
// 获取属性存储的映射数据
let fileMap = {} as any
this.logger.debug("attrs=>", attrs)
if (!StrUtil.isEmptyString(attrs[SIYUAN_PICGO_FILE_MAP_KEY])) {
fileMap = JsonUtil.safeParse(attrs[SIYUAN_PICGO_FILE_MAP_KEY], {})
this.logger.debug("fileMap=>", fileMap)
}
// 处理思源本地图片预览
// 这个是从思源查出来解析的是否是本地
if (retImg.isLocal) {
const baseUrl = imageBaseUrl ?? this.siyuanConfig.apiUrl ?? ""
imgUrl = StrUtil.pathJoin(baseUrl, "/" + imgUrl)
}
const imageItem = new ImageItem(originUrl, imgUrl, retImg.isLocal, retImg.alt, retImg.title)
// fileMap 查出来的是是否上传,上传了,isLocal就false
if (fileMap[imageItem.hash]) {
const newImageItem = fileMap[imageItem.hash]
this.logger.debug("newImageItem=>", newImageItem)
if (!newImageItem.isLocal) {
imageItem.isLocal = false
imageItem.url = newImageItem.url
}
}
// imageItem.originUrl = decodeURIComponent(imageItem.originUrl)
// imageItem.url = decodeURIComponent(imageItem.url)
this.logger.debug("imageItem=>", imageItem)
ret.push(imageItem)
}
this.logger.debug("ret=>", ret)
return ret
}
// ===================================================================================================================
/**
* 上传当前文章图片到图床(单篇文档所有图片全部批量上传,提供给外部调用)
*
* @param pageId 文章ID
* @param attrs 文章属性
* @param mdContent 文章的Markdown文本
*/
public async uploadPostImagesToBed(pageId: string, attrs: any, mdContent: string): Promise<PicgoPostResult> {
const ret = new PicgoPostResult()
const localImages = this.imageParser.parseLocalImagesToArray(mdContent)
const uniqueLocalImages = [...new Set([...localImages])]
this.logger.debug("uniqueLocalImages=>", uniqueLocalImages)
if (uniqueLocalImages.length === 0) {
ret.flag = false
ret.hasImages = false
ret.mdContent = mdContent
ret.errmsg = "文章中没有图片"
return ret
}
// 开始上传
try {
ret.hasImages = true
const imageItemArray = await this.doConvertImagesToImagesItemArray(attrs, uniqueLocalImages)
const replaceMap = {} as any
let hasLocalImages = false
for (let i = 0; i < imageItemArray.length; i++) {
const imageItem = imageItemArray[i]
if (imageItem.originUrl.includes("assets")) {
replaceMap[imageItem.hash] = imageItem
}
if (!imageItem.isLocal) {
this.logger.debug("已经上传过图床,请勿重复上传=>", imageItem.originUrl)
continue
}
hasLocalImages = true
let newattrs: any
let isLocal: boolean
let newImageItem: ImageItem
try {
// 实际上传逻辑
await this.uploadSingleImageToBed(pageId, attrs, imageItem)
// 上传完成,需要获取最新链接
newattrs = await this.siyuanApi.getBlockAttrs(pageId)
isLocal = false
const newfileMap = JsonUtil.safeParse<any>(newattrs[SIYUAN_PICGO_FILE_MAP_KEY], {})
newImageItem = newfileMap[imageItem.hash]
} catch (e) {
newattrs = attrs
isLocal = true
newImageItem = imageItem
this.logger.warn("单个图片上传异常", { pageId, attrs, imageItem })
this.logger.warn("单个图片上传失败,错误信息如下", e)
}
// 无论成功失败都要保存元数据,失败了当做本地图片
replaceMap[imageItem.hash] = new ImageItem(
newImageItem.originUrl,
newImageItem.url,
isLocal,
newImageItem.alt,
newImageItem.title
)
}
if (!hasLocalImages) {
// ElMessage.info("未发现本地图片,不上传!若之前上传过,将做链接替换")
this.logger.warn("未发现本地图片,不上传!若之前上传过,将做链接替换")
}
// 处理链接替换
this.logger.debug("准备替换正文图片,replaceMap=>", JSON.stringify(replaceMap))
this.logger.debug("开始替换正文,原文=>", JSON.stringify({ mdContent }))
ret.mdContent = this.imageParser.replaceImagesWithImageItemArray(mdContent, replaceMap)
this.logger.debug("图片链接替换完成,新正文=>", JSON.stringify({ newmdContent: ret.mdContent }))
ret.flag = true
this.logger.debug("正文替换完成,最终结果=>", ret)
} catch (e: any) {
ret.flag = false
ret.errmsg = e.toString()
this.logger.error("文章图片上传失败=>", e)
}
return ret
}
/**
* 上传单张图片到图床(当前图片单个上传,提供给外部调用)
*
* @param pageId 文章ID
* @param attrs 文章属性
* @param imageItem 图片信息
* @param forceUpload 强制上传
*/
public async uploadSingleImageToBed(
pageId: string,
attrs: any,
imageItem: ImageItem,
forceUpload?: boolean
): Promise<void> {
const mapInfoStr = attrs[SIYUAN_PICGO_FILE_MAP_KEY] ?? "{}"
const fileMap = JsonUtil.safeParse<any>(mapInfoStr, {})
this.logger.debug("fileMap=>", fileMap)
// 处理上传
const filePaths = []
if (!forceUpload && !imageItem.isLocal) {
this.logger.warn("非本地图片,忽略=>", imageItem.url)
return
}
// 兼容剪贴板
let imageFullPath: string | Blob | File
// blob 或者 file 直接上传
if (isFileOrBlob(imageItem.url)) {
imageFullPath = imageItem.url
} else {
if (this.isSiyuanOrSiyuanNewWin) {
// 如果是路径解析路径
const win = SiyuanDevice.siyuanWindow()
const dataDir: string = win.siyuan.config.system.dataDir
imageFullPath = `${dataDir}/assets/${imageItem.name}`
this.logger.info(`Will upload picture from ${imageFullPath}, imageItem =>`, imageItem)
const fs = win.require("fs")
if (!fs.existsSync(imageFullPath)) {
// 路径不存在直接上传
imageFullPath = imageItem.url
}
} else {
// 浏览器环境直接上传
imageFullPath = imageItem.url
}
}
// noinspection SuspiciousTypeOfGuard
if (!imageFullPath || (typeof imageFullPath === "string" && imageFullPath.trim().length == 0)) {
this.logger.warn("upload from clipboard")
} else {
filePaths.push(imageFullPath)
}
this.logger.warn("start uploading =>", filePaths)
// 批量上传
const imageJson: any = await this.originalUpload(filePaths)
this.logger.debug("图片上传完成,imageJson=>", imageJson)
const imageJsonObj = JsonUtil.safeParse(imageJson, []) as any
// 处理后续
if (imageJsonObj && imageJsonObj.length > 0) {
const img = imageJsonObj[0]
if (!img?.imgUrl || StrUtil.isEmptyString(img.imgUrl)) {
throw new Error(
"图片上传失败,可能原因:PicGO配置错误或者该平台不支持图片覆盖,请检查配置或者尝试上传新图片。请打开picgo.log查看更多信息"
)
}
const newImageItem = new ImageItem(imageItem.originUrl, img.imgUrl, false, imageItem.alt, imageItem.title)
fileMap[newImageItem.hash] = newImageItem
} else {
throw new Error("图片上传失败,可能原因:PicGO配置错误,请检查配置。请打开picgo.log查看更多信息")
}
this.logger.debug("newFileMap=>", fileMap)
const newFileMapStr = JSON.stringify(fileMap)
await this.siyuanApi.setBlockAttrs(pageId, {
[SIYUAN_PICGO_FILE_MAP_KEY]: newFileMapStr,
})
return imageJsonObj
}
// ===================================================================================================================
private updateConfig() {
// 迁移旧插件配置
let legacyCfgfolder = ""
// 初始化思源 PicGO 配置
const workspaceDir = win?.siyuan?.config?.system?.workspaceDir ?? ""
if (hasNodeEnv && workspaceDir !== "") {
const path = win.require("path")
legacyCfgfolder = path.join(workspaceDir, "data", "storage", "syp", "picgo")
// 如果新插件采用了不同的目录,需要迁移旧插件 node_modules 文件夹
if (legacyCfgfolder !== this.picgoApi.picgo.baseDir) {
void this.moveFile(legacyCfgfolder, this.picgoApi.picgo.baseDir)
}
}
// 旧的配置位置
// [工作空间]/data/storage/syp/picgo/picgo.cfg.json
// [工作空间]/data/storage/syp/picgo/package.json
// [工作空间]/data/storage/syp/picgo/mac.applescript
// [工作空间]/data/storage/syp/picgo/i18n-cli
// [工作空间]/data/storage/syp/picgo/picgo-clipboard-images
//
// 新配置位置
// ~/.universal-picgo
}
private async moveFile(from: string, to: string) {
const fs = win.fs
const existFrom = fs.existsSync(from)
const existTo = fs.existsSync(to)
if (!existFrom) {
return
}
// 存在旧文件采取迁移
this.cfgUpdating = true
this.logger.info(`will move ${from} to ${to}`)
// 目的地存在复制
if (existTo) {
this.copyFolder(from, to)
.then(() => {
this.cfgUpdating = false
})
.catch((e: any) => {
this.cfgUpdating = false
this.logger.error(`copy ${from} to ${to} failed: ${e}`)
})
} else {
// 不存在移动过去
fs.promises
.rename(from, to)
.then(() => {
this.cfgUpdating = false
})
.catch((e: any) => {
this.cfgUpdating = false
this.logger.error(`move ${from} to ${to} failed: ${e}`)
})
}
}
private async copyFolder(from: string, to: string) {
const fs = win.fs
const path = win.require("path")
const files = await fs.promises.readdir(from)
for (const file of files) {
if (file.startsWith(".")) {
continue
}
const sourcePath = path.join(from, file)
const destPath = path.join(to, file)
const stats = await fs.promises.lstat(sourcePath)
if (stats.isDirectory()) {
await fs.promises.mkdir(destPath, { recursive: true })
// 递归复制子文件夹
await this.copyFolder(sourcePath, destPath)
} else {
await fs.promises.copyFile(sourcePath, destPath)
}
}
// 删除源文件夹
await fs.promises.rmdir(from, { recursive: true })
}
}
export { SiyuanPicgoPostApi }