feat(halo): 新增文章搜索和导出功能

- 添加文章搜索命令,支持按关键词筛选和发布状态过滤
- 新增文章导出功能,支持导出为 Markdown 和 JSON 格式
- 扩展国际化配置,添加相关翻译文本
- 更新功能增强计划文档,标记已完成功能
- 移除不再需要的下载信息和插件清单文件
This commit is contained in:
2026-04-26 17:35:37 +08:00
parent 5c4a16dc3a
commit b72f36926a
16 changed files with 565 additions and 666 deletions
+89
View File
@@ -0,0 +1,89 @@
import i18next from "i18next";
import { Notice, requestUrl } from "obsidian";
import { openSiteSelectionModal } from "../site-selection-modal";
import type HaloPlugin from "../main";
import type { HaloSite } from "../settings";
export async function exportPostAsMarkdown(plugin: HaloPlugin, postName: string, title: string): Promise<void> {
try {
let site = plugin.settings.sites[0];
if (plugin.settings.sites.length > 1) {
site = await openSiteSelectionModal(plugin);
}
const headers = {
Authorization: `Bearer ${site.token}`,
};
const snapshot = (await requestUrl({
url: `${site.url}/apis/uc.api.content.halo.run/v1alpha1/posts/${postName}/draft?patched=true`,
headers,
}).json) as any;
const { "content.halo.run/patched-raw": raw } = snapshot?.metadata?.annotations || {};
if (!raw) {
new Notice(i18next.t("export.error_no_content"));
return;
}
const content = `---
title: ${title}
---
${raw}`;
const fileName = `${title}.md`;
const file = await plugin.app.vault.create(fileName, content);
plugin.app.workspace.getLeaf().openFile(file);
new Notice(i18next.t("export.notice_export_success", { fileName }));
} catch (error) {
console.error("[HaloPlugin] Export as markdown failed:", error);
new Notice(i18next.t("export.error_export_failed"));
}
}
export async function exportPostAsJson(plugin: HaloPlugin, postName: string, title: string): Promise<void> {
try {
let site = plugin.settings.sites[0];
if (plugin.settings.sites.length > 1) {
site = await openSiteSelectionModal(plugin);
}
const headers = {
Authorization: `Bearer ${site.token}`,
};
const post = (await requestUrl({
url: `${site.url}/apis/uc.api.content.halo.run/v1alpha1/posts/${postName}`,
headers,
}).json) as any;
const snapshot = (await requestUrl({
url: `${site.url}/apis/uc.api.content.halo.run/v1alpha1/posts/${postName}/draft?patched=true`,
headers,
}).json) as any;
const exportData = {
post: post,
content: {
raw: snapshot?.metadata?.annotations?.["content.halo.run/patched-raw"] || "",
content: snapshot?.metadata?.annotations?.["content.halo.run/patched-content"] || "",
rawType: snapshot?.spec?.rawType || "markdown",
},
exportedAt: new Date().toISOString(),
};
const fileName = `${title}.json`;
const file = await plugin.app.vault.create(fileName, JSON.stringify(exportData, null, 2));
plugin.app.workspace.getLeaf().openFile(file);
new Notice(i18next.t("export.notice_export_success", { fileName }));
} catch (error) {
console.error("[HaloPlugin] Export as JSON failed:", error);
new Notice(i18next.t("export.error_export_failed"));
}
}
@@ -0,0 +1,24 @@
import i18next from "i18next";
import { Notice } from "obsidian";
import { openSiteSelectionModal } from "../site-selection-modal";
import { openSearchModal } from "../modals/search-modal";
import type HaloPlugin from "../main";
export async function searchPosts(plugin: HaloPlugin): Promise<void> {
try {
if (plugin.settings.sites.length === 0) {
new Notice(i18next.t("command.pull_post.error_no_sites"));
return;
}
let site = plugin.settings.sites[0];
if (plugin.settings.sites.length > 1) {
site = await openSiteSelectionModal(plugin);
}
openSearchModal(plugin, site);
} catch (error) {
console.error("[HaloPlugin] Search posts failed:", error);
new Notice(i18next.t("common.error_connection_failed"));
}
}
+26
View File
@@ -36,6 +36,17 @@
},
"manage_categories": {
"name": "Manage categories"
},
"search_posts": {
"name": "Search Halo posts"
},
"export_markdown": {
"name": "Export as Markdown",
"error_not_published": "This document is not published to Halo"
},
"export_json": {
"name": "Export as JSON",
"error_not_published": "This document is not published to Halo"
}
},
"settings": {
@@ -223,6 +234,21 @@
"notice_delete_success": "Category deleted successfully",
"error_delete_failed": "Failed to delete category"
},
"search_modal": {
"title": "Search Halo Posts",
"search_placeholder": "Enter keywords to search...",
"loading": "Loading...",
"no_results": "No matching posts found",
"result_count": "Found {count} posts",
"button_view": "View",
"button_export_md": "Export MD",
"button_export_json": "Export JSON"
},
"export": {
"error_no_content": "Unable to get post content",
"error_export_failed": "Export failed",
"notice_export_success": "Exported to file: {fileName}"
},
"common": {
"error_connection_failed": "Connection failed",
"button_close": "Close",
+26
View File
@@ -36,6 +36,17 @@
},
"manage_categories": {
"name": "管理分类"
},
"search_posts": {
"name": "搜索 Halo 文章"
},
"export_markdown": {
"name": "导出为 Markdown",
"error_not_published": "此文档还未发布到 Halo"
},
"export_json": {
"name": "导出为 JSON",
"error_not_published": "此文档还未发布到 Halo"
}
},
"settings": {
@@ -223,6 +234,21 @@
"notice_delete_success": "分类删除成功",
"error_delete_failed": "分类删除失败"
},
"search_modal": {
"title": "搜索 Halo 文章",
"search_placeholder": "输入关键词搜索...",
"loading": "加载中...",
"no_results": "没有找到匹配的文章",
"result_count": "找到 {count} 篇文章",
"button_view": "查看",
"button_export_md": "导出 MD",
"button_export_json": "导出 JSON"
},
"export": {
"error_no_content": "无法获取文章内容",
"error_export_failed": "导出失败",
"notice_export_success": "已导出到文件: {fileName}"
},
"common": {
"error_connection_failed": "连接失败",
"button_close": "关闭",
+26
View File
@@ -36,6 +36,17 @@
},
"manage_categories": {
"name": "管理分類"
},
"search_posts": {
"name": "搜索 Halo 文章"
},
"export_markdown": {
"name": "導出為 Markdown",
"error_not_published": "此文件還未發佈到 Halo"
},
"export_json": {
"name": "導出為 JSON",
"error_not_published": "此文件還未發佈到 Halo"
}
},
"settings": {
@@ -223,6 +234,21 @@
"notice_delete_success": "分類刪除成功",
"error_delete_failed": "分類刪除失敗"
},
"search_modal": {
"title": "搜索 Halo 文章",
"search_placeholder": "輸入關鍵詞搜索...",
"loading": "加載中...",
"no_results": "沒有找到匹配的文章",
"result_count": "找到 {count} 篇文章",
"button_view": "查看",
"button_export_md": "導出 MD",
"button_export_json": "導出 JSON"
},
"export": {
"error_no_content": "無法獲取文章內容",
"error_export_failed": "導出失敗",
"notice_export_success": "已導出到文件: {fileName}"
},
"common": {
"error_connection_failed": "連接失敗",
"button_close": "關閉",
+40
View File
@@ -6,6 +6,8 @@ import { openPostSelectionModal } from "./post-selection-model";
import { importFromMarkdownFile } from "./commands/import-markdown";
import { deletePost } from "./commands/delete-post";
import { manageTags, manageCategories } from "./commands/manage-taxonomy";
import { exportPostAsMarkdown, exportPostAsJson } from "./commands/export-post";
import { searchPosts } from "./commands/search-posts";
import { DEFAULT_SETTINGS, type HaloSetting, HaloSettingTab, type HaloSite } from "./settings";
import HaloService from "./service";
import { openSiteSelectionModal } from "./site-selection-modal";
@@ -141,6 +143,44 @@ export default class HaloPlugin extends Plugin {
},
});
this.addCommand({
id: "search-posts",
name: i18next.t("command.search_posts.name"),
callback: async () => {
await searchPosts(this);
},
});
this.addCommand({
id: "export-post-as-markdown",
name: i18next.t("command.export_markdown.name"),
editorCallback: async () => {
const { activeEditor } = this.app.workspace;
if (!activeEditor || !activeEditor.file) return;
const matterData = this.app.metadataCache.getFileCache(activeEditor.file)?.frontmatter;
if (!matterData?.halo?.name) {
new Notice(i18next.t("command.export_markdown.error_not_published"));
return;
}
await exportPostAsMarkdown(this, matterData.halo.name, matterData.title || activeEditor.file.basename);
},
});
this.addCommand({
id: "export-post-as-json",
name: i18next.t("command.export_json.name"),
editorCallback: async () => {
const { activeEditor } = this.app.workspace;
if (!activeEditor || !activeEditor.file) return;
const matterData = this.app.metadataCache.getFileCache(activeEditor.file)?.frontmatter;
if (!matterData?.halo?.name) {
new Notice(i18next.t("command.export_json.error_not_published"));
return;
}
await exportPostAsJson(this, matterData.halo.name, matterData.title || activeEditor.file.basename);
},
});
this.addSettingTab(new HaloSettingTab(this));
}
+188
View File
@@ -0,0 +1,188 @@
import type { ListedPost } from "@halo-dev/api-client";
import i18next from "i18next";
import { Modal, Notice, Setting, requestUrl } from "obsidian";
import type HaloPlugin from "../main";
import type { HaloSite } from "../settings";
import { exportPostAsMarkdown, exportPostAsJson } from "../commands/export-post";
export function openSearchModal(plugin: HaloPlugin, site: HaloSite): void {
const modal = new SearchModal(plugin, site);
modal.open();
}
class SearchModal extends Modal {
private posts: ListedPost[] = [];
private filteredPosts: ListedPost[] = [];
private searchKeyword = "";
private filter: "all" | "published" | "draft" = "all";
constructor(
private readonly plugin: HaloPlugin,
private readonly site: HaloSite,
) {
super(app);
}
async onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", {
text: i18next.t("search_modal.title"),
});
const searchInput = new Setting(contentEl)
.setName(i18next.t("search_modal.search_placeholder"))
.addText((text) => {
text.setPlaceholder(i18next.t("search_modal.search_placeholder"))
.onChange((value) => {
this.searchKeyword = value;
this.filterPosts();
this.renderResults();
});
});
const filterEl = contentEl.createDiv("search-filter");
const allButton = filterEl.createEl("button", {
text: i18next.t("post_selection_modal.filter_all"),
cls: this.filter === "all" ? "active" : "",
});
allButton.addEventListener("click", async () => {
this.filter = "all";
await this.loadPosts();
this.renderResults();
});
const publishedButton = filterEl.createEl("button", {
text: i18next.t("post_selection_modal.filter_published"),
cls: this.filter === "published" ? "active" : "",
});
publishedButton.addEventListener("click", async () => {
this.filter = "published";
await this.loadPosts();
this.renderResults();
});
const draftButton = filterEl.createEl("button", {
text: i18next.t("post_selection_modal.filter_draft"),
cls: this.filter === "draft" ? "active" : "",
});
draftButton.addEventListener("click", async () => {
this.filter = "draft";
await this.loadPosts();
this.renderResults();
});
const resultsEl = contentEl.createDiv("search-results");
resultsEl.createEl("p", { text: i18next.t("search_modal.loading") });
await this.loadPosts();
this.renderResults();
}
private async loadPosts() {
try {
let labelSelector = "content.halo.run/deleted=false";
if (this.filter === "published") {
labelSelector = "content.halo.run/deleted=false,content.halo.run/published=true";
} else if (this.filter === "draft") {
labelSelector = "content.halo.run/deleted=false,content.halo.run/published=false";
}
const response = await requestUrl({
url: `${this.site.url}/apis/uc.api.content.halo.run/v1alpha1/posts?labelSelector=${encodeURIComponent(labelSelector)}&size=100`,
headers: {
Authorization: `Bearer ${this.site.token}`,
},
});
this.posts = response.json.items || [];
this.filterPosts();
} catch (error) {
new Notice(i18next.t("common.error_connection_failed"));
}
}
private filterPosts() {
if (!this.searchKeyword) {
this.filteredPosts = this.posts;
return;
}
const keyword = this.searchKeyword.toLowerCase();
this.filteredPosts = this.posts.filter((post) => {
const title = (post.post.spec.title || "").toLowerCase();
const slug = (post.post.spec.slug || "").toLowerCase();
return title.includes(keyword) || slug.includes(keyword);
});
}
private renderResults() {
const { contentEl } = this;
const resultsEl = contentEl.querySelector(".search-results") as HTMLElement;
if (!resultsEl) return;
resultsEl.empty();
if (this.filteredPosts.length === 0) {
resultsEl.createEl("p", { text: i18next.t("search_modal.no_results") });
return;
}
resultsEl.createEl("p", {
text: i18next.t("search_modal.result_count", { count: this.filteredPosts.length }),
cls: "result-count"
});
for (const post of this.filteredPosts) {
this.renderPostItem(post, resultsEl);
}
}
private renderPostItem(post: ListedPost, container: HTMLElement) {
const postEl = container.createDiv("search-result-item");
const headerEl = postEl.createDiv("result-header");
const titleEl = headerEl.createEl("h3", {
text: post.post.spec.title || i18next.t("post_selection_modal.untitled"),
});
const statusEl = headerEl.createSpan({
text: post.post.spec.publish ? i18next.t("post_selection_modal.status_published") : i18next.t("post_selection_modal.status_draft"),
cls: post.post.spec.publish ? "status-published" : "status-draft",
});
const metaEl = postEl.createDiv("result-meta");
metaEl.createEl("span", { text: `Slug: ${post.post.spec.slug}` });
metaEl.createEl("span", { text: ` | ${new Date(post.post.metadata.creationTimestamp).toLocaleDateString()}` });
const actionsEl = postEl.createDiv("result-actions");
actionsEl.createEl("button", {
text: i18next.t("search_modal.button_view"),
}).addEventListener("click", () => {
window.open(`${this.site.url}/archives/${post.post.spec.slug}`, "_blank");
});
actionsEl.createEl("button", {
text: i18next.t("search_modal.button_export_md"),
}).addEventListener("click", async () => {
await exportPostAsMarkdown(this.plugin, post.post.metadata.name, post.post.spec.title);
});
actionsEl.createEl("button", {
text: i18next.t("search_modal.button_export_json"),
}).addEventListener("click", async () => {
await exportPostAsJson(this.plugin, post.post.metadata.name, post.post.spec.title);
});
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}