feat(halo): 添加 Halo 客户端、内容服务和图像服务,支持文章发布和管理功能

This commit is contained in:
2026-04-28 14:15:36 +08:00
parent b13cd32d6a
commit b7f6288492
11 changed files with 1062 additions and 27 deletions
+4
View File
@@ -1,5 +1,9 @@
{
"recentFiles": [
{
"basename": "DeepSeek-V4博客大纲",
"path": "博客/其他/DeepSeek-V4博客大纲.md"
},
{
"basename": "DeepSeek-V4全面解析",
"path": "博客/DeepSeek-V4全面解析.md"
+11 -27
View File
@@ -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 CodeVibe Coding 时代的 AI 编程利器.md",
"未命名.canvas"
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"git.ignoreLimitWarning": true
}
+124
View File
@@ -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",
};
}
}
+75
View File
@@ -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);
}
}
+402
View File
@@ -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 };
}
}
+101
View File
@@ -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}`);
}
}
+74
View File
@@ -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;
};
}