feat(halo): 新增文章搜索和导出功能
- 添加文章搜索命令,支持按关键词筛选和发布状态过滤 - 新增文章导出功能,支持导出为 Markdown 和 JSON 格式 - 扩展国际化配置,添加相关翻译文本 - 更新功能增强计划文档,标记已完成功能 - 移除不再需要的下载信息和插件清单文件
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "关闭",
|
||||
|
||||
@@ -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": "關閉",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user