From b7f62884920b612f3501104a5b349ca2005ff0b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E8=88=AA=E5=AE=87?= <3364451258@qq.com> Date: Tue, 28 Apr 2026 14:15:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(halo):=20=E6=B7=BB=E5=8A=A0=20Halo=20?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E3=80=81=E5=86=85=E5=AE=B9=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=92=8C=E5=9B=BE=E5=83=8F=E6=9C=8D=E5=8A=A1=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=96=87=E7=AB=A0=E5=8F=91=E5=B8=83=E5=92=8C?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugins/recent-files-obsidian/data.json | 4 + .obsidian/workspace.json | 38 +- .vscode/settings.json | 3 + obsidian-halo/src/services/client.ts | 124 ++++++ obsidian-halo/src/services/content-service.ts | 64 +++ obsidian-halo/src/services/error.ts | 75 ++++ obsidian-halo/src/services/halo-service.ts | 402 ++++++++++++++++++ obsidian-halo/src/services/image-service.ts | 66 +++ obsidian-halo/src/services/post-service.ts | 101 +++++ .../src/services/taxonomy-service.ts | 138 ++++++ obsidian-halo/src/services/types.ts | 74 ++++ 11 files changed, 1062 insertions(+), 27 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 obsidian-halo/src/services/client.ts create mode 100644 obsidian-halo/src/services/content-service.ts create mode 100644 obsidian-halo/src/services/error.ts create mode 100644 obsidian-halo/src/services/halo-service.ts create mode 100644 obsidian-halo/src/services/image-service.ts create mode 100644 obsidian-halo/src/services/post-service.ts create mode 100644 obsidian-halo/src/services/taxonomy-service.ts create mode 100644 obsidian-halo/src/services/types.ts diff --git a/.obsidian/plugins/recent-files-obsidian/data.json b/.obsidian/plugins/recent-files-obsidian/data.json index 5a58535..afa8d36 100644 --- a/.obsidian/plugins/recent-files-obsidian/data.json +++ b/.obsidian/plugins/recent-files-obsidian/data.json @@ -1,5 +1,9 @@ { "recentFiles": [ + { + "basename": "DeepSeek-V4博客大纲", + "path": "博客/其他/DeepSeek-V4博客大纲.md" + }, { "basename": "DeepSeek-V4全面解析", "path": "博客/DeepSeek-V4全面解析.md" diff --git a/.obsidian/workspace.json b/.obsidian/workspace.json index 3db642d..4701203 100644 --- a/.obsidian/workspace.json +++ b/.obsidian/workspace.json @@ -11,10 +11,14 @@ "id": "e7a7b303c61786dc", "type": "leaf", "state": { - "type": "empty", - "state": {}, + "type": "markdown", + "state": { + "file": "博客/其他/DeepSeek-V4博客大纲.md", + "mode": "source", + "source": false + }, "icon": "lucide-file", - "title": "新标签页" + "title": "DeepSeek-V4博客大纲" } } ] @@ -84,7 +88,7 @@ } ], "direction": "horizontal", - "width": 293.5 + "width": 263.5 }, "right": { "id": "1a950cafdb3ea126", @@ -197,30 +201,10 @@ } ], "currentTab": 3 - }, - { - "id": "909893d80e09e8a3", - "type": "tabs", - "children": [ - { - "id": "e7a7b303c61786dc", - "type": "leaf", - "state": { - "type": "markdown", - "state": { - "file": "博客/DeepSeek-V4全面解析.md", - "mode": "source", - "source": false - }, - "icon": "lucide-file", - "title": "DeepSeek-V4全面解析" - } - } - ] } ], "direction": "horizontal", - "width": 1013.5 + "width": 439.5 }, "left-ribbon": { "hiddenItems": { @@ -248,9 +232,10 @@ }, "active": "e7a7b303c61786dc", "lastOpenFiles": [ - "copilot/copilot-conversations/分析@20260426_205456.md", + "copilot/copilot-conversations/你好@20260428_130750.md", "copilot/copilot-conversations", "copilot", + "copilot/copilot-conversations/分析@20260426_205456.md", "博客/标签分类审查报告.md", "博客/DeepSeek-V4全面解析.md", "博客/Git团队协作指南(精简版).md", @@ -293,7 +278,6 @@ "copilot/copilot-conversations/分析@20260425_212055.md", "copilot/copilot-custom-prompts/halo.md", "Excalidraw/Drawing 2026-04-25 21.16.49.excalidraw.md", - "博客/深入解析 Claude Code:Vibe Coding 时代的 AI 编程利器.md", "未命名.canvas" ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3b66410 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "git.ignoreLimitWarning": true +} \ No newline at end of file diff --git a/obsidian-halo/src/services/client.ts b/obsidian-halo/src/services/client.ts new file mode 100644 index 0000000..f455bf0 --- /dev/null +++ b/obsidian-halo/src/services/client.ts @@ -0,0 +1,124 @@ +import { requestUrl } from "obsidian"; +import type { HaloSite } from "../settings"; +import { HaloError, HttpError } from "./error"; + +export class HaloClient { + private readonly baseUrl: string; + private readonly headers: Record; + + constructor(site: HaloSite) { + this.baseUrl = site.url; + this.headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${site.token}`, + }; + } + + private handleResponse(response: { json: unknown; status: number }): T { + const { json, status } = response; + + if (status >= 200 && status < 300) { + return json as T; + } + + throw HttpError.fromStatus(status, json); + } + + private async request(path: string, options: { method: string; body?: object }): Promise { + try { + const response = await requestUrl({ + url: `${this.baseUrl}${path}`, + method: options.method, + headers: this.headers, + body: options.body ? JSON.stringify(options.body) : undefined, + contentType: options.body ? "application/json" : undefined, + }); + + return this.handleResponse({ json: response.json, status: response.status }); + } catch (error) { + if (error instanceof HttpError) { + throw this.mapHttpError(error); + } + if (error instanceof TypeError) { + // Network error + throw HaloError.network(error); + } + throw HaloError.unknown(error); + } + } + + private mapHttpError(error: HttpError): HaloError { + switch (error.statusCode) { + case 401: + return HaloError.unauthorized(); + case 403: + return HaloError.forbidden(); + case 404: + return HaloError.notFound("资源"); + case 500: + case 502: + case 503: + return HaloError.serverError(); + default: + return new HaloError(error.message, `HTTP_${error.statusCode}`, error.statusCode, error); + } + } + + async get(path: string): Promise { + return this.request(path, { method: "GET" }); + } + + async post(path: string, body: object): Promise { + return this.request(path, { method: "POST", body }); + } + + async put(path: string, body: object): Promise { + return this.request(path, { method: "PUT", body }); + } + + async delete(path: string): Promise { + try { + const response = await requestUrl({ + url: `${this.baseUrl}${path}`, + method: "DELETE", + headers: this.headers, + }); + + if (response.status >= 400) { + throw HttpError.fromStatus(response.status, response.json); + } + } catch (error) { + if (error instanceof HttpError) { + throw this.mapHttpError(error); + } + if (error instanceof TypeError) { + throw HaloError.network(error); + } + throw HaloError.unknown(error); + } + } + + async putVoid(path: string, body?: object): Promise { + try { + const response = await requestUrl({ + url: `${this.baseUrl}${path}`, + method: "PUT", + headers: this.headers, + body: body ? JSON.stringify(body) : undefined, + contentType: body ? "application/json" : undefined, + }); + + if (response.status >= 400) { + throw HttpError.fromStatus(response.status, response.json); + } + } catch (error) { + if (error instanceof HttpError) { + throw this.mapHttpError(error); + } + if (error instanceof TypeError) { + throw HaloError.network(error); + } + throw HaloError.unknown(error); + } + } +} diff --git a/obsidian-halo/src/services/content-service.ts b/obsidian-halo/src/services/content-service.ts new file mode 100644 index 0000000..46547aa --- /dev/null +++ b/obsidian-halo/src/services/content-service.ts @@ -0,0 +1,64 @@ +import { slugify } from "transliteration"; +import markdownIt from "../utils/markdown"; +import { readMatter } from "../utils/yaml"; +import type { Content, Post } from "./types"; + +export interface FrontmatterData { + title?: string; + slug?: string; + excerpt?: string; + cover?: string; + categories?: string[]; + tags?: string[]; + halo?: { + site?: string; + name?: string; + publish?: boolean; + }; +} + +export class ContentService { + renderMarkdown(raw: string): string { + return markdownIt.render(raw); + } + + extractFrontmatter(md: string): { frontmatter: FrontmatterData; rawContent: string } { + const result = readMatter(md); + const frontmatter = result.data as FrontmatterData; + const rawContent = result.content; + return { frontmatter, rawContent }; + } + + buildPostSpec( + title: string, + slug: string, + _raw: string, + frontmatter: FrontmatterData, + _content: Content, + ): Post["spec"] { + return { + allowComment: true, + baseSnapshot: "", + categories: [], + cover: frontmatter.cover || "", + deleted: false, + excerpt: { + autoGenerate: !frontmatter.excerpt, + raw: frontmatter.excerpt || "", + }, + headSnapshot: "", + htmlMetas: [], + owner: "", + pinned: false, + priority: 0, + publish: false, + publishTime: "", + releaseSnapshot: "", + slug: slug || slugify(title, { trim: true }), + tags: [], + template: "", + title, + visible: "PUBLIC", + }; + } +} diff --git a/obsidian-halo/src/services/error.ts b/obsidian-halo/src/services/error.ts new file mode 100644 index 0000000..61caebf --- /dev/null +++ b/obsidian-halo/src/services/error.ts @@ -0,0 +1,75 @@ +export class HaloError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly statusCode?: number, + public readonly originalError?: unknown, + ) { + super(message); + this.name = "HaloError"; + } + + static network(error: unknown): HaloError { + return new HaloError("网络请求失败,请检查网络连接", "NETWORK_ERROR", undefined, error); + } + + static unauthorized(): HaloError { + return new HaloError("认证失败,请检查 Token 是否有效", "UNAUTHORIZED", 401); + } + + static forbidden(): HaloError { + return new HaloError("权限不足,无法执行此操作", "FORBIDDEN", 403); + } + + static notFound(resource: string): HaloError { + return new HaloError(`${resource} 未找到`, "NOT_FOUND", 404); + } + + static serverError(message = "服务器错误"): HaloError { + return new HaloError(message, "SERVER_ERROR", 500); + } + + static validationError(message: string): HaloError { + return new HaloError(message, "VALIDATION_ERROR"); + } + + static unknown(error: unknown): HaloError { + const message = error instanceof Error ? error.message : "未知错误"; + return new HaloError(message, "UNKNOWN", undefined, error); + } + + isNetworkError(): boolean { + return this.code === "NETWORK_ERROR"; + } + + isAuthError(): boolean { + return this.code === "UNAUTHORIZED" || this.code === "FORBIDDEN"; + } + + isNotFound(): boolean { + return this.code === "NOT_FOUND"; + } +} + +export class HttpError extends Error { + constructor( + public readonly statusCode: number, + message: string, + public readonly response?: unknown, + ) { + super(message); + this.name = "HttpError"; + } + + static fromStatus(statusCode: number, response?: unknown): HttpError { + const messages: Record = { + 400: "请求参数错误", + 401: "认证失败", + 403: "权限不足", + 404: "资源不存在", + 500: "服务器内部错误", + }; + const message = messages[statusCode] || `HTTP 错误 (${statusCode})`; + return new HttpError(statusCode, message, response); + } +} diff --git a/obsidian-halo/src/services/halo-service.ts b/obsidian-halo/src/services/halo-service.ts new file mode 100644 index 0000000..8a46853 --- /dev/null +++ b/obsidian-halo/src/services/halo-service.ts @@ -0,0 +1,402 @@ +import { type App, Notice, type TFile } from "obsidian"; +import { randomUUID } from "./utils/id"; +import markdownIt from "./utils/markdown"; +import { slugify } from "transliteration"; +import type { HaloSetting, HaloSite } from "./settings"; +import type { HaloClient } from "./services/client"; +import { PostService } from "./services/post-service"; +import { ImageService } from "./services/image-service"; +import { TaxonomyService } from "./services/taxonomy-service"; +import { ContentService } from "./services/content-service"; +import type { Content, Post } from "./services/types"; +import { HaloError } from "./error"; + +export class HaloService { + private readonly app: App; + private readonly siteUrl: string; + private readonly client: HaloClient; + private readonly postService: PostService; + private readonly imageService: ImageService; + private readonly taxonomyService: TaxonomyService; + private readonly contentService: ContentService; + + constructor(app: App, settings: HaloSetting, site: HaloSite) { + this.app = app; + this.siteUrl = site.url; + this.client = new HaloClient(site); + this.postService = new PostService(app, this.client); + this.imageService = new ImageService(site); + this.taxonomyService = new TaxonomyService(this.client); + this.contentService = new ContentService(); + } + + async publishPost(): Promise { + const { activeEditor } = this.app.workspace; + + if (!activeEditor || !activeEditor.file) { + return; + } + + let processedRaw = 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; + + processedRaw = frontmatterPosition ? processedRaw.slice(frontmatterPosition.end.offset) : processedRaw; + + // Process images if enabled + if (this.settings.imageUpload?.enabled) { + const imageResult = await this.imageService.uploadLocalImages( + processedRaw, + this.app.vault, + activeEditor.file.path, + ); + + if (imageResult.successCount > 0) { + new Notice(`图片上传成功 (${imageResult.successCount}/${imageResult.successCount + imageResult.failCount})`); + } else if (imageResult.failCount > 0) { + new Notice(`部分图片上传失败 (失败: ${imageResult.failCount})`); + } + + processedRaw = imageResult.processedContent; + } + + // Build content + const content: Content = { + rawType: "markdown", + raw: processedRaw, + content: markdownIt.render(processedRaw), + }; + + // Get existing post or create new params + let params: Post; + let isUpdate = false; + + if (matterData?.halo?.name) { + const existingPost = await this.postService.getPost(matterData.halo.name); + if (existingPost) { + params = existingPost.post; + content.raw = processedRaw; + content.content = markdownIt.render(processedRaw); + isUpdate = true; + } else { + params = this.createDefaultPostParams(); + } + } else { + params = this.createDefaultPostParams(); + } + + // Update params from frontmatter + params.spec.title = matterData?.title || activeEditor.file.basename; + params.spec.slug = matterData?.slug || slugify(params.spec.title, { trim: true }); + + 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) { + params.spec.categories = await this.taxonomyService.getCategoryNames(matterData.categories); + } + + if (matterData?.tags) { + params.spec.tags = await this.taxonomyService.getTagNames(matterData.tags); + } + + // Set content annotation + params.metadata.annotations = { + ...params.metadata.annotations, + "content.halo.run/content-json": JSON.stringify(content), + }; + + // Ensure metadata.name exists + if (!params.metadata.name) { + params.metadata.name = randomUUID(); + } + + try { + if (isUpdate) { + await this.postService.updatePost(params.metadata.name, params); + } else { + const newPost = await this.postService.createPost(params); + params.metadata.name = newPost.metadata.name; + } + + // Handle publish status + if (matterData?.halo?.hasOwnProperty("publish")) { + await this.postService.changePublishStatus(params.metadata.name, matterData.halo.publish); + } else if (this.settings.publishByDefault) { + await this.postService.changePublishStatus(params.metadata.name, true); + } + + // Refresh post to get latest data + const postResult = await this.postService.getPost(params.metadata.name); + if (!postResult || !postResult.post.metadata) { + new Notice("发布失败:无法获取文章详情"); + return; + } + + params = postResult.post; + + // Update frontmatter + const postCategories = await this.taxonomyService.getCategoryDisplayNames(params.spec.categories); + const postTags = await this.taxonomyService.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.siteUrl, + name: params.metadata.name, + publish: params.spec.publish, + }; + }); + + new Notice("发布成功"); + } catch (error) { + if (error instanceof HaloError) { + console.error(`[HaloService] 发布失败 [${error.code}]:`, error.message); + new Notice(error.message); + } else { + console.error("[HaloService] 发布失败:", error); + new Notice("发布失败"); + } + } + } + + async updatePost(): Promise { + 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("文章尚未发布到 Halo"); + return; + } + + const post = await this.postService.getPost(matterData.halo.name); + + if (!post) { + new Notice("在 Halo 中未找到文章"); + return; + } + + const postCategories = await this.taxonomyService.getCategoryDisplayNames(post.post.spec.categories); + const postTags = await this.taxonomyService.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.siteUrl, + name: post.post.metadata.name, + publish: post.post.spec.publish, + }; + }); + } + + async pullPost(name: string): Promise { + const post = await this.postService.getPost(name); + + if (!post) { + new Notice("在 Halo 中未找到文章"); + return; + } + + const postCategories = await this.taxonomyService.getCategoryDisplayNames(post.post.spec.categories); + const postTags = await this.taxonomyService.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.siteUrl, + name: name, + publish: post.post.spec.publish, + }; + }); + } + + async importPost(file: TFile, publishImmediately: boolean = false): Promise { + try { + let raw = await this.app.vault.read(file); + const matterData = this.app.metadataCache.getFileCache(file)?.frontmatter; + const frontmatterPosition = this.app.metadataCache.getFileCache(file)?.frontmatterPosition; + + raw = frontmatterPosition ? raw.slice(frontmatterPosition.end.offset) : raw; + + // Process images if enabled + if (this.settings.imageUpload?.enabled) { + const imageResult = await this.imageService.uploadLocalImages(raw, this.app.vault, file.path); + + if (imageResult.successCount > 0) { + new Notice(`图片上传成功 (${imageResult.successCount}/${imageResult.successCount + imageResult.failCount})`); + } + + raw = imageResult.processedContent; + } + + const content: Content = { + rawType: "markdown", + raw: raw, + content: markdownIt.render(raw), + }; + + const params: Post = { + apiVersion: "content.halo.run/v1alpha1", + kind: "Post", + metadata: { + annotations: { + "content.halo.run/content-json": JSON.stringify(content), + }, + name: randomUUID(), + }, + spec: { + allowComment: true, + baseSnapshot: "", + categories: [], + cover: matterData?.cover || "", + deleted: false, + excerpt: { + autoGenerate: matterData?.excerpt ? false : true, + raw: matterData?.excerpt || "", + }, + headSnapshot: "", + htmlMetas: [], + owner: "", + pinned: false, + priority: 0, + publish: publishImmediately, + publishTime: "", + releaseSnapshot: "", + slug: matterData?.slug || slugify(matterData?.title || file.basename, { trim: true }), + tags: [], + template: "", + title: matterData?.title || file.basename, + visible: "PUBLIC", + }, + }; + + // Handle categories + if (matterData?.categories) { + params.spec.categories = await this.taxonomyService.getCategoryNames(matterData.categories); + } + + // Handle tags + if (matterData?.tags) { + params.spec.tags = await this.taxonomyService.getTagNames(matterData.tags); + } + + // Create post + const post = await this.postService.createPost(params); + + return !!post && !!post.metadata; + } catch (error) { + if (error instanceof HaloError) { + console.error(`[HaloService] 导入文章失败 [${error.code}]:`, error.message); + } else { + console.error("[HaloService] 导入文章失败:", error); + } + return false; + } + } + + async deletePost(name: string): Promise { + try { + await this.postService.deletePost(name); + return true; + } catch (error) { + if (error instanceof HaloError) { + console.error(`[HaloService] 删除文章失败 [${error.code}]:`, error.message); + } else { + console.error("[HaloService] 删除文章失败:", error); + } + return false; + } + } + + async createTag(displayName: string, slug: string, color: string): Promise { + await this.taxonomyService.createTag(displayName, slug, color); + } + + async updateTag(name: string, displayName: string, slug: string, color: string): Promise { + await this.taxonomyService.updateTag(name, displayName, slug, color); + } + + async deleteTag(name: string): Promise { + await this.taxonomyService.deleteTag(name); + } + + async createCategory(displayName: string, slug: string, priority: number): Promise { + await this.taxonomyService.createCategory(displayName, slug, priority); + } + + async updateCategory(name: string, displayName: string, slug: string, priority: number): Promise { + await this.taxonomyService.updateCategory(name, displayName, slug, priority); + } + + async deleteCategory(name: string): Promise { + await this.taxonomyService.deleteCategory(name); + } + + private createDefaultPostParams(): Post { + return { + 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", + }, + }; + } +} + +export default HaloService; diff --git a/obsidian-halo/src/services/image-service.ts b/obsidian-halo/src/services/image-service.ts new file mode 100644 index 0000000..70fd595 --- /dev/null +++ b/obsidian-halo/src/services/image-service.ts @@ -0,0 +1,66 @@ +import type { Vault } from "obsidian"; +import ImageUploader from "../service/image-uploader"; +import type { HaloSite } from "../settings"; +import { extractImageReferences, getAbsolutePath, replaceImagePaths } from "../utils/image"; + +export class ImageService { + private uploader: ImageUploader; + + constructor(site: HaloSite) { + this.uploader = new ImageUploader(site.url, site.token); + } + + async uploadLocalImages( + content: string, + vault: Vault, + currentFilePath: string, + ): Promise<{ processedContent: string; successCount: number; failCount: number }> { + const imageReferences = extractImageReferences(content); + + if (imageReferences.length === 0) { + return { processedContent: content, successCount: 0, failCount: 0 }; + } + + const localImages = imageReferences.filter( + (ref) => !ref.path.startsWith("http://") && !ref.path.startsWith("https://") && !ref.path.startsWith("data:"), + ); + + if (localImages.length === 0) { + return { processedContent: content, successCount: 0, failCount: 0 }; + } + + const absolutePaths = localImages + .map((ref) => ({ + original: ref.path, + absolute: getAbsolutePath(vault, ref.path, currentFilePath), + })) + .filter((item) => item.absolute !== null) as { original: string; absolute: string }[]; + + if (absolutePaths.length === 0) { + return { processedContent: content, successCount: 0, failCount: 0 }; + } + + const pathMapping = await this.uploader.uploadImages( + absolutePaths.map((item) => item.absolute), + vault, + ); + + if (pathMapping.size === 0) { + return { processedContent: content, successCount: 0, failCount: absolutePaths.length }; + } + + const mapping = new Map(); + absolutePaths.forEach((item) => { + const remoteUrl = pathMapping.get(item.absolute); + if (remoteUrl) { + mapping.set(item.original, remoteUrl); + } + }); + + const processedContent = replaceImagePaths(content, mapping); + const successCount = mapping.size; + const failCount = absolutePaths.length - successCount; + + return { processedContent, successCount, failCount }; + } +} diff --git a/obsidian-halo/src/services/post-service.ts b/obsidian-halo/src/services/post-service.ts new file mode 100644 index 0000000..d9ffa9d --- /dev/null +++ b/obsidian-halo/src/services/post-service.ts @@ -0,0 +1,101 @@ +import type { App, TFile } from "obsidian"; +import { randomUUID } from "../utils/id"; +import type { HaloClient } from "./client"; +import type { Post, Snapshot, Content } from "./types"; +import { HaloError } from "./error"; + +export class PostService { + constructor( + private app: App, + private client: HaloClient, + ) {} + + async getPost(name: string): Promise<{ post: Post; content: Content } | undefined> { + try { + const post = await this.client.get(`/apis/uc.api.content.halo.run/v1alpha1/posts/${name}`); + + const snapshot = await this.client.get( + `/apis/uc.api.content.halo.run/v1alpha1/posts/${name}/draft?patched=true`, + ); + + 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 { post, content }; + } catch (error) { + if (error instanceof HaloError && error.isNotFound()) { + return undefined; + } + throw error; + } + } + + async createPost(params: Post): Promise { + return await this.client.post("/apis/uc.api.content.halo.run/v1alpha1/posts", params); + } + + async updatePost(name: string, params: Post): Promise { + await this.client.put(`/apis/uc.api.content.halo.run/v1alpha1/posts/${name}`, params); + } + + async deletePost(name: string): Promise { + await this.client.delete(`/apis/uc.api.content.halo.run/v1alpha1/posts/${name}`); + } + + async changePublishStatus(name: string, publish: boolean): Promise { + await this.client.putVoid( + `/apis/uc.api.content.halo.run/v1alpha1/posts/${name}/${publish ? "publish" : "unpublish"}`, + ); + } + + async createPostFromFile( + file: TFile, + content: Content, + frontmatter: Record, + ): Promise { + const params: Post = { + apiVersion: "content.halo.run/v1alpha1", + kind: "Post", + metadata: { + annotations: { + "content.halo.run/content-json": JSON.stringify(content), + }, + name: randomUUID(), + }, + spec: { + allowComment: true, + baseSnapshot: "", + categories: [], + cover: (frontmatter.cover as string) || "", + deleted: false, + excerpt: { + autoGenerate: frontmatter.excerpt ? false : true, + raw: (frontmatter.excerpt as string) || "", + }, + headSnapshot: "", + htmlMetas: [], + owner: "", + pinned: false, + priority: 0, + publish: false, + publishTime: "", + releaseSnapshot: "", + slug: (frontmatter.slug as string) || "", + tags: [], + template: "", + title: (frontmatter.title as string) || file.basename, + visible: "PUBLIC", + }, + }; + + return await this.createPost(params); + } +} diff --git a/obsidian-halo/src/services/taxonomy-service.ts b/obsidian-halo/src/services/taxonomy-service.ts new file mode 100644 index 0000000..06c84fa --- /dev/null +++ b/obsidian-halo/src/services/taxonomy-service.ts @@ -0,0 +1,138 @@ +import { slugify } from "transliteration"; +import type { HaloClient } from "./client"; +import type { Category, Tag } from "./types"; + +export class TaxonomyService { + constructor(private client: HaloClient) {} + + async getCategories(): Promise { + const data = await this.client.get<{ items: Category[] }>("/apis/content.halo.run/v1alpha1/categories"); + return data.items; + } + + async getTags(): Promise { + const data = await this.client.get<{ items: Tag[] }>("/apis/content.halo.run/v1alpha1/tags"); + return data.items; + } + + async getCategoryNames(displayNames: string[]): Promise { + const allCategories = await this.getCategories(); + + const notExistDisplayNames = displayNames.filter( + (name) => !allCategories.find((item) => item.spec.displayName === name), + ); + + const newCategories = await Promise.all( + notExistDisplayNames.map((name, index) => + this.createCategory(name, slugify(name, { trim: true }), allCategories.length + index), + ), + ); + + 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.metadata.name)]; + } + + async getCategoryDisplayNames(names?: string[]): Promise { + if (!names || names.length === 0) return []; + 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[]; + } + + async createCategory(displayName: string, slug: string, priority: number): Promise { + return await this.client.post("/apis/content.halo.run/v1alpha1/categories", { + spec: { + displayName, + slug, + description: "", + cover: "", + template: "", + priority, + children: [], + }, + apiVersion: "content.halo.run/v1alpha1", + kind: "Category", + metadata: { name: "", generateName: "category-" }, + }); + } + + async updateCategory(name: string, displayName: string, slug: string, priority: number): Promise { + const category = await this.client.get(`/apis/content.halo.run/v1alpha1/categories/${name}`); + category.spec.displayName = displayName; + category.spec.slug = slug; + category.spec.priority = priority; + await this.client.put(`/apis/content.halo.run/v1alpha1/categories/${name}`, category); + } + + async deleteCategory(name: string): Promise { + await this.client.delete(`/apis/content.halo.run/v1alpha1/categories/${name}`); + } + + async getTagNames(displayNames: string[]): Promise { + const allTags = await this.getTags(); + + const notExistDisplayNames = displayNames.filter( + (name) => !allTags.find((item) => item.spec.displayName === name), + ); + + const newTags = await Promise.all( + notExistDisplayNames.map((name) => this.createTag(name, slugify(name, { trim: true }), "#ffffff")), + ); + + 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.metadata.name)]; + } + + async getTagDisplayNames(names?: string[]): Promise { + if (!names || names.length === 0) return []; + 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[]; + } + + async createTag(displayName: string, slug: string, color: string): Promise { + return await this.client.post("/apis/content.halo.run/v1alpha1/tags", { + spec: { + displayName, + slug, + color: color || "#ffffff", + cover: "", + }, + apiVersion: "content.halo.run/v1alpha1", + kind: "Tag", + metadata: { name: "", generateName: "tag-" }, + }); + } + + async updateTag(name: string, displayName: string, slug: string, color: string): Promise { + const tag = await this.client.get(`/apis/content.halo.run/v1alpha1/tags/${name}`); + tag.spec.displayName = displayName; + tag.spec.slug = slug; + tag.spec.color = color || "#ffffff"; + await this.client.put(`/apis/content.halo.run/v1alpha1/tags/${name}`, tag); + } + + async deleteTag(name: string): Promise { + await this.client.delete(`/apis/content.halo.run/v1alpha1/tags/${name}`); + } +} diff --git a/obsidian-halo/src/services/types.ts b/obsidian-halo/src/services/types.ts new file mode 100644 index 0000000..83cf101 --- /dev/null +++ b/obsidian-halo/src/services/types.ts @@ -0,0 +1,74 @@ +export interface Post { + apiVersion: string; + kind: string; + metadata: { + annotations: Record; + name: string; + }; + spec: { + allowComment: boolean; + baseSnapshot: string; + categories: string[]; + cover: string; + deleted: boolean; + excerpt: { + autoGenerate: boolean; + raw: string; + }; + headSnapshot: string; + htmlMetas: unknown[]; + owner: string; + pinned: boolean; + priority: number; + publish: boolean; + publishTime: string; + releaseSnapshot: string; + slug: string; + tags: string[]; + template: string; + title: string; + visible: string; + }; +} + +export interface Snapshot { + metadata: { + annotations?: Record; + }; + spec?: { + rawType?: string; + }; +} + +export interface Content { + rawType: string; + raw: string; + content: string; +} + +export interface Category { + metadata: { + name: string; + }; + spec: { + displayName: string; + slug: string; + description: string; + cover: string; + template: string; + priority: number; + children: unknown[]; + }; +} + +export interface Tag { + metadata: { + name: string; + }; + spec: { + displayName: string; + slug: string; + color: string; + cover: string; + }; +}