From 704df2774f6d7a81088ea5216b74b0c424241da3 Mon Sep 17 00:00:00 2001 From: Satsuki Akiba Date: Mon, 9 Jun 2025 16:02:26 +0900 Subject: [PATCH] Add category deletion feature --- admin-panel/src/App.tsx | 231 +++++++++++++++++++++++++++++++++++++++- admin-server.ts | 80 ++++++++++++++ services/database.ts | 91 ++++++++++++++++ services/llm.ts | 13 ++- 4 files changed, 407 insertions(+), 8 deletions(-) diff --git a/admin-panel/src/App.tsx b/admin-panel/src/App.tsx index 9dfec8c..4f7090e 100644 --- a/admin-panel/src/App.tsx +++ b/admin-panel/src/App.tsx @@ -74,6 +74,17 @@ interface Setting { updatedAt: string; } +interface CategoryData { + feedCategories: string[]; + episodeCategories: string[]; + allCategories: string[]; +} + +interface CategoryCounts { + feedCount: number; + episodeCount: number; +} + function App() { const [feeds, setFeeds] = useState([]); const [stats, setStats] = useState(null); @@ -92,8 +103,10 @@ function App() { {}, ); const [editingSettings, setEditingSettings] = useState<{ [key: string]: string }>({}); + const [categories, setCategories] = useState({ feedCategories: [], episodeCategories: [], allCategories: [] }); + const [categoryCounts, setCategoryCounts] = useState<{ [category: string]: CategoryCounts }>({}); const [activeTab, setActiveTab] = useState< - "dashboard" | "feeds" | "episodes" | "env" | "settings" | "batch" | "requests" + "dashboard" | "feeds" | "episodes" | "env" | "settings" | "batch" | "requests" | "categories" >("dashboard"); useEffect(() => { @@ -103,7 +116,7 @@ function App() { const loadData = async () => { setLoading(true); try { - const [feedsRes, statsRes, envRes, settingsRes, requestsRes, episodesRes] = + const [feedsRes, statsRes, envRes, settingsRes, requestsRes, episodesRes, categoriesRes] = await Promise.all([ fetch("/api/admin/feeds"), fetch("/api/admin/stats"), @@ -111,6 +124,7 @@ function App() { fetch("/api/admin/settings"), fetch("/api/admin/feed-requests"), fetch("/api/admin/episodes"), + fetch("/api/admin/categories/all"), ]); if ( @@ -119,12 +133,13 @@ function App() { !envRes.ok || !settingsRes.ok || !requestsRes.ok || - !episodesRes.ok + !episodesRes.ok || + !categoriesRes.ok ) { throw new Error("Failed to load data"); } - const [feedsData, statsData, envData, settingsData, requestsData, episodesData] = + const [feedsData, statsData, envData, settingsData, requestsData, episodesData, categoriesData] = await Promise.all([ feedsRes.json(), statsRes.json(), @@ -132,6 +147,7 @@ function App() { settingsRes.json(), requestsRes.json(), episodesRes.json(), + categoriesRes.json(), ]); setFeeds(feedsData); @@ -140,6 +156,25 @@ function App() { setSettings(settingsData); setFeedRequests(requestsData); setEpisodes(episodesData); + setCategories(categoriesData); + + // Load category counts for all categories + const countsPromises = categoriesData.allCategories.map(async (category: string) => { + const res = await fetch(`/api/admin/categories/${encodeURIComponent(category)}/counts`); + if (res.ok) { + const counts = await res.json(); + return { category, counts }; + } + return { category, counts: { feedCount: 0, episodeCount: 0 } }; + }); + + const countsResults = await Promise.all(countsPromises); + const countsMap: { [category: string]: CategoryCounts } = {}; + countsResults.forEach(({ category, counts }) => { + countsMap[category] = counts; + }); + setCategoryCounts(countsMap); + setError(null); } catch (err) { setError("データの読み込みに失敗しました"); @@ -391,6 +426,41 @@ function App() { setEditingSettings({ ...editingSettings, [key]: value }); }; + const deleteCategory = async (category: string, target: "feeds" | "episodes" | "both") => { + const targetText = target === "both" ? "フィードとエピソード" : target === "feeds" ? "フィード" : "エピソード"; + const counts = categoryCounts[category] || { feedCount: 0, episodeCount: 0 }; + const totalCount = target === "both" ? counts.feedCount + counts.episodeCount : + target === "feeds" ? counts.feedCount : counts.episodeCount; + + if ( + !confirm( + `本当にカテゴリ「${category}」を${targetText}から削除しますか?\n\n${totalCount}件のアイテムが影響を受けます。この操作は取り消せません。` + ) + ) { + return; + } + + try { + const res = await fetch(`/api/admin/categories/${encodeURIComponent(category)}`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ target }), + }); + + const data = await res.json(); + + if (res.ok) { + setSuccess(data.message); + loadData(); // Reload data to update category list and counts + } else { + setError(data.error || "カテゴリ削除に失敗しました"); + } + } catch (err) { + setError("カテゴリ削除に失敗しました"); + console.error("Error deleting category:", err); + } + }; + const deleteEpisode = async (episodeId: string, episodeTitle: string) => { if ( !confirm( @@ -483,6 +553,12 @@ function App() { > 設定管理 + + )} + {isInEpisodes && ( + + )} + {(isInFeeds || isInEpisodes) && ( + + )} + + + ); + })} + + + )} + +
+

カテゴリ削除について

+
    +
  • フィードから削除: フィードのカテゴリのみを削除します
  • +
  • エピソードから削除: エピソードのカテゴリのみを削除します
  • +
  • すべてから削除: フィードとエピソード両方からカテゴリを削除します
  • +
  • 削除されたカテゴリは NULL に設定され、分類が解除されます
  • +
  • この操作は元に戻すことができません
  • +
+
+ + )} + {activeTab === "env" && ( <>

環境変数設定

diff --git a/admin-server.ts b/admin-server.ts index 0d69418..347507f 100644 --- a/admin-server.ts +++ b/admin-server.ts @@ -14,6 +14,11 @@ import { fetchEpisodesWithArticles, getAllCategories, getAllFeedsIncludingInactive, + getAllUsedCategories, + getCategoryCounts, + deleteCategoryFromBoth, + deleteFeedCategory, + deleteEpisodeCategory, getFeedByUrl, getFeedRequests, getFeedsByCategory, @@ -345,6 +350,81 @@ app.delete("/api/admin/episodes/:id", async (c) => { } }); +// Category management API endpoints +app.get("/api/admin/categories/all", async (c) => { + try { + const categories = await getAllUsedCategories(); + return c.json(categories); + } catch (error) { + console.error("Error fetching all used categories:", error); + return c.json({ error: "Failed to fetch categories" }, 500); + } +}); + +app.get("/api/admin/categories/:category/counts", async (c) => { + try { + const category = decodeURIComponent(c.req.param("category")); + const counts = await getCategoryCounts(category); + return c.json(counts); + } catch (error) { + console.error("Error fetching category counts:", error); + return c.json({ error: "Failed to fetch category counts" }, 500); + } +}); + +app.delete("/api/admin/categories/:category", async (c) => { + try { + const category = decodeURIComponent(c.req.param("category")); + const { target } = await c.req.json<{ target: "feeds" | "episodes" | "both" }>(); + + if (!category || category.trim() === "") { + return c.json({ error: "Category name is required" }, 400); + } + + if (!target || !["feeds", "episodes", "both"].includes(target)) { + return c.json({ error: "Valid target (feeds, episodes, or both) is required" }, 400); + } + + console.log(`🗑️ Admin deleting category "${category}" from ${target}`); + + let result; + if (target === "both") { + result = await deleteCategoryFromBoth(category); + return c.json({ + result: "DELETED", + message: `Category "${category}" deleted from feeds and episodes`, + category, + feedChanges: result.feedChanges, + episodeChanges: result.episodeChanges, + totalChanges: result.feedChanges + result.episodeChanges + }); + } else if (target === "feeds") { + const changes = await deleteFeedCategory(category); + return c.json({ + result: "DELETED", + message: `Category "${category}" deleted from feeds`, + category, + feedChanges: changes, + episodeChanges: 0, + totalChanges: changes + }); + } else if (target === "episodes") { + const changes = await deleteEpisodeCategory(category); + return c.json({ + result: "DELETED", + message: `Category "${category}" deleted from episodes`, + category, + feedChanges: 0, + episodeChanges: changes, + totalChanges: changes + }); + } + } catch (error) { + console.error("Error deleting category:", error); + return c.json({ error: "Failed to delete category" }, 500); + } +}); + // Database diagnostic endpoint app.get("/api/admin/db-diagnostic", async (c) => { try { diff --git a/services/database.ts b/services/database.ts index 9cf1774..81706fd 100644 --- a/services/database.ts +++ b/services/database.ts @@ -1633,6 +1633,97 @@ export async function updateEpisodeCategory( } } +// Category cleanup functions +export async function deleteFeedCategory(category: string): Promise { + try { + const stmt = db.prepare("UPDATE feeds SET category = NULL WHERE category = ?"); + const result = stmt.run(category); + return result.changes; + } catch (error) { + console.error("Error deleting feed category:", error); + throw error; + } +} + +export async function deleteEpisodeCategory(category: string): Promise { + try { + const stmt = db.prepare("UPDATE episodes SET category = NULL WHERE category = ?"); + const result = stmt.run(category); + return result.changes; + } catch (error) { + console.error("Error deleting episode category:", error); + throw error; + } +} + +export async function deleteCategoryFromBoth(category: string): Promise<{feedChanges: number, episodeChanges: number}> { + try { + db.exec("BEGIN TRANSACTION"); + + const feedChanges = await deleteFeedCategory(category); + const episodeChanges = await deleteEpisodeCategory(category); + + db.exec("COMMIT"); + + return { feedChanges, episodeChanges }; + } catch (error) { + db.exec("ROLLBACK"); + console.error("Error deleting category from both tables:", error); + throw error; + } +} + +export async function getAllUsedCategories(): Promise<{feedCategories: string[], episodeCategories: string[], allCategories: string[]}> { + try { + // Get feed categories + const feedCatStmt = db.prepare( + "SELECT DISTINCT category FROM feeds WHERE category IS NOT NULL AND category != '' ORDER BY category" + ); + const feedCatRows = feedCatStmt.all() as any[]; + const feedCategories = feedCatRows.map(row => row.category); + + // Get episode categories + const episodeCatStmt = db.prepare( + "SELECT DISTINCT category FROM episodes WHERE category IS NOT NULL AND category != '' ORDER BY category" + ); + const episodeCatRows = episodeCatStmt.all() as any[]; + const episodeCategories = episodeCatRows.map(row => row.category); + + // Get all unique categories + const allCategoriesSet = new Set([...feedCategories, ...episodeCategories]); + const allCategories = Array.from(allCategoriesSet).sort(); + + return { + feedCategories, + episodeCategories, + allCategories + }; + } catch (error) { + console.error("Error getting all used categories:", error); + throw error; + } +} + +export async function getCategoryCounts(category: string): Promise<{feedCount: number, episodeCount: number}> { + try { + // Count feeds with this category + const feedCountStmt = db.prepare("SELECT COUNT(*) as count FROM feeds WHERE category = ?"); + const feedCountResult = feedCountStmt.get(category) as { count: number }; + + // Count episodes with this category + const episodeCountStmt = db.prepare("SELECT COUNT(*) as count FROM episodes WHERE category = ?"); + const episodeCountResult = episodeCountStmt.get(category) as { count: number }; + + return { + feedCount: feedCountResult.count, + episodeCount: episodeCountResult.count + }; + } catch (error) { + console.error("Error getting category counts:", error); + throw error; + } +} + // Migration function to classify existing episodes without categories export async function migrateEpisodesWithCategories(): Promise { try { diff --git a/services/llm.ts b/services/llm.ts index 313ecb4..8c1dc81 100644 --- a/services/llm.ts +++ b/services/llm.ts @@ -127,7 +127,10 @@ ${articleDetails} try { const response = await openai.chat.completions.create({ model: config.openai.modelName, - messages: [{ role: "system", content: prompt.trim() }, {role:"user", content: sendContent.trim()}], + messages: [ + { role: "system", content: prompt.trim() }, + { role: "user", content: sendContent.trim() }, + ], temperature: 0.6, }); @@ -171,7 +174,9 @@ export async function openAI_ClassifyEpisode( } const prompt = ` -ポッドキャストエピソードの情報を見て、適切なトピックカテゴリに分類してください。 +以下のポッドキャストエピソードの情報を見て、適切なトピックカテゴリに分類してください。 + +${textForClassification} 以下のカテゴリから1つを選択してください: - テクノロジー @@ -191,8 +196,8 @@ export async function openAI_ClassifyEpisode( try { const response = await openai.chat.completions.create({ model: config.openai.modelName, - messages: [{ role: "system", content: prompt.trim() }, {role: "user", content: textForClassification.trim()}], - temperature: 0.3, + messages: [{ role: "user", content: prompt.trim() }], + temperature: 0.2, }); const category = response.choices[0]?.message?.content?.trim();