feat(halo): 添加 Halo 客户端、内容服务和图像服务,支持文章发布和管理功能
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"recentFiles": [
|
||||
{
|
||||
"basename": "DeepSeek-V4博客大纲",
|
||||
"path": "博客/其他/DeepSeek-V4博客大纲.md"
|
||||
},
|
||||
{
|
||||
"basename": "DeepSeek-V4全面解析",
|
||||
"path": "博客/DeepSeek-V4全面解析.md"
|
||||
|
||||
Vendored
+11
-27
@@ -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"
|
||||
]
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"git.ignoreLimitWarning": true
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
|
||||
constructor(site: HaloSite) {
|
||||
this.baseUrl = site.url;
|
||||
this.headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${site.token}`,
|
||||
};
|
||||
}
|
||||
|
||||
private handleResponse<T>(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<T>(path: string, options: { method: string; body?: object }): Promise<T> {
|
||||
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<T>({ 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<T>(path: string): Promise<T> {
|
||||
return this.request<T>(path, { method: "GET" });
|
||||
}
|
||||
|
||||
async post<T>(path: string, body: object): Promise<T> {
|
||||
return this.request<T>(path, { method: "POST", body });
|
||||
}
|
||||
|
||||
async put<T>(path: string, body: object): Promise<T> {
|
||||
return this.request<T>(path, { method: "PUT", body });
|
||||
}
|
||||
|
||||
async delete(path: string): Promise<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<number, string> = {
|
||||
400: "请求参数错误",
|
||||
401: "认证失败",
|
||||
403: "权限不足",
|
||||
404: "资源不存在",
|
||||
500: "服务器内部错误",
|
||||
};
|
||||
const message = messages[statusCode] || `HTTP 错误 (${statusCode})`;
|
||||
return new HttpError(statusCode, message, response);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<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("文章尚未发布到 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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
await this.taxonomyService.createTag(displayName, slug, color);
|
||||
}
|
||||
|
||||
async updateTag(name: string, displayName: string, slug: string, color: string): Promise<void> {
|
||||
await this.taxonomyService.updateTag(name, displayName, slug, color);
|
||||
}
|
||||
|
||||
async deleteTag(name: string): Promise<void> {
|
||||
await this.taxonomyService.deleteTag(name);
|
||||
}
|
||||
|
||||
async createCategory(displayName: string, slug: string, priority: number): Promise<void> {
|
||||
await this.taxonomyService.createCategory(displayName, slug, priority);
|
||||
}
|
||||
|
||||
async updateCategory(name: string, displayName: string, slug: string, priority: number): Promise<void> {
|
||||
await this.taxonomyService.updateCategory(name, displayName, slug, priority);
|
||||
}
|
||||
|
||||
async deleteCategory(name: string): Promise<void> {
|
||||
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;
|
||||
@@ -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<string, string>();
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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<Post>(`/apis/uc.api.content.halo.run/v1alpha1/posts/${name}`);
|
||||
|
||||
const snapshot = await this.client.get<Snapshot>(
|
||||
`/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<Post> {
|
||||
return await this.client.post<Post>("/apis/uc.api.content.halo.run/v1alpha1/posts", params);
|
||||
}
|
||||
|
||||
async updatePost(name: string, params: Post): Promise<void> {
|
||||
await this.client.put(`/apis/uc.api.content.halo.run/v1alpha1/posts/${name}`, params);
|
||||
}
|
||||
|
||||
async deletePost(name: string): Promise<void> {
|
||||
await this.client.delete(`/apis/uc.api.content.halo.run/v1alpha1/posts/${name}`);
|
||||
}
|
||||
|
||||
async changePublishStatus(name: string, publish: boolean): Promise<void> {
|
||||
await this.client.putVoid(
|
||||
`/apis/uc.api.content.halo.run/v1alpha1/posts/${name}/${publish ? "publish" : "unpublish"}`,
|
||||
);
|
||||
}
|
||||
|
||||
async createPostFromFile(
|
||||
file: TFile,
|
||||
content: Content,
|
||||
frontmatter: Record<string, unknown>,
|
||||
): Promise<Post> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Category[]> {
|
||||
const data = await this.client.get<{ items: Category[] }>("/apis/content.halo.run/v1alpha1/categories");
|
||||
return data.items;
|
||||
}
|
||||
|
||||
async getTags(): Promise<Tag[]> {
|
||||
const data = await this.client.get<{ items: Tag[] }>("/apis/content.halo.run/v1alpha1/tags");
|
||||
return data.items;
|
||||
}
|
||||
|
||||
async getCategoryNames(displayNames: string[]): Promise<string[]> {
|
||||
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<string[]> {
|
||||
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<Category> {
|
||||
return await this.client.post<Category>("/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<void> {
|
||||
const category = await this.client.get<Category>(`/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<void> {
|
||||
await this.client.delete(`/apis/content.halo.run/v1alpha1/categories/${name}`);
|
||||
}
|
||||
|
||||
async getTagNames(displayNames: string[]): Promise<string[]> {
|
||||
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<string[]> {
|
||||
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<Tag> {
|
||||
return await this.client.post<Tag>("/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<void> {
|
||||
const tag = await this.client.get<Tag>(`/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<void> {
|
||||
await this.client.delete(`/apis/content.halo.run/v1alpha1/tags/${name}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
export interface Post {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
metadata: {
|
||||
annotations: Record<string, string>;
|
||||
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<string, string>;
|
||||
};
|
||||
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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user