diff --git a/package.json b/package.json index 4ef6d19..65b02dd 100644 --- a/package.json +++ b/package.json @@ -12,19 +12,25 @@ "author": "", "license": "MIT", "devDependencies": { + "@types/lodash-es": "^4.17.12", "@types/node": "^16.11.6", "@types/object-path": "^0.11.4", + "@types/qs": "^6.9.16", "@typescript-eslint/eslint-plugin": "5.29.0", "@typescript-eslint/parser": "5.29.0", "builtin-modules": "3.3.0", + "date-fns": "^4.1.0", "esbuild": "0.17.3", "esbuild-copy-static-files": "^0.1.0", + "file-type-checker": "^1.1.2", + "lodash-es": "^4.17.21", + "object-path": "^0.11.8", "obsidian": "latest", "tslib": "2.4.0", "typescript": "4.7.4" }, "dependencies": { "axios": "^1.7.7", - "object-path": "^0.11.8" + "qs": "^6.13.0" } } diff --git a/src/config.ts b/src/config.ts index a21cfc6..d619c8c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,19 +4,44 @@ const config = { text: 'Lskypro', value: 'lsky', }, + { + text: 'Halo', + value: 'halo', + }, { text: 'Github', value: 'github', }, ], - reqHeader: { - "Authorization": "Bearer Jq7mTKdjN7BxMjLoG8Jj8eaGGV2GuurwumOGFCBm", - "Accept": "application/json", - "Content-Type": "multipart/form-data" + lskySetting: { + "apiURL": "https://img.elinzy.com/api/v1/upload", + "apiReqHeader": { + "Authorization": "Bearer Jq7mTKdjN7BxMjLoG8Jj8eaGGV2GuurwumOGFCBm", + "Accept": "application/json", + "Content-Type": "multipart/form-data" + }, + "apiReqBody": { + "file": "$FILE", + "strategy_id": 2 + }, + "imgUrlPath": "data.pathname", + "imgUrlPrefix": "https://blog.elinzy.com/upload/eblog/" }, - reqBody: { - "file": "$FILE", - "strategy_id": 2 + haloSetting: { + // See: https://api.halo.run/#/ + "apiURL": "https://blog.elinzy.com/apis/api.console.halo.run/v1alpha1/attachments/upload", + "apiReqHeader": { + "Authorization": "Bearer pat_eyJraWQiOiI1SG9MUWVvUW10bV9zd2JISFRSODFEd1dUWVNyUUlyc0N3NjE2R1dQTXpnIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2Jsb2cuZWxpbnp5LmNvbS8iLCJzdWIiOiJhZG1pbiIsImlhdCI6MTcxMTk1NjY5MiwianRpIjoiN2E5NDY3NjEtY2QzYi1kODc4LTJkOGMtZWQxNmUwZDk4NzFjIiwicGF0X25hbWUiOiJwYXQtYWRtaW4tbkdnZG0ifQ.cl7n0f9SqP5qwYUW2ZM6DL6Ex6Xh9GvxL_hwEDJuUn8tlyuzOhcjUqkGSCO1D8E0lqym7RtV7xDQzSLkQybKbSwRqeMEDbLbdLMDpzD2Vu1aptz5m0ifsm9pvpm2Wqnn_px68arhqHqmMwP-8N9F1m-fMu1ovBuXI2gzHA56Ne1yWsSObeasxqNP1CNsez_abdv0fPKxbBsV_2JrQeHYc0y90M8V0WZ9d3_zdrgOMg6Hm481VprrHUwB9nxrsVsCxd_ROwf93ZhGo_VPV5YG8_Pz4CwRGzkIABVHO1leUXyMD24nOpG9csGo-mauuik7oi1h6eLUMi2902qKFUctwRsF4lKQSP6h2Li9UUt0FYQp78uaTDbke3eAxLq1nqWfOn3Lplpe1tz8MPXtZGclx2xz4zkQWs5TdOU4cuukMIJoXfc1VCbAR9AuaG1BVMZYnL-scgg9I9GNHjtcfgFMskUaEKQlyYTNiIweaqQHMlBHvdxJjCMko9jdB2qnMNw9cs2QfMreK278A6ucHMOB9WGVh0thpor1gk2MvAcV3k-ai21BKUdOLooxftdXbI7V9xif_fLEZwFvtf2RtRqChJpXLyGS2-xoOwg00kz0khckv43vntSCPZ8pqHnTAWsy_ssf80WOajs2guY25l04PXAshEGndvziB0Aod3AoXdY", + "Content-Type": "multipart/form-data", + "Host": "blog.elinzy.com", + }, + "apiReqBody": { + "file": "$FILE", + "policyName": "attachment-policy-gUpSV", // 策略 + "groupName": "attachment-group-JWlvm" // 分组 + }, + "imgUrlPath": "data.metadata.annotations['storage.halo.run/uri']", + "imgUrlPrefix": "https://blog.elinzy.com/" } } diff --git a/src/haloUpload.ts b/src/haloUpload.ts new file mode 100644 index 0000000..492dcde --- /dev/null +++ b/src/haloUpload.ts @@ -0,0 +1,86 @@ +// Defaults +import objectPath from "object-path"; +import fileTypeChecker from 'file-type-checker'; + +// Types +import { IUploader, IUploadPluginSettings} from "./type"; + +// Utils +import { RestClient } from './utils/rest-client'; +import { FormItems, Media, fileToArrayBuffer } from './utils/utils'; +import {Notice} from "obsidian"; + +// Class +class HaloUpload implements IUploader { + private readonly settings: IUploadPluginSettings; + private readonly client: RestClient; + + constructor(settings: IUploadPluginSettings) { + this.settings = settings; + + this.client = new RestClient(); + } + + async upload(file: File, options?: any): Promise { + return new Promise(async (resolve, reject) => { + const uploadBody = JSON.parse(this.settings.haloSetting.apiReqBody); + + const [buf, filename] = await fileToArrayBuffer(file); + const fileType = fileTypeChecker.detectFile(buf); + const media: Media = { + mimeType: fileType?.mimeType ?? 'application/octet-stream', + fileName: filename, + content: buf + } + + const formItems = new FormItems(); + for (const key in uploadBody) { + if (uploadBody[key] == "$FILE") { + formItems.append(key, media) + } + else { + formItems.append(key, uploadBody[key]) + } + } + + // NOTE: Obsidian 是基于 Electron 开发的,对于请求 Halo 的接口来说相当于是在浏览器上请求的,所以存在跨域问题。 + // See: https://github.com/halo-sigs/obsidian-halo/issues/2 + // axios.post(this.settings.haloSetting.apiURL, formData, { + // "headers": JSON.parse(this.settings.haloSetting.apiReqHeader), + // withCredentials: true, + // }).then(res => { + // const url = [this.settings.haloSetting.imgUrlPrefix, objectPath.get(res.data, this.settings.haloSetting.imgUrlPath)].join('') + // + // resolve(url) + // }, err => { + // reject(err) + // }) + + this.client.httpPost( + this.settings.haloSetting.apiURL, + formItems, + { + headers: JSON.parse(this.settings.haloSetting.apiReqHeader), + } + ) + .then((res: {}) => { + const url = [ + this.settings.haloSetting.imgUrlPrefix, + objectPath.get(res, JSON.parse(this.settings.haloSetting.imgUrlPath)) + ].join(''); + + resolve(url) + }) + .catch(err => { + reject(err) + }); + }) + } + + // + drawSettingsTab(parentEL: HTMLDivElement) { + + } +} + +export default HaloUpload; diff --git a/src/lskyUpload.ts b/src/lskyUpload.ts index fcc9c47..40ddc15 100644 --- a/src/lskyUpload.ts +++ b/src/lskyUpload.ts @@ -1,26 +1,27 @@ // Defaults -import { IUploadPluginSettings } from "./type"; +import { IUploadPluginSettings, IUploader } from "./type"; import axios from "axios"; import objectPath from "object-path"; +import {Notice} from "obsidian"; -class LskyUpload { +class LskyUpload implements IUploader { private readonly settings: IUploadPluginSettings; constructor(settings: IUploadPluginSettings) { this.settings = settings; } - async upload(file: File, fullPath?: string): Promise { + async upload(file: File): Promise { return new Promise((resolve, reject) => { const formData = new FormData(); const uploadBody = JSON.parse(this.settings.lskySetting.apiReqBody); for (const key in uploadBody) { if (uploadBody[key] == "$FILE") { - formData.append(key, file, file.name) + formData.append(key, file, file.name); } else { - formData.append(key, uploadBody[key]) + formData.append(key, uploadBody[key]); } } diff --git a/src/main.ts b/src/main.ts index ceca178..1dfd8fa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,4 @@ import { - App, Editor, MarkdownView, Notice, @@ -44,7 +43,14 @@ const DEFAULT_SETTINGS: IUploadPluginSettings = { apiReqBody: "{\"file\": \"$FILE\"}", imgUrlPath: "", imgUrlPrefix: "", - } + }, + haloSetting: { + apiURL: "", + apiReqHeader: "", + apiReqBody: "{\"file\": \"$FILE\"}", + imgUrlPath: "", + imgUrlPrefix: "", + }, } class UploadPlugin extends Plugin { @@ -63,7 +69,9 @@ class UploadPlugin extends Plugin { // 赋值 this.helper = new Helper(this.app); - this.uploader = uploader(this.settings); + + // 初始化设置 uploader + this.setupUploader(); // 图标 addIcon( @@ -150,7 +158,7 @@ class UploadPlugin extends Plugin { // 自定义方法 // 右键菜单上传文件 - async menuUploadFile(file: TFile) { + async menuUploadFileTest(file: TFile) { new Notice('准备上传'); let content = this.helper.getValue(); @@ -232,6 +240,93 @@ class UploadPlugin extends Plugin { }); } + async menuUploadFile(file: TFile) { + if (!file) { + new Notice('不合法文件,请重新尝试'); + return; + } + if (!this.uploader) { + new Notice("未配置上传器,请检查设置"); + return; + } + + new Notice('准备上传'); + + let content = this.helper.getValue(); + const basePath = this.helper.getBasePath(); + const fileArray = this.helper.getAllFiles(); + + let imageList: IImage[] = []; + + // 查找文档中相同路径文件 + for (const match of fileArray) { + const imageName = match.name; + const encodedUri = match.path; + + const fileName = basename(decodeURI(encodedUri)); + + if (file && file.name === fileName) { + const abstractImageFile = join(basePath, file.path); + + if (isAssetTypeAnImage(abstractImageFile)) { + imageList.push({ + path: abstractImageFile, + name: imageName, + source: match.source, + }); + } + } + } + + if (imageList.length === 0) { + new Notice("没有匹配到文件"); + return; + } + + const files = []; + + // 通过文件路径读取文件,并转化为文件格式 + for (let i = 0; i < imageList.length; i++) { + const { path, name } = imageList[i]; + const fileName = basename(decodeURI(path)); + const buffer: Buffer = await new Promise((resolve, reject) => { + readFile(path, (err, data) => { + if (err) { + reject(err); + } + resolve(data); + }); + }); + const arrayBuffer = bufferToArrayBuffer(buffer); + files.push(new File([arrayBuffer], fileName)); + } + + if (files.length === 0) { + new Notice("文件读取失败"); + return; + } + + // 通过uploader上传文件 + this.uploader.upload(files[0]) + .then(res => { + // 文件上传成功后,替换文档中相同路径文件的link + imageList.map(item => { + content = content.replaceAll( + item.source, + `![](${res})` + ); + }); + + // 更新文档 + this.helper.setValue(content); + + new Notice("上传成功"); + }) + .catch(err => { + new Notice(`上传失败: ${err}`); + }); + } + // 注册右键菜单事件 registerMenuEvent() { this.registerEvent( @@ -256,6 +351,15 @@ class UploadPlugin extends Plugin { ) ); } + + // 设置 uploader + setupUploader() { + try { + this.uploader = uploader(this.settings); + } catch (e) { + console.log(`设置 Uploader 失败: ${e}`); + } + } } export default UploadPlugin; diff --git a/src/settings-tab.ts b/src/settings-tab.ts index 46d83af..2fff63f 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -60,8 +60,9 @@ class UploadSettingsTab extends PluginSettingTab { e.setValue(this.plugin.settings.mode) e.onChange(async value => { this.plugin.settings.mode = value; + // NOTE: 重新设置uploader + this.plugin.setupUploader(); await this.plugin.saveSettings(); - await this.drawModeSettings(this.modeDiv, value); }) }) @@ -76,6 +77,9 @@ class UploadSettingsTab extends PluginSettingTab { case 'lsky': this.drawLskySettings(parentEl); break; + case 'halo': + this.drawHaloSettings(parentEl); + break; case 'github': this.drawGithubSettings(parentEl); break; @@ -186,6 +190,103 @@ class UploadSettingsTab extends PluginSettingTab { }) } + // Halo 设置 + private drawHaloSettings(parentEL: HTMLDivElement) { + // Api URL + new Setting(parentEL) + .setName("API 地址") + .setDesc("请求地址") + .addText(text => { + text + .setPlaceholder("") + .setValue(this.plugin.settings.haloSetting.apiURL) + .onChange(async (value) => { + try { + this.plugin.settings.haloSetting.apiURL = value; + await this.plugin.saveSettings(); + } + catch (e) { + console.log(e) + } + }) + }) + + // Api request header + new Setting(parentEL) + .setName("POST Header") + .setDesc("请求头,json 格式") + .addTextArea((text) => { + text.setPlaceholder("输入合法的请求头") + .setValue(this.plugin.settings.haloSetting.apiReqHeader) + .onChange(async (value) => { + try { + this.plugin.settings.haloSetting.apiReqHeader = value; + await this.plugin.saveSettings(); + } + catch (e) { + console.log(e) + } + }) + text.inputEl.rows = 5 + text.inputEl.cols = 40 + }) + + // Api request body + new Setting(parentEL) + .setName("POST Body") + .setDesc("请求体,json 格式") + .addTextArea((text) => { + text.setPlaceholder("输入合法的请求体") + .setValue(this.plugin.settings.haloSetting.apiReqBody) + .onChange(async (value) => { + try { + this.plugin.settings.haloSetting.apiReqBody = value; + await this.plugin.saveSettings(); + } + catch (e) { + console.log(e) + } + }) + text.inputEl.rows = 5 + text.inputEl.cols = 40 + }) + new Setting(parentEL) + .setName("图片 URL 路径") + .setDesc("返回json数据中的图片URL字段的路径,如:data/pathname") + .addText(text => { + text + .setPlaceholder("") + .setValue(this.plugin.settings.haloSetting.imgUrlPath) + .onChange(async (value) => { + try { + this.plugin.settings.haloSetting.imgUrlPath = value; + await this.plugin.saveSettings(); + } + catch (e) { + console.log(e) + } + }) + }) + + new Setting(parentEL) + .setName("图片 URL 前缀") + .setDesc("可选,当填入时,URL = 前缀 + 图片的路径值") + .addText(text => { + text + .setPlaceholder("") + .setValue(this.plugin.settings.haloSetting.imgUrlPrefix) + .onChange(async (value) => { + try { + this.plugin.settings.haloSetting.imgUrlPrefix = value; + await this.plugin.saveSettings(); + } + catch (e) { + console.log(e) + } + }) + }) + } + // Github 设置 private drawGithubSettings(parentEL: HTMLDivElement) {} } diff --git a/src/type.ts b/src/type.ts index cf57e13..a3e8abb 100644 --- a/src/type.ts +++ b/src/type.ts @@ -22,8 +22,16 @@ export interface IUploadPluginSettings { imgUrlPrefix: string; lskySetting: ILskySettings; + haloSetting: ILskySettings; } export interface IUploader { - upload(file: File, fullPath?: string): Promise; + upload(file: File | IMedia, options?: Record): Promise; } + +export interface IMedia { + mimeType: string; + fileName: string; + content: ArrayBuffer; +} +export type SafeAny = any; // eslint-disable-line @typescript-eslint/no-explicit-any diff --git a/src/uploader.ts b/src/uploader.ts index c5973dd..003aee7 100644 --- a/src/uploader.ts +++ b/src/uploader.ts @@ -1,4 +1,5 @@ import LskyUpload from "./lskyUpload"; +import HaloUpload from "./haloUpload"; // Types import { IUploadPluginSettings, IUploader } from './type'; @@ -7,6 +8,8 @@ function Uploader(settings: IUploadPluginSettings): IUploader { switch (settings.mode) { case 'lsky': return new LskyUpload(settings); + case 'halo': + return new HaloUpload(settings); default: throw new Error('should not reach here!') } diff --git a/src/utils/helper.ts b/src/utils/helper.ts index bec9534..0f6c7e5 100644 --- a/src/utils/helper.ts +++ b/src/utils/helper.ts @@ -1,4 +1,4 @@ -import { MarkdownView, App } from "obsidian"; +import { MarkdownView, App, TFile, FileSystemAdapter } from "obsidian"; import { parse } from "path"; interface Image { @@ -119,4 +119,14 @@ export default class Helper { return blackDomainList.some(blackDomain => domain.includes(blackDomain)); } + + async readBinary(file: TFile) { + return this.app.vault.readBinary(file); + } + + getBasePath() { + return ( + this.app.vault.adapter as FileSystemAdapter + ).getBasePath() + } } diff --git a/src/utils/rest-client.ts b/src/utils/rest-client.ts new file mode 100644 index 0000000..f7986be --- /dev/null +++ b/src/utils/rest-client.ts @@ -0,0 +1,81 @@ +import { requestUrl } from 'obsidian'; +import { getBoundary, FormItems, type FormItemNameMapper } from './utils'; + +interface RestOptions { + url: URL; +} + +export class RestClient { + constructor( + private readonly options?: RestOptions + ) { + console.log(options); + } + + async httpGet( + url: string, + options?: { + headers: Record + } + ): Promise { + const endpoint = url; + const opts = { + headers: {}, + ...options + }; + console.log('REST GET', endpoint, opts); + const response = await requestUrl({ + url: endpoint, + method: 'GET', + headers: { + 'content-type': 'application/json', + 'user-agent': 'obsidian.md', + ...opts.headers + } + }); + console.log('GET response', response); + return response.json; + } + + async httpPost( + url: string, + body: any, + options?: { + headers: Record; + formItemNameMapper?: FormItemNameMapper; + } + ): Promise { + const endpoint = url; + const predefinedHeaders: Record = {}; + let requestBody: any; + + if (body instanceof FormItems) { + const boundary = getBoundary(); + requestBody = await body.toArrayBuffer({ + boundary, + nameMapper: options?.formItemNameMapper + }); + predefinedHeaders['content-type'] = `multipart/form-data; boundary=${boundary}`; + } else if (body instanceof ArrayBuffer) { + requestBody = body; + } else { + requestBody = JSON.stringify(body); + predefinedHeaders['content-type'] = 'application/json'; + } + + const response = await requestUrl({ + url: endpoint, + method: 'POST', + headers: { + 'user-agent': 'obsidian.md', + ...predefinedHeaders, + ...options?.headers + }, + body: requestBody + }); + + console.log('POST response', response); + return response.json; + + } +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 3c3d4e3..23c8319 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,4 +1,8 @@ -import { resolve, extname, relative, join, parse, posix } from "path"; +import { resolve, extname, relative, join, parse, posix, basename} from "path"; +import { format } from 'date-fns'; +import { isArray, isFunction, isString, template } from 'lodash-es'; +import { SafeAny, IMedia } from "../type"; + const IMAGE_EXT_LIST = [ ".png", ".jpg", @@ -11,14 +15,30 @@ const IMAGE_EXT_LIST = [ ".avif", ]; -export function isAnImage(ext: string) { - return IMAGE_EXT_LIST.includes(ext.toLowerCase()); -} - -export function isAssetTypeAnImage(path: string): Boolean { - return isAnImage(extname(path)); +/** + * File 转 ArrayBuffer + * @param file + */ +export async function fileToArrayBuffer(file: File) { + return new Promise((resolve, reject) => { + const fr = new FileReader(); + const filename = file.name; + + fr.readAsArrayBuffer(file); + fr.addEventListener("loadend",(e: any) => { + const buf = e.target.result; + resolve([buf, filename]); + },false); + fr.addEventListener("error", (e: any) => { + reject(e); + }) + }) } +/** + * buffer 转 ArrayBuffer + * @param buffer + */ export function bufferToArrayBuffer(buffer: Buffer) { const arrayBuffer = new ArrayBuffer(buffer.length); const view = new Uint8Array(arrayBuffer); @@ -27,3 +47,147 @@ export function bufferToArrayBuffer(buffer: Buffer) { } return arrayBuffer; } + +/** + * 生成 boundary + */ +export function getBoundary(): string { + return `----obsidianBoundary${format(new Date(), 'yyyyMMddHHmmss')}`; +} + +/** + * 检验图片是否合法 + * @param ext + */ +export function isAnImage(ext: string) { + return IMAGE_EXT_LIST.includes(ext.toLowerCase()); +} + +/** + * 检验图片路径是否合法 + * @param path + */ +export function isAssetTypeAnImage(path: string): Boolean { + return isAnImage(extname(path)); +} + + +type UrlGetter = () => string; + +/** + * 获取 URL + * @param url + * @param defaultValue + * @param params + */ +export function getUrl( + url: string | UrlGetter | undefined, + defaultValue: string, + params?: { [p: string]: string | number } +): string { + let resultUrl: string; + if (isString(url)) { + resultUrl = url; + } else if (isFunction(url)) { + resultUrl = url(); + } else { + resultUrl = defaultValue; + } + if (params) { + const compiled = template(resultUrl); + return compiled(params); + } else { + return resultUrl; + } +} + +/** + * 检测是否是合法的Media + * @param obj + */ +export function isMedia(obj: any): obj is IMedia { + return ( + typeof obj === 'object' + && obj !== null + && 'mimeType' in obj && typeof obj.mimeType === 'string' + && 'fileName' in obj && typeof obj.fileName === 'string' + && 'content' in obj && obj.content instanceof ArrayBuffer + ); +} + +export interface Media { + mimeType: string; + fileName: string; + content: ArrayBuffer; +} + +/** + * Convert original item name to custom one. + * + * @param name original item name. If `isArray` is `true`, which means is in an array, the `name` will be appended by `[]` + * @param isArray whether this item is in an array + */ +export type FormItemNameMapper = (name: string, isArray: boolean) => string; + +export class FormItems { + #formData: Record = {}; + + append(name: string, data: string): FormItems; + append(name: string, data: IMedia): FormItems; + append(name: string, data: string | IMedia): FormItems { + const existing = this.#formData[name]; + if (existing) { + this.#formData[name] = [ existing ]; + this.#formData[name].push(data); + } else { + this.#formData[name] = data; + } + return this; + } + + toArrayBuffer(option: { + boundary: string; + nameMapper?: FormItemNameMapper; + }): Promise { + const CRLF = '\r\n'; + const itemPart = (name: string, data: string | IMedia, isArray: boolean) => { + let itemName = name; + if (option.nameMapper) { + itemName = option.nameMapper(name, isArray); + } + + body.push(encodedItemStart); + if (isString(data)) { + body.push(encoder.encode(`Content-Disposition: form-data; name="${itemName}"${CRLF}${CRLF}`)); + body.push(encoder.encode(data)); + } else { + const media = data; + body.push(encoder.encode(`Content-Disposition: form-data; name="${itemName}"; filename="${media.fileName}"${CRLF}Content-Type: ${media.mimeType}${CRLF}${CRLF}`)); + body.push(media.content); + } + body.push(encoder.encode(CRLF)); + }; + + const encoder = new TextEncoder(); + const encodedItemStart = encoder.encode(`--${option.boundary}${CRLF}`); + const body: ArrayBuffer[] = []; + Object.entries(this.#formData).forEach(([ name, data ]) => { + if (isArray(data)) { + data.forEach(item => { + itemPart(`${name}[]`, item, true); + }); + } else { + itemPart(name, data, false); + } + }); + body.push(encoder.encode(`--${option.boundary}--`)); + return new Blob(body).arrayBuffer(); + } +} + +export function formItemNameMapper(name: string, isArray: boolean): string { + if (name === 'file' && !isArray) { + return 'media[]'; + } + return name; +}