feat(halo): 添加图片自动上传功能

- 新增图片处理工具模块 `src/utils/image.ts`,包含图片引用提取、绝对路径解析和路径替换功能
- 新增图片上传服务 `src/service/image-uploader.ts`,支持调用 Halo 媒体 API 上传图片并实现缓存机制
- 在设置界面添加图片上传开关和上传路径配置项
- 更新发布流程,在提交到 Halo 前自动检测并上传本地图片,替换为远程 URL
- 添加英文、简体中文和繁体中文的国际化文案
- 更新插件版本至 1.1.1 并完善相关配置文件
This commit is contained in:
2026-04-26 16:11:11 +08:00
commit 7d332d3b8c
213 changed files with 128271 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
import * as en from "./locales/en.json";
import * as zhCN from "./locales/zh-cn.json";
import * as zhTW from "./locales/zh-tw.json";
export const resources = {
en: { translation: en },
"zh-CN": { translation: zhCN },
"zh-TW": { translation: zhTW },
} as const;
+114
View File
@@ -0,0 +1,114 @@
{
"ribbon_icon": {
"publish": "Publish current document to Halo"
},
"command": {
"publish": {
"name": "Publish to Halo",
"error_no_matched_site": "The site this document publishes to is not configured"
},
"publish_with_defaults": {
"name": "Publish to Halo (use default settings)",
"error_no_default_site": "Please configure the default site first"
},
"update_post": {
"name": "Update content from Halo",
"error_not_published": "This document is not published to Halo yet",
"error_no_matched_site": "The site this document publishes to is not configured",
"success": "Updated"
},
"pull_post": {
"name": "Pull posts from Halo",
"error_no_sites": "Please configure sites first"
}
},
"settings": {
"title": "Halo publishing settings",
"site": {
"name": "Halo sites",
"description": "Halo site management, supports multiple sites",
"actions": {
"open": "Open"
}
},
"publishByDefault": {
"name": "Publish post by default",
"description": "After checking, the first post created will be published directly"
},
"imageUpload": {
"title": "Image Upload Settings",
"enabled": {
"name": "Enable image upload",
"description": "Automatically upload local images to Halo when publishing"
},
"uploadPath": {
"name": "Upload path",
"description": "Media path for uploading images to Halo, leave empty for root directory"
},
"preserveOriginal": {
"name": "Preserve original path",
"description": "Also keep local image path as fallback"
}
}
},
"post_selection_modal": {
"title": "Pull posts from Halo",
"button_pull": "Pull"
},
"site_editing_modal": {
"title": "Halo site",
"settings": {
"name": {
"name": "Site name",
"description": "Halo site name"
},
"url": {
"name": "Site URL",
"description": "Halo site URL"
},
"token": {
"name": "Personal Access Token",
"description": "Can be created in user profile, need permissions for managing posts"
},
"default": {
"name": "Set as default",
"description": "Set as default publishing site"
},
"validate": {
"button": "Validate",
"button_validating": "Validating...",
"notice_validated": "Validation succeeded",
"error_no_permissions": "Current account has no permissions for managing posts"
},
"save": {
"button": "Save"
}
}
},
"site_selection_modal": {
"title": "Choose a Halo site",
"button_choose": "Choose"
},
"sites_modal": {
"title": "Halo sites",
"actions": {
"set_default": "Set as default",
"edit": "Edit",
"add": "Add"
}
},
"service": {
"error_site_not_match": "Site URL does not match",
"error_publish_failed": "Publishing failed, please retry",
"notice_publish_success": "Published successfully",
"error_not_published": "This document is not published to Halo yet",
"error_post_not_found": "Post does not exist",
"image_upload_success": "Image uploaded successfully",
"image_upload_failed": "Image upload failed",
"image_uploading": "Uploading image..."
},
"common": {
"error_connection_failed": "Connection failed",
"button_close": "Close"
}
}
+114
View File
@@ -0,0 +1,114 @@
{
"ribbon_icon": {
"publish": "发布当前文档到 Halo"
},
"command": {
"publish": {
"name": "发布到 Halo",
"error_no_matched_site": "此文档发布到的站点未配置"
},
"publish_with_defaults": {
"name": "发布到 Halo(使用默认配置)",
"error_no_default_site": "请先配置默认站点"
},
"update_post": {
"name": "从 Halo 更新内容",
"error_not_published": "此文档还未发布到 Halo",
"error_no_matched_site": "此文档发布到的站点未配置",
"success": "已更新"
},
"pull_post": {
"name": "从 Halo 拉取文档",
"error_no_sites": "请先配置站点"
}
},
"settings": {
"title": "Halo 发布设置",
"site": {
"name": "Halo 站点",
"description": "Halo 站点管理,支持设置多个",
"actions": {
"open": "打开"
}
},
"publishByDefault": {
"name": "默认发布文章",
"description": "勾选之后,首次创建文章将直接发布"
},
"imageUpload": {
"title": "图片上传设置",
"enabled": {
"name": "启用图片上传",
"description": "发布时自动将本地图片上传到 Halo"
},
"uploadPath": {
"name": "上传路径",
"description": "图片上传到 Halo 的媒体路径,留空表示根目录"
},
"preserveOriginal": {
"name": "保留原路径",
"description": "同时保留本地图片路径作为备选"
}
}
},
"post_selection_modal": {
"title": "从 Halo 拉取文章",
"button_pull": "拉取"
},
"site_editing_modal": {
"title": "Halo 站点",
"settings": {
"name": {
"name": "站点名称",
"description": "Halo 的站点名称"
},
"url": {
"name": "站点地址",
"description": "Halo 的站点地址"
},
"token": {
"name": "个人令牌",
"description": "可以在个人资料中创建,需要包含文章管理的相关权限"
},
"default": {
"name": "是否设置为默认",
"description": "设置为默认的发布站点"
},
"validate": {
"button": "验证",
"button_validating": "验证中...",
"notice_validated": "验证成功",
"error_no_permissions": "当前账号没有文章管理权限"
},
"save": {
"button": "保存"
}
}
},
"site_selection_modal": {
"title": "选择一个 Halo 站点",
"button_choose": "选择"
},
"sites_modal": {
"title": "Halo 站点",
"actions": {
"set_default": "设为默认",
"edit": "编辑",
"add": "添加"
}
},
"service": {
"error_site_not_match": "站点地址不匹配",
"error_publish_failed": "发布失败,请重试",
"notice_publish_success": "发布成功",
"error_not_published": "此文档还未发布到 Halo",
"error_post_not_found": "文章不存在",
"image_upload_success": "图片上传成功",
"image_upload_failed": "图片上传失败",
"image_uploading": "正在上传图片..."
},
"common": {
"error_connection_failed": "连接失败",
"button_close": "关闭"
}
}
+114
View File
@@ -0,0 +1,114 @@
{
"ribbon_icon": {
"publish": "發佈當前文件到 Halo"
},
"command": {
"publish": {
"name": "發佈到 Halo",
"error_no_matched_site": "此文件發佈到的網站未配置"
},
"publish_with_defaults": {
"name": "發佈到 Halo(使用默認配置)",
"error_no_default_site": "請先配置默認網站"
},
"update_post": {
"name": "從 Halo 更新內容",
"error_not_published": "此文件還未發佈到 Halo",
"error_no_matched_site": "此文件發佈到的網站未配置",
"success": "已更新"
},
"pull_post": {
"name": "從 Halo 拉取文件",
"error_no_sites": "請先配置網站"
}
},
"settings": {
"title": "Halo 發佈設置",
"site": {
"name": "Halo 網站",
"description": "Halo 網站管理,支持設置多個",
"actions": {
"open": "打開"
}
},
"publishByDefault": {
"name": "默认發布文章",
"description": "勾選之後,首次建立文章將直接發布"
},
"imageUpload": {
"title": "圖片上傳設置",
"enabled": {
"name": "啟用圖片上傳",
"description": "發布時自動將本地圖片上傳到 Halo"
},
"uploadPath": {
"name": "上傳路徑",
"description": "圖片上傳到 Halo 的媒體路徑,留空表示根目錄"
},
"preserveOriginal": {
"name": "保留原路徑",
"description": "同時保留本地圖片路徑作為備選"
}
}
},
"post_selection_modal": {
"title": "從 Halo 拉取文章",
"button_pull": "拉取"
},
"site_editing_modal": {
"title": "Halo 網站",
"settings": {
"name": {
"name": "網站名稱",
"description": "Halo 的網站名稱"
},
"url": {
"name": "網站地址",
"description": "Halo 的網站地址"
},
"token": {
"name": "個人密鑰",
"description": "可以在個人資料中創建,需要包含文章管理的相關權限"
},
"default": {
"name": "是否設置為默認",
"description": "設置為默認的發佈網站"
},
"validate": {
"button": "驗證",
"button_validating": "驗證中...",
"notice_validated": "驗證成功",
"error_no_permissions": "當前帳號沒有文章管理權限"
},
"save": {
"button": "保存"
}
}
},
"site_selection_modal": {
"title": "選擇一個 Halo 網站",
"button_choose": "選擇"
},
"sites_modal": {
"title": "Halo 網站",
"actions": {
"set_default": "設為默認",
"edit": "編輯",
"add": "添加"
}
},
"service": {
"error_site_not_match": "網站地址不匹配",
"error_publish_failed": "發佈失敗,請重試",
"notice_publish_success": "發佈成功",
"error_not_published": "此文件還未發佈到 Halo",
"error_post_not_found": "文章不存在",
"image_upload_success": "圖片上傳成功",
"image_upload_failed": "圖片上傳失敗",
"image_uploading": "正在上傳圖片..."
},
"common": {
"error_connection_failed": "連接失敗",
"button_close": "關閉"
}
}
File diff suppressed because one or more lines are too long
+148
View File
@@ -0,0 +1,148 @@
import i18next from "i18next";
import { Notice, Plugin, moment } from "obsidian";
import { resources } from "./i18n";
import { addHaloIcon } from "./icons";
import { openPostSelectionModal } from "./post-selection-model";
import HaloService from "./service";
import { DEFAULT_SETTINGS, type HaloSetting, HaloSettingTab, type HaloSite } from "./settings";
import { openSiteSelectionModal } from "./site-selection-modal";
export default class HaloPlugin extends Plugin {
settings: HaloSetting;
async onload() {
console.log("loading obsidian-halo plugin");
await i18next.init({
lng: moment.locale(),
fallbackLng: "en",
resources,
returnNull: false,
});
await this.loadSettings();
addHaloIcon();
this.addRibbonIcon("halo-logo", i18next.t("ribbon_icon.publish"), async (evt: MouseEvent) => {
await this.publishCommand();
});
this.addCommand({
id: "publish",
name: i18next.t("command.publish.name"),
callback: async () => {
await this.publishCommand();
},
});
this.addCommand({
id: "publish-with-defaults",
name: i18next.t("command.publish_with_defaults.name"),
callback: async () => {
const site = this.settings.sites.find((site) => site.default);
if (!site) {
new Notice(i18next.t("command.publish_with_defaults.error_no_default_site"));
return;
}
const service = new HaloService(this.app, this.settings, site);
await service.publishPost();
},
});
this.addCommand({
id: "update-post",
name: i18next.t("command.update_post.name"),
editorCallback: async () => {
const { activeEditor } = this.app.workspace;
if (!activeEditor || !activeEditor.file) {
return;
}
const matterData = this.app.metadataCache.getFileCache(activeEditor.file)?.frontmatter;
if (!matterData?.halo?.site) {
new Notice(i18next.t("command.update_post.error_not_published"));
return;
}
const site = this.settings.sites.find((site) => site.url === matterData.halo?.site);
if (!site) {
new Notice(i18next.t("command.update_post.error_no_matched_site"));
return;
}
const service = new HaloService(this.app, this.settings, site);
await service.updatePost();
new Notice(i18next.t("command.update_post.success"));
},
});
this.addCommand({
id: "pull-post",
name: i18next.t("command.pull_post.name"),
callback: async () => {
if (this.settings.sites.length === 0) {
new Notice(i18next.t("command.pull_post.error_no_sites"));
return;
}
let site: HaloSite = this.settings.sites[0];
if (this.settings.sites.length > 1) {
site = await openSiteSelectionModal(this);
}
const post = await openPostSelectionModal(this, site);
const service = new HaloService(this.app, this.settings, site);
await service.pullPost(post.post.metadata.name);
},
});
this.addSettingTab(new HaloSettingTab(this));
}
onunload() {}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
private async publishCommand() {
const { activeEditor } = this.app.workspace;
if (!activeEditor || !activeEditor.file) {
return;
}
const matterData = this.app.metadataCache.getFileCache(activeEditor.file)?.frontmatter;
if (matterData?.halo?.site) {
const site = this.settings.sites.find((site) => site.url === matterData.halo.site);
if (!site) {
new Notice(i18next.t("command.publish.error_no_matched_site"));
return;
}
const service = new HaloService(this.app, this.settings, site);
await service.publishPost();
return;
}
const site = await openSiteSelectionModal(this);
const service = new HaloService(this.app, this.settings, site);
await service.publishPost();
}
}
+72
View File
@@ -0,0 +1,72 @@
import type { ListedPost } from "@halo-dev/api-client";
import i18next from "i18next";
import { Modal, Notice, Setting, requestUrl } from "obsidian";
import type HaloPlugin from "./main";
import type { HaloSite } from "./settings";
export function openPostSelectionModal(plugin: HaloPlugin, site: HaloSite): Promise<ListedPost> {
return new Promise<ListedPost>((resolve, reject) => {
const modal = new PostSelectionModal(plugin, site, (post) => {
resolve(post);
});
modal.open();
});
}
class PostSelectionModal extends Modal {
constructor(
private readonly plugin: HaloPlugin,
private readonly site: HaloSite,
private readonly onSelect: (post: ListedPost) => void,
) {
super(app);
}
onOpen() {
const { contentEl } = this;
const renderPostList = (): void => {
contentEl.empty();
contentEl.createEl("h2", {
text: i18next.t("post_selection_modal.title"),
});
requestUrl({
url: `${this.site.url}/apis/uc.api.content.halo.run/v1alpha1/posts?labelSelector=content.halo.run%2Fdeleted%3Dfalse`,
headers: {
Authorization: `Bearer ${this.site.token}`,
},
})
.then((response) => {
const posts: ListedPost[] = response.json.items;
for (const post of posts) {
const setting = new Setting(contentEl).setName(post.post.spec.title).setDesc(post.post.spec.slug);
setting.addButton((button) =>
button.setButtonText(i18next.t("post_selection_modal.button_pull")).onClick(() => {
this.onSelect(post);
this.close();
}),
);
}
})
.catch(() => {
new Notice(i18next.t("common.error_connection_failed"));
})
.finally(() => {
new Setting(contentEl).addButton((button) =>
button.setButtonText(i18next.t("common.button_close")).onClick(() => this.close()),
);
});
};
renderPostList();
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
@@ -0,0 +1,97 @@
import type { TFile, Vault } from "obsidian";
import { requestUrl } from "obsidian";
export class ImageUploader {
private readonly siteUrl: string;
private readonly token: string;
private readonly headers: Record<string, string>;
private readonly uploadedImagesCache: Map<string, string>;
constructor(siteUrl: string, token: string) {
this.siteUrl = siteUrl;
this.token = token;
this.uploadedImagesCache = new Map();
this.headers = {
Authorization: `Bearer ${this.token}`,
};
}
private toBase64(arrayBuffer: ArrayBuffer): string {
const bytes = new Uint8Array(arrayBuffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
public async uploadImage(filePath: string, vault: Vault): Promise<string | null> {
const normalizedPath = filePath.replace(/\\/g, "/");
if (this.uploadedImagesCache.has(normalizedPath)) {
return this.uploadedImagesCache.get(normalizedPath) || null;
}
try {
const file = vault.getAbstractFileByPath(normalizedPath);
if (!file || !(file instanceof TFile)) {
console.error(`[ImageUploader] 文件不存在或不是有效文件: ${normalizedPath}`);
return null;
}
const arrayBuffer = await vault.readBinary(file);
const base64Data = this.toBase64(arrayBuffer);
const filename = normalizedPath.split("/").pop() || "image";
const boundary = `----ObsidianHalo${Date.now()}`;
let body = `--${boundary}\r\n`;
body += `Content-Disposition: form-data; name="file"; filename="${filename}"\r\n`;
body += "Content-Type: application/octet-stream\r\n";
body += "Content-Transfer-Encoding: base64\r\n\r\n";
body += base64Data;
body += `\r\n--${boundary}--\r\n`;
const response = await requestUrl({
url: `${this.siteUrl}/apis/api.console.halo.run/v1alpha1/attachments/upload`,
method: "POST",
headers: {
...this.headers,
"Content-Type": `multipart/form-data; boundary=${boundary}`,
},
body: body,
});
const jsonResponse = response.json;
if (jsonResponse?.url) {
this.uploadedImagesCache.set(normalizedPath, jsonResponse.url);
return jsonResponse.url;
}
return null;
} catch (error) {
console.error(`[ImageUploader] 上传图片失败: ${filePath}`, error);
return null;
}
}
public async uploadImages(filePaths: string[], vault: Vault): Promise<Map<string, string>> {
const result = new Map<string, string>();
for (const filePath of filePaths) {
const url = await this.uploadImage(filePath, vault);
if (url) {
result.set(filePath, url);
}
}
return result;
}
public getCachedUrl(filePath: string): string | undefined {
const normalizedPath = filePath.replace(/\\/g, "/");
return this.uploadedImagesCache.get(normalizedPath);
}
}
+493
View File
@@ -0,0 +1,493 @@
import type { Category, Content, Post, Snapshot, Tag } from "@halo-dev/api-client";
import i18next from "i18next";
import { type App, Notice, requestUrl } from "obsidian";
import { randomUUID } from "src/utils/id";
import markdownIt from "src/utils/markdown";
import { slugify } from "transliteration";
import type { HaloSetting, HaloSite } from "../settings";
import { extractImageReferences, getAbsolutePath, replaceImagePaths } from "../utils/image";
import ImageUploader from "./image-uploader";
class HaloService {
private readonly site: HaloSite;
private readonly app: App;
private readonly settings: HaloSetting;
private readonly headers: Record<string, string> = {};
constructor(app: App, settings: HaloSetting, site: HaloSite) {
this.app = app;
this.settings = settings;
this.site = site;
this.headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${site.token}`,
};
}
public async getPost(name: string): Promise<{ post: Post; content: Content } | undefined> {
try {
const post = (await requestUrl({
url: `${this.site.url}/apis/uc.api.content.halo.run/v1alpha1/posts/${name}`,
headers: this.headers,
}).json) as Post;
const snapshot = (await requestUrl({
url: `${this.site.url}/apis/uc.api.content.halo.run/v1alpha1/posts/${name}/draft?patched=true`,
headers: this.headers,
}).json) as Snapshot;
const { "content.halo.run/patched-content": patchedContent, "content.halo.run/patched-raw": patchedRaw } =
snapshot.metadata.annotations || {};
const { rawType } = snapshot.spec || {};
const content: Content = {
content: patchedContent,
raw: patchedRaw,
rawType,
};
return Promise.resolve({
post,
content,
});
} catch (error) {
return Promise.resolve(undefined);
}
}
public async publishPost(): Promise<void> {
const { activeEditor } = this.app.workspace;
if (!activeEditor || !activeEditor.file) {
return;
}
const imageUploader = new ImageUploader(this.site.url, this.site.token);
let params: Post = {
apiVersion: "content.halo.run/v1alpha1",
kind: "Post",
metadata: {
annotations: {},
name: "",
},
spec: {
allowComment: true,
baseSnapshot: "",
categories: [],
cover: "",
deleted: false,
excerpt: {
autoGenerate: true,
raw: "",
},
headSnapshot: "",
htmlMetas: [],
owner: "",
pinned: false,
priority: 0,
publish: false,
publishTime: "",
releaseSnapshot: "",
slug: "",
tags: [],
template: "",
title: "",
visible: "PUBLIC",
},
};
let content: Content = {
rawType: "markdown",
raw: "",
content: "",
};
const md = await this.app.vault.read(activeEditor.file);
const matterData = this.app.metadataCache.getFileCache(activeEditor.file)?.frontmatter;
const frontmatterPosition = this.app.metadataCache.getFileCache(activeEditor.file)?.frontmatterPosition;
const raw = frontmatterPosition ? md.slice(frontmatterPosition?.end.offset) : md;
// 检测并上传本地图片
let processedRaw = raw;
if (this.settings.imageUpload?.enabled) {
const imageReferences = extractImageReferences(raw);
if (imageReferences.length > 0) {
const localImages = imageReferences.filter((ref) => !ref.path.startsWith("http://") && !ref.path.startsWith("https://") && !ref.path.startsWith("data:"));
if (localImages.length > 0) {
new Notice(`检测到 ${localImages.length} 个本地图片,正在上传...`);
const absolutePaths = localImages
.map((ref) => ({
original: ref.path,
absolute: getAbsolutePath(this.app.vault, ref.path, activeEditor.file.path),
}))
.filter((item) => item.absolute !== null) as { original: string; absolute: string }[];
if (absolutePaths.length > 0) {
const pathMapping = await imageUploader.uploadImages(absolutePaths.map((item) => item.absolute), this.app.vault);
if (pathMapping.size > 0) {
const mapping = new Map<string, string>();
absolutePaths.forEach((item) => {
const remoteUrl = pathMapping.get(item.absolute);
if (remoteUrl) {
mapping.set(item.original, remoteUrl);
}
});
processedRaw = replaceImagePaths(processedRaw, mapping);
const successCount = mapping.size;
const failCount = absolutePaths.length - successCount;
if (failCount === 0) {
new Notice(`✓ 图片上传成功 (${successCount}/${absolutePaths.length})`);
} else {
new Notice(`⚠ 部分图片上传失败 (成功: ${successCount}, 失败: ${failCount})`);
}
} else {
new Notice("⚠ 图片上传失败,保留原始路径");
}
}
}
}
}
// check site url
if (matterData?.halo?.site && matterData.halo.site !== this.site.url) {
new Notice(i18next.t("service.error_site_not_match"));
return;
}
if (matterData?.halo?.name) {
const post = await this.getPost(matterData.halo.name);
if (post) {
params = post.post;
content = post.content;
}
}
content.raw = processedRaw;
content.content = markdownIt.render(processedRaw);
// restore metadata
if (matterData?.title) {
params.spec.title = matterData.title;
}
if (matterData?.slug) {
params.spec.slug = matterData.slug;
}
if (matterData?.excerpt) {
params.spec.excerpt.raw = matterData.excerpt;
params.spec.excerpt.autoGenerate = false;
}
if (matterData?.cover) {
params.spec.cover = matterData.cover;
}
if (matterData?.categories) {
const categoryNames = await this.getCategoryNames(matterData.categories);
params.spec.categories = categoryNames;
}
if (matterData?.tags) {
const tagNames = await this.getTagNames(matterData.tags);
params.spec.tags = tagNames;
}
try {
if (params.metadata.name) {
const { name } = params.metadata;
await requestUrl({
url: `${this.site.url}/apis/uc.api.content.halo.run/v1alpha1/posts/${name}`,
method: "PUT",
contentType: "application/json",
headers: this.headers,
body: JSON.stringify(params),
});
const snapshot = (await requestUrl({
url: `${this.site.url}/apis/uc.api.content.halo.run/v1alpha1/posts/${name}/draft?patched=true`,
headers: this.headers,
}).json) as Snapshot;
snapshot.metadata.annotations = {
...snapshot.metadata.annotations,
"content.halo.run/content-json": JSON.stringify(content),
};
await requestUrl({
url: `${this.site.url}/apis/uc.api.content.halo.run/v1alpha1/posts/${name}/draft`,
method: "PUT",
contentType: "application/json",
headers: this.headers,
body: JSON.stringify(snapshot),
});
} else {
params.metadata.name = randomUUID();
params.spec.title = matterData?.title || activeEditor.file.basename;
params.spec.slug = matterData?.slug || slugify(params.spec.title, { trim: true });
params.metadata.annotations = {
...params.metadata.annotations,
"content.halo.run/content-json": JSON.stringify(content),
};
const post = await requestUrl({
url: `${this.site.url}/apis/uc.api.content.halo.run/v1alpha1/posts`,
method: "POST",
contentType: "application/json",
headers: this.headers,
body: JSON.stringify(params),
}).json;
params = post;
}
// Publish post
// biome-ignore lint: no
if (matterData?.halo?.hasOwnProperty("publish")) {
if (matterData?.halo?.publish) {
await this.changePostPublish(params.metadata.name, true);
} else {
await this.changePostPublish(params.metadata.name, false);
}
} else {
if (this.settings.publishByDefault) {
await this.changePostPublish(params.metadata.name, true);
}
}
params = (await this.getPost(params.metadata.name))?.post || params;
} catch (error) {
new Notice(i18next.t("service.error_publish_failed"));
return;
}
const postCategories = await this.getCategoryDisplayNames(params.spec.categories);
const postTags = await this.getTagDisplayNames(params.spec.tags);
this.app.fileManager.processFrontMatter(activeEditor.file, (frontmatter) => {
frontmatter.title = params.spec.title;
frontmatter.slug = params.spec.slug;
frontmatter.cover = params.spec.cover;
frontmatter.excerpt = params.spec.excerpt.autoGenerate ? undefined : params.spec.excerpt.raw;
frontmatter.categories = postCategories;
frontmatter.tags = postTags;
frontmatter.halo = {
site: this.site.url,
name: params.metadata.name,
publish: params.spec.publish,
};
});
new Notice(i18next.t("service.notice_publish_success"));
}
public async changePostPublish(name: string, publish: boolean): Promise<void> {
await requestUrl({
url: `${this.site.url}/apis/uc.api.content.halo.run/v1alpha1/posts/${name}/${publish ? "publish" : "unpublish"}`,
method: "PUT",
contentType: "application/json",
headers: this.headers,
});
}
public async getCategories(): Promise<Category[]> {
const data = await requestUrl({
url: `${this.site.url}/apis/content.halo.run/v1alpha1/categories`,
headers: this.headers,
});
return Promise.resolve(data.json.items);
}
public async getTags(): Promise<Tag[]> {
const data = await requestUrl({
url: `${this.site.url}/apis/content.halo.run/v1alpha1/tags`,
headers: this.headers,
});
return Promise.resolve(data.json.items);
}
public async updatePost(): Promise<void> {
const { activeEditor } = this.app.workspace;
if (!activeEditor || !activeEditor.file) {
return;
}
const matterData = this.app.metadataCache.getFileCache(activeEditor.file)?.frontmatter;
if (!matterData?.halo?.name) {
new Notice(i18next.t("service.error_not_published"));
return;
}
const post = await this.getPost(matterData.halo.name);
if (!post) {
new Notice(i18next.t("service.error_post_not_found"));
return;
}
const postCategories = await this.getCategoryDisplayNames(post.post.spec.categories);
const postTags = await this.getTagDisplayNames(post.post.spec.tags);
await this.app.vault.modify(activeEditor.file, `${post.content.raw}`);
this.app.fileManager.processFrontMatter(activeEditor.file, (frontmatter) => {
frontmatter.title = post.post.spec.title;
frontmatter.slug = post.post.spec.slug;
frontmatter.cover = post.post.spec.cover;
frontmatter.excerpt = post.post.spec.excerpt.autoGenerate ? undefined : post.post.spec.excerpt.raw;
frontmatter.categories = postCategories;
frontmatter.tags = postTags;
frontmatter.halo = {
site: this.site.url,
name: post.post.metadata.name,
publish: post.post.spec.publish,
};
});
}
public async pullPost(name: string): Promise<void> {
const post = await this.getPost(name);
if (!post) {
new Notice(i18next.t("service.error_post_not_found"));
return;
}
const postCategories = await this.getCategoryDisplayNames(post.post.spec.categories);
const postTags = await this.getTagDisplayNames(post.post.spec.tags);
const file = await this.app.vault.create(`${post.post.spec.title}.md`, `${post.content.raw}`);
this.app.workspace.getLeaf().openFile(file);
this.app.fileManager.processFrontMatter(file, (frontmatter) => {
frontmatter.title = post.post.spec.title;
frontmatter.slug = post.post.spec.slug;
frontmatter.cover = post.post.spec.cover;
frontmatter.excerpt = post.post.spec.excerpt.autoGenerate ? undefined : post.post.spec.excerpt.raw;
frontmatter.categories = postCategories;
frontmatter.tags = postTags;
frontmatter.halo = {
site: this.site.url,
name: name,
publish: post.post.spec.publish,
};
});
}
public async getCategoryNames(displayNames: string[]): Promise<string[]> {
const allCategories = await this.getCategories();
const notExistDisplayNames = displayNames.filter(
(name) => !allCategories.find((item) => item.spec.displayName === name),
);
const promises = notExistDisplayNames.map((name, index) =>
requestUrl({
url: `${this.site.url}/apis/content.halo.run/v1alpha1/categories`,
method: "POST",
contentType: "application/json",
headers: this.headers,
body: JSON.stringify({
spec: {
displayName: name,
slug: slugify(name, { trim: true }),
description: "",
cover: "",
template: "",
priority: allCategories.length + index,
children: [],
},
apiVersion: "content.halo.run/v1alpha1",
kind: "Category",
metadata: { name: "", generateName: "category-" },
}),
}),
);
const newCategories = await Promise.all(promises);
const existNames = displayNames
.map((name) => {
const found = allCategories.find((item) => item.spec.displayName === name);
return found ? found.metadata.name : undefined;
})
.filter(Boolean) as string[];
return [...existNames, ...newCategories.map((item) => item.json.metadata.name)];
}
public async getCategoryDisplayNames(names?: string[]): Promise<string[]> {
const categories = await this.getCategories();
return names
?.map((name) => {
const found = categories.find((item) => item.metadata.name === name);
return found ? found.spec.displayName : undefined;
})
.filter(Boolean) as string[];
}
public async getTagNames(displayNames: string[]): Promise<string[]> {
const allTags = await this.getTags();
const notExistDisplayNames = displayNames.filter((name) => !allTags.find((item) => item.spec.displayName === name));
const promises = notExistDisplayNames.map((name) =>
requestUrl({
url: `${this.site.url}/apis/content.halo.run/v1alpha1/tags`,
method: "POST",
contentType: "application/json",
headers: this.headers,
body: JSON.stringify({
spec: {
displayName: name,
slug: slugify(name, { trim: true }),
color: "#ffffff",
cover: "",
},
apiVersion: "content.halo.run/v1alpha1",
kind: "Tag",
metadata: { name: "", generateName: "tag-" },
}),
}),
);
const newTags = await Promise.all(promises);
const existNames = displayNames
.map((name) => {
const found = allTags.find((item) => item.spec.displayName === name);
return found ? found.metadata.name : undefined;
})
.filter(Boolean) as string[];
return [...existNames, ...newTags.map((item) => item.json.metadata.name)];
}
public async getTagDisplayNames(names?: string[]): Promise<string[]> {
const tags = await this.getTags();
return names
?.map((name) => {
const found = tags.find((item) => item.metadata.name === name);
return found ? found.spec.displayName : undefined;
})
.filter(Boolean) as string[];
}
}
export default HaloService;
+95
View File
@@ -0,0 +1,95 @@
import i18next from "i18next";
import { PluginSettingTab, Setting } from "obsidian";
import type HaloPlugin from "./main";
import { HaloSitesModal } from "./sites-modal";
export interface HaloSite {
name: string;
url: string;
token: string;
default: boolean;
}
export interface ImageUploadSettings {
enabled: boolean;
uploadPath: string;
preserveOriginal: boolean;
}
export interface HaloSetting {
sites: HaloSite[];
publishByDefault: boolean;
imageUpload: ImageUploadSettings;
}
export const DEFAULT_SETTINGS: HaloSetting = {
sites: [],
publishByDefault: false,
imageUpload: {
enabled: true,
uploadPath: "",
preserveOriginal: false,
},
};
export class HaloSettingTab extends PluginSettingTab {
constructor(private readonly plugin: HaloPlugin) {
super(app, plugin);
}
display() {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName(i18next.t("settings.site.name"))
.setDesc(i18next.t("settings.site.description"))
.addButton((button) =>
button.setButtonText(i18next.t("settings.site.actions.open")).onClick(() => {
new HaloSitesModal(this.plugin).open();
}),
);
new Setting(containerEl)
.setName(i18next.t("settings.publishByDefault.name"))
.setDesc(i18next.t("settings.publishByDefault.description"))
.addToggle((toggle) => {
toggle.setValue(this.plugin.settings.publishByDefault).onChange((value) => {
this.plugin.settings.publishByDefault = value;
this.plugin.saveSettings();
});
});
// 图片上传设置
new Setting(containerEl)
.setName(i18next.t("settings.imageUpload.enabled.name"))
.setDesc(i18next.t("settings.imageUpload.enabled.description"))
.addToggle((toggle) => {
toggle.setValue(this.plugin.settings.imageUpload.enabled).onChange((value) => {
this.plugin.settings.imageUpload.enabled = value;
this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName(i18next.t("settings.imageUpload.uploadPath.name"))
.setDesc(i18next.t("settings.imageUpload.uploadPath.description"))
.addText((text) => {
text.setValue(this.plugin.settings.imageUpload.uploadPath).onChange((value) => {
this.plugin.settings.imageUpload.uploadPath = value;
this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName(i18next.t("settings.imageUpload.preserveOriginal.name"))
.setDesc(i18next.t("settings.imageUpload.preserveOriginal.description"))
.addToggle((toggle) => {
toggle.setValue(this.plugin.settings.imageUpload.preserveOriginal).onChange((value) => {
this.plugin.settings.imageUpload.preserveOriginal = value;
this.plugin.saveSettings();
});
});
}
}
+129
View File
@@ -0,0 +1,129 @@
import i18next from "i18next";
import { Modal, Notice, Setting, requestUrl } from "obsidian";
import type HaloPlugin from "./main";
import type { HaloSite } from "./settings";
export function openSiteEditingModal(
plugin: HaloPlugin,
site?: HaloSite,
index = -1,
): Promise<{ site: HaloSite; index?: number }> {
return new Promise((resolve, reject) => {
const modal = new SiteEditingModal(
plugin,
site || { name: "", url: "", default: false, token: "" },
index,
(site, index) => {
resolve({
site,
index,
});
},
);
modal.open();
});
}
export class SiteEditingModal extends Modal {
private readonly currentSite: HaloSite;
constructor(
private readonly plugin: HaloPlugin,
private readonly site: HaloSite,
private readonly index: number,
private readonly onSubmit: (site: HaloSite, index?: number) => void,
) {
super(app);
this.currentSite = Object.assign({}, site);
}
onOpen(): void {
const { contentEl } = this;
const renderContent = () => {
contentEl.empty();
contentEl.createEl("h2", { text: i18next.t("site_editing_modal.title") });
new Setting(contentEl)
.setName(i18next.t("site_editing_modal.settings.name.name"))
.setDesc(i18next.t("site_editing_modal.settings.name.description"))
.addText((text) =>
text.setValue(this.currentSite.name).onChange((value) => {
this.currentSite.name = value;
}),
);
new Setting(contentEl)
.setName(i18next.t("site_editing_modal.settings.url.name"))
.setDesc(i18next.t("site_editing_modal.settings.url.description"))
.addText((text) =>
text.setValue(this.currentSite.url).onChange((value) => {
this.currentSite.url = value;
}),
);
new Setting(contentEl)
.setName(i18next.t("site_editing_modal.settings.token.name"))
.setDesc(i18next.t("site_editing_modal.settings.token.description"))
.addText((text) =>
text.setValue(this.currentSite.token).onChange((value) => {
this.currentSite.token = value;
}),
);
new Setting(contentEl)
.setName(i18next.t("site_editing_modal.settings.default.name"))
.setDesc(i18next.t("site_editing_modal.settings.default.description"))
.addToggle((toggle) =>
toggle.setValue(this.currentSite.default).onChange((value) => {
this.currentSite.default = value;
}),
);
new Setting(contentEl)
.addButton((button) => {
button.setButtonText(i18next.t("site_editing_modal.settings.validate.button")).onClick(() => {
button.setDisabled(true);
button.setButtonText(i18next.t("site_editing_modal.settings.validate.button_validating"));
requestUrl({
url: `${this.currentSite.url}/apis/api.console.halo.run/v1alpha1/users/-/permissions`,
headers: {
Authorization: `Bearer ${this.currentSite.token}`,
},
})
.then((response) => {
if (response.json.uiPermissions.includes("uc:posts:manage")) {
new Notice(i18next.t("site_editing_modal.settings.validate.notice_validated"));
} else {
new Notice(i18next.t("site_editing_modal.settings.validate.error_no_permissions"));
}
})
.catch(() => {
new Notice(i18next.t("common.error_connection_failed"));
})
.finally(() => {
button.setDisabled(false);
button.setButtonText(i18next.t("site_editing_modal.settings.validate.button"));
});
});
})
.addButton((button) =>
button
.setButtonText(i18next.t("site_editing_modal.settings.save.button"))
.setCta()
.onClick(() => {
this.onSubmit(this.currentSite, this.index);
this.close();
}),
);
};
renderContent();
}
onClose(): void {
const { contentEl } = this;
contentEl.empty();
}
}
+60
View File
@@ -0,0 +1,60 @@
import i18next from "i18next";
import { Modal, Setting } from "obsidian";
import type HaloPlugin from "./main";
import type { HaloSite } from "./settings";
export function openSiteSelectionModal(plugin: HaloPlugin): Promise<HaloSite> {
return new Promise<HaloSite>((resolve, reject) => {
const modal = new SiteSelectionModal(plugin, (site) => {
resolve(site);
});
modal.open();
});
}
class SiteSelectionModal extends Modal {
private readonly sites: HaloSite[];
constructor(
private readonly plugin: HaloPlugin,
private readonly onSelect: (site: HaloSite) => void,
) {
super(app);
this.sites = plugin.settings.sites;
}
onOpen() {
const { contentEl } = this;
const renderContent = (): void => {
contentEl.empty();
contentEl.createEl("h2", {
text: i18next.t("site_selection_modal.title"),
});
for (const site of this.sites) {
const setting = new Setting(contentEl).setName(site.name).setDesc(site.url);
setting.addButton((button) =>
button.setButtonText(i18next.t("site_selection_modal.button_choose")).onClick(() => {
this.onSelect(site);
this.close();
}),
);
}
new Setting(contentEl).addButton((button) =>
button.setButtonText(i18next.t("common.button_close")).onClick(() => this.close()),
);
};
renderContent();
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
+86
View File
@@ -0,0 +1,86 @@
import i18next from "i18next";
import { Modal, Setting } from "obsidian";
import type HaloPlugin from "./main";
import { openSiteEditingModal } from "./site-editing-modal";
export class HaloSitesModal extends Modal {
constructor(private readonly plugin: HaloPlugin) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
const renderContent = (): void => {
contentEl.empty();
contentEl.createEl("h2", { text: i18next.t("sites_modal.title") });
this.plugin.settings.sites.forEach((site, index) => {
const setting = new Setting(contentEl).setName(site.name).setDesc(site.url);
if (!site.default) {
setting.addButton((button) =>
button.setButtonText(i18next.t("sites_modal.actions.set_default")).onClick(() => {
for (const site of this.plugin.settings.sites) {
site.default = false;
}
site.default = true;
this.plugin.saveSettings();
renderContent();
}),
);
}
setting.addButton((button) =>
button.setButtonText(i18next.t("sites_modal.actions.edit")).onClick(async () => {
const { site: updatedSite, index: currentIndex } = await openSiteEditingModal(this.plugin, site, index);
if (currentIndex !== undefined && currentIndex > -1) {
this.plugin.settings.sites[currentIndex] = updatedSite;
await this.plugin.saveSettings();
renderContent();
}
}),
);
setting.addExtraButton((button) =>
button.setIcon("lucide-trash").onClick(() => {
this.plugin.settings.sites.splice(index, 1);
this.plugin.saveSettings();
renderContent();
}),
);
});
new Setting(contentEl).addButton((button) =>
button.setButtonText(i18next.t("sites_modal.actions.add")).onClick(async () => {
const { site } = await openSiteEditingModal(this.plugin);
if (this.plugin.settings.sites.length === 0) {
site.default = true;
}
if (site.default) {
for (const site of this.plugin.settings.sites) {
site.default = false;
}
}
this.plugin.settings.sites.push(site);
await this.plugin.saveSettings();
renderContent();
}),
);
};
renderContent();
}
onClose(): void {
const { contentEl } = this;
contentEl.empty();
}
}
+7
View File
@@ -0,0 +1,7 @@
export function randomUUID() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
+83
View File
@@ -0,0 +1,83 @@
import { TFile, type Vault } from "obsidian";
export interface ImageReference {
alt: string;
path: string;
fullMatch: string;
}
const IMAGE_PATTERN = /!\[([^\]]*)\]\(([^)]+)\)/g;
export function extractImageReferences(content: string): ImageReference[] {
const references: ImageReference[] = [];
const matches = content.matchAll(IMAGE_PATTERN);
for (const match of matches) {
const [, alt, path] = match;
if (isExternalUrl(path)) {
continue;
}
references.push({
alt,
path,
fullMatch: match[0],
});
}
return references;
}
export function getAbsolutePath(vault: Vault, filePath: string, currentFilePath: string): string | null {
let absolutePath: string;
if (filePath.startsWith("./") || filePath.startsWith("../")) {
absolutePath = resolveRelativePath(currentFilePath, filePath);
} else if (filePath.startsWith("/")) {
absolutePath = filePath.substring(1);
} else {
absolutePath = resolveRelativePath(currentFilePath, filePath);
}
const file = vault.getAbstractFileByPath(absolutePath);
if (file instanceof TFile) {
return absolutePath;
}
return null;
}
export function replaceImagePaths(content: string, pathMapping: Map<string, string>): string {
return content.replace(IMAGE_PATTERN, (fullMatch, alt, path) => {
const newPath = pathMapping.get(path);
if (newPath) {
return `![${alt}](${newPath})`;
}
return fullMatch;
});
}
function isExternalUrl(path: string): boolean {
return path.startsWith("http://") || path.startsWith("https://") || path.startsWith("data:");
}
function resolveRelativePath(currentFilePath: string, relativePath: string): string {
const currentParts = currentFilePath.split("/");
currentParts.pop();
const relativeParts = relativePath.split("/");
for (const part of relativeParts) {
if (part === "..") {
currentParts.pop();
} else if (part !== ".") {
currentParts.push(part);
}
}
return currentParts.join("/");
}
+14
View File
@@ -0,0 +1,14 @@
import MarkdownIt from "markdown-it";
import MarkdownItAnchor from "markdown-it-anchor";
const markdownIt = new MarkdownIt({
html: true,
xhtmlOut: true,
breaks: true,
linkify: true,
typographer: true,
});
markdownIt.use(MarkdownItAnchor);
export default markdownIt;
+19
View File
@@ -0,0 +1,19 @@
import matter from "gray-matter";
import yaml from "js-yaml";
const options = {
engines: {
yaml: {
parse: (input: string) => yaml.load(input) as object,
stringify: (data: object) => {
return yaml.dump(data, {
styles: { "!!null": "empty" },
});
},
},
},
};
export function readMatter(content: string) {
return matter(content, options);
}