Update admin panel and server configuration
This commit is contained in:
@ -64,10 +64,21 @@ interface Episode {
|
|||||||
feedCategory?: string;
|
feedCategory?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Setting {
|
||||||
|
key: string;
|
||||||
|
value: string | null;
|
||||||
|
isCredential: boolean;
|
||||||
|
description: string;
|
||||||
|
defaultValue: string;
|
||||||
|
required: boolean;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [feeds, setFeeds] = useState<Feed[]>([]);
|
const [feeds, setFeeds] = useState<Feed[]>([]);
|
||||||
const [stats, setStats] = useState<Stats | null>(null);
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
const [envVars, setEnvVars] = useState<EnvVars>({});
|
const [envVars, setEnvVars] = useState<EnvVars>({});
|
||||||
|
const [settings, setSettings] = useState<Setting[]>([]);
|
||||||
const [feedRequests, setFeedRequests] = useState<FeedRequest[]>([]);
|
const [feedRequests, setFeedRequests] = useState<FeedRequest[]>([]);
|
||||||
const [episodes, setEpisodes] = useState<Episode[]>([]);
|
const [episodes, setEpisodes] = useState<Episode[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -80,8 +91,9 @@ function App() {
|
|||||||
const [approvalNotes, setApprovalNotes] = useState<{ [key: string]: string }>(
|
const [approvalNotes, setApprovalNotes] = useState<{ [key: string]: string }>(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
const [editingSettings, setEditingSettings] = useState<{ [key: string]: string }>({});
|
||||||
const [activeTab, setActiveTab] = useState<
|
const [activeTab, setActiveTab] = useState<
|
||||||
"dashboard" | "feeds" | "episodes" | "env" | "batch" | "requests"
|
"dashboard" | "feeds" | "episodes" | "env" | "settings" | "batch" | "requests"
|
||||||
>("dashboard");
|
>("dashboard");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -91,11 +103,12 @@ function App() {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [feedsRes, statsRes, envRes, requestsRes, episodesRes] =
|
const [feedsRes, statsRes, envRes, settingsRes, requestsRes, episodesRes] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetch("/api/admin/feeds"),
|
fetch("/api/admin/feeds"),
|
||||||
fetch("/api/admin/stats"),
|
fetch("/api/admin/stats"),
|
||||||
fetch("/api/admin/env"),
|
fetch("/api/admin/env"),
|
||||||
|
fetch("/api/admin/settings"),
|
||||||
fetch("/api/admin/feed-requests"),
|
fetch("/api/admin/feed-requests"),
|
||||||
fetch("/api/admin/episodes"),
|
fetch("/api/admin/episodes"),
|
||||||
]);
|
]);
|
||||||
@ -104,17 +117,19 @@ function App() {
|
|||||||
!feedsRes.ok ||
|
!feedsRes.ok ||
|
||||||
!statsRes.ok ||
|
!statsRes.ok ||
|
||||||
!envRes.ok ||
|
!envRes.ok ||
|
||||||
|
!settingsRes.ok ||
|
||||||
!requestsRes.ok ||
|
!requestsRes.ok ||
|
||||||
!episodesRes.ok
|
!episodesRes.ok
|
||||||
) {
|
) {
|
||||||
throw new Error("Failed to load data");
|
throw new Error("Failed to load data");
|
||||||
}
|
}
|
||||||
|
|
||||||
const [feedsData, statsData, envData, requestsData, episodesData] =
|
const [feedsData, statsData, envData, settingsData, requestsData, episodesData] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
feedsRes.json(),
|
feedsRes.json(),
|
||||||
statsRes.json(),
|
statsRes.json(),
|
||||||
envRes.json(),
|
envRes.json(),
|
||||||
|
settingsRes.json(),
|
||||||
requestsRes.json(),
|
requestsRes.json(),
|
||||||
episodesRes.json(),
|
episodesRes.json(),
|
||||||
]);
|
]);
|
||||||
@ -122,6 +137,7 @@ function App() {
|
|||||||
setFeeds(feedsData);
|
setFeeds(feedsData);
|
||||||
setStats(statsData);
|
setStats(statsData);
|
||||||
setEnvVars(envData);
|
setEnvVars(envData);
|
||||||
|
setSettings(settingsData);
|
||||||
setFeedRequests(requestsData);
|
setFeedRequests(requestsData);
|
||||||
setEpisodes(episodesData);
|
setEpisodes(episodesData);
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -338,6 +354,43 @@ function App() {
|
|||||||
setApprovalNotes({ ...approvalNotes, [requestId]: notes });
|
setApprovalNotes({ ...approvalNotes, [requestId]: notes });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateSetting = async (key: string, value: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/settings/${key}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ value }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setSuccess(data.message);
|
||||||
|
setEditingSettings({ ...editingSettings, [key]: "" });
|
||||||
|
loadData();
|
||||||
|
} else {
|
||||||
|
setError(data.error || "設定更新に失敗しました");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError("設定更新に失敗しました");
|
||||||
|
console.error("Error updating setting:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditingSetting = (key: string, currentValue: string | null) => {
|
||||||
|
setEditingSettings({ ...editingSettings, [key]: currentValue || "" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEditingSetting = (key: string) => {
|
||||||
|
const newEditing = { ...editingSettings };
|
||||||
|
delete newEditing[key];
|
||||||
|
setEditingSettings(newEditing);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEditingValue = (key: string, value: string) => {
|
||||||
|
setEditingSettings({ ...editingSettings, [key]: value });
|
||||||
|
};
|
||||||
|
|
||||||
const deleteEpisode = async (episodeId: string, episodeTitle: string) => {
|
const deleteEpisode = async (episodeId: string, episodeTitle: string) => {
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
@ -424,6 +477,12 @@ function App() {
|
|||||||
>
|
>
|
||||||
フィード承認
|
フィード承認
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn ${activeTab === "settings" ? "btn-primary" : "btn-secondary"}`}
|
||||||
|
onClick={() => setActiveTab("settings")}
|
||||||
|
>
|
||||||
|
設定管理
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`btn ${activeTab === "env" ? "btn-primary" : "btn-secondary"}`}
|
className={`btn ${activeTab === "env" ? "btn-primary" : "btn-secondary"}`}
|
||||||
onClick={() => setActiveTab("env")}
|
onClick={() => setActiveTab("env")}
|
||||||
@ -1087,6 +1146,172 @@ function App() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === "settings" && (
|
||||||
|
<>
|
||||||
|
<h3>設定管理</h3>
|
||||||
|
<p style={{ marginBottom: "20px", color: "#7f8c8d" }}>
|
||||||
|
システム設定を管理できます。設定はデータベースに保存され、アプリケーション全体に適用されます。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "20px" }}>
|
||||||
|
<div className="stats-grid" style={{ gridTemplateColumns: "repeat(4, 1fr)" }}>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="value">{settings.length}</div>
|
||||||
|
<div className="label">総設定数</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="value">
|
||||||
|
{settings.filter(s => s.value !== null && s.value !== "").length}
|
||||||
|
</div>
|
||||||
|
<div className="label">設定済み</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="value">
|
||||||
|
{settings.filter(s => s.required).length}
|
||||||
|
</div>
|
||||||
|
<div className="label">必須設定</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="value">
|
||||||
|
{settings.filter(s => s.isCredential).length}
|
||||||
|
</div>
|
||||||
|
<div className="label">認証情報</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-list">
|
||||||
|
{settings.map((setting) => (
|
||||||
|
<div key={setting.key} className="setting-item" style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "16px",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: "4px",
|
||||||
|
marginBottom: "12px",
|
||||||
|
backgroundColor: setting.required && (!setting.value || setting.value === "") ? "#fff5f5" : "#fff"
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "4px" }}>
|
||||||
|
<h4 style={{ margin: 0, fontSize: "16px" }}>{setting.key}</h4>
|
||||||
|
{setting.required && (
|
||||||
|
<span style={{
|
||||||
|
padding: "2px 6px",
|
||||||
|
background: "#dc3545",
|
||||||
|
color: "white",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "10px"
|
||||||
|
}}>必須</span>
|
||||||
|
)}
|
||||||
|
{setting.isCredential && (
|
||||||
|
<span style={{
|
||||||
|
padding: "2px 6px",
|
||||||
|
background: "#ffc107",
|
||||||
|
color: "black",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "10px"
|
||||||
|
}}>認証情報</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: "0 0 8px 0", fontSize: "14px", color: "#666" }}>
|
||||||
|
{setting.description}
|
||||||
|
</p>
|
||||||
|
<div style={{ fontSize: "12px", color: "#999" }}>
|
||||||
|
デフォルト値: {setting.defaultValue || "なし"} |
|
||||||
|
最終更新: {new Date(setting.updatedAt).toLocaleString("ja-JP")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingSettings[setting.key] !== undefined ? (
|
||||||
|
<div style={{ marginTop: "8px", display: "flex", gap: "8px", alignItems: "center" }}>
|
||||||
|
<input
|
||||||
|
type={setting.isCredential ? "password" : "text"}
|
||||||
|
value={editingSettings[setting.key]}
|
||||||
|
onChange={(e) => updateEditingValue(setting.key, e.target.value)}
|
||||||
|
placeholder={setting.defaultValue || "値を入力..."}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "6px 10px",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "14px"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-success"
|
||||||
|
onClick={() => updateSetting(setting.key, editingSettings[setting.key])}
|
||||||
|
style={{ fontSize: "12px", padding: "6px 12px" }}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => cancelEditingSetting(setting.key)}
|
||||||
|
style={{ fontSize: "12px", padding: "6px 12px" }}
|
||||||
|
>
|
||||||
|
キャンセル
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ marginTop: "8px", display: "flex", alignItems: "center", gap: "12px" }}>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "6px 10px",
|
||||||
|
background: "#f8f9fa",
|
||||||
|
border: "1px solid #e9ecef",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "14px",
|
||||||
|
minHeight: "32px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center"
|
||||||
|
}}>
|
||||||
|
{setting.value === null || setting.value === "" ? (
|
||||||
|
<span style={{ color: "#dc3545", fontStyle: "italic" }}>未設定</span>
|
||||||
|
) : setting.isCredential ? (
|
||||||
|
<span style={{ color: "#666" }}>••••••••</span>
|
||||||
|
) : (
|
||||||
|
setting.value
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => startEditingSetting(setting.key, setting.value)}
|
||||||
|
style={{ fontSize: "12px", padding: "6px 12px" }}
|
||||||
|
>
|
||||||
|
編集
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "20px",
|
||||||
|
padding: "16px",
|
||||||
|
background: "#f8f9fa",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h4>設定について</h4>
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#6c757d",
|
||||||
|
marginTop: "8px",
|
||||||
|
paddingLeft: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<li><strong>必須設定:</strong> アプリケーションの動作に必要な設定です</li>
|
||||||
|
<li><strong>認証情報:</strong> セキュリティ上重要な情報で、表示時にマスクされます</li>
|
||||||
|
<li>設定の変更は即座にアプリケーションに反映されます</li>
|
||||||
|
<li>デフォルト値が設定されている項目は、空にするとデフォルト値が使用されます</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === "env" && (
|
{activeTab === "env" && (
|
||||||
<>
|
<>
|
||||||
<h3>環境変数設定</h3>
|
<h3>環境変数設定</h3>
|
||||||
|
@ -14,12 +14,16 @@ import {
|
|||||||
fetchEpisodesWithArticles,
|
fetchEpisodesWithArticles,
|
||||||
getAllCategories,
|
getAllCategories,
|
||||||
getAllFeedsIncludingInactive,
|
getAllFeedsIncludingInactive,
|
||||||
|
getAllSettings,
|
||||||
getFeedByUrl,
|
getFeedByUrl,
|
||||||
getFeedRequests,
|
getFeedRequests,
|
||||||
getFeedsByCategory,
|
getFeedsByCategory,
|
||||||
getFeedsGroupedByCategory,
|
getFeedsGroupedByCategory,
|
||||||
|
getSetting,
|
||||||
|
getSettingsForAdminUI,
|
||||||
toggleFeedActive,
|
toggleFeedActive,
|
||||||
updateFeedRequestStatus,
|
updateFeedRequestStatus,
|
||||||
|
updateSetting,
|
||||||
} from "./services/database.js";
|
} from "./services/database.js";
|
||||||
|
|
||||||
// Validate configuration on startup
|
// Validate configuration on startup
|
||||||
@ -92,6 +96,74 @@ app.get("/api/admin/env", async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Settings management API endpoints
|
||||||
|
app.get("/api/admin/settings", async (c) => {
|
||||||
|
try {
|
||||||
|
const settings = await getSettingsForAdminUI();
|
||||||
|
return c.json(settings);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching settings:", error);
|
||||||
|
return c.json({ error: "Failed to fetch settings" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/admin/settings/:key", async (c) => {
|
||||||
|
try {
|
||||||
|
const key = c.req.param("key");
|
||||||
|
const setting = await getSetting(key);
|
||||||
|
|
||||||
|
if (!setting) {
|
||||||
|
return c.json({ error: "Setting not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mask credential values for security
|
||||||
|
if (setting.isCredential && setting.value) {
|
||||||
|
setting.value = "••••••••";
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(setting);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching setting:", error);
|
||||||
|
return c.json({ error: "Failed to fetch setting" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/api/admin/settings/:key", async (c) => {
|
||||||
|
try {
|
||||||
|
const key = c.req.param("key");
|
||||||
|
const { value } = await c.req.json<{ value: string }>();
|
||||||
|
|
||||||
|
if (!value && value !== "") {
|
||||||
|
return c.json({ error: "Setting value is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔧 Admin updating setting: ${key}`);
|
||||||
|
|
||||||
|
// Check if setting exists
|
||||||
|
const existingSetting = await getSetting(key);
|
||||||
|
if (!existingSetting) {
|
||||||
|
return c.json({ error: "Setting not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateSetting(key, value);
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
return c.json({
|
||||||
|
result: "UPDATED",
|
||||||
|
message: "Setting updated successfully",
|
||||||
|
key,
|
||||||
|
// Don't return the value for credentials
|
||||||
|
value: existingSetting.isCredential ? "••••••••" : value,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return c.json({ error: "Failed to update setting" }, 500);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating setting:", error);
|
||||||
|
return c.json({ error: "Failed to update setting" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Feed management API endpoints
|
// Feed management API endpoints
|
||||||
app.get("/api/admin/feeds", async (c) => {
|
app.get("/api/admin/feeds", async (c) => {
|
||||||
try {
|
try {
|
||||||
|
12
schema.sql
12
schema.sql
@ -68,6 +68,17 @@ CREATE TABLE IF NOT EXISTS feed_requests (
|
|||||||
admin_notes TEXT
|
admin_notes TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Environment variable settings stored in database
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT,
|
||||||
|
is_credential BOOLEAN DEFAULT 0,
|
||||||
|
description TEXT,
|
||||||
|
default_value TEXT,
|
||||||
|
required BOOLEAN DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
-- Create indexes for better performance
|
-- Create indexes for better performance
|
||||||
CREATE INDEX IF NOT EXISTS idx_articles_feed_id ON articles(feed_id);
|
CREATE INDEX IF NOT EXISTS idx_articles_feed_id ON articles(feed_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_articles_pub_date ON articles(pub_date);
|
CREATE INDEX IF NOT EXISTS idx_articles_pub_date ON articles(pub_date);
|
||||||
@ -78,3 +89,4 @@ CREATE INDEX IF NOT EXISTS idx_tts_queue_status ON tts_queue(status);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_tts_queue_created_at ON tts_queue(created_at);
|
CREATE INDEX IF NOT EXISTS idx_tts_queue_created_at ON tts_queue(created_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_feed_requests_status ON feed_requests(status);
|
CREATE INDEX IF NOT EXISTS idx_feed_requests_status ON feed_requests(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_feed_requests_created_at ON feed_requests(created_at);
|
CREATE INDEX IF NOT EXISTS idx_feed_requests_created_at ON feed_requests(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_settings_is_credential ON settings(is_credential);
|
||||||
|
@ -126,86 +126,21 @@ function initializeDatabase(): Database {
|
|||||||
db.exec("PRAGMA cache_size = 1000;");
|
db.exec("PRAGMA cache_size = 1000;");
|
||||||
db.exec("PRAGMA temp_store = memory;");
|
db.exec("PRAGMA temp_store = memory;");
|
||||||
|
|
||||||
// Ensure schema is set up - use the complete schema
|
// Load and execute schema from file
|
||||||
db.exec(`CREATE TABLE IF NOT EXISTS feeds (
|
const schemaPath = path.join(config.paths.projectRoot, "schema.sql");
|
||||||
id TEXT PRIMARY KEY,
|
if (fs.existsSync(schemaPath)) {
|
||||||
url TEXT NOT NULL UNIQUE,
|
const schema = fs.readFileSync(schemaPath, "utf-8");
|
||||||
title TEXT,
|
db.exec(schema);
|
||||||
description TEXT,
|
} else {
|
||||||
category TEXT,
|
throw new Error(`Schema file not found: ${schemaPath}`);
|
||||||
last_updated TEXT,
|
}
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
active BOOLEAN DEFAULT 1
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS articles (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
feed_id TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
link TEXT NOT NULL UNIQUE,
|
|
||||||
description TEXT,
|
|
||||||
content TEXT,
|
|
||||||
pub_date TEXT NOT NULL,
|
|
||||||
discovered_at TEXT NOT NULL,
|
|
||||||
processed BOOLEAN DEFAULT 0,
|
|
||||||
FOREIGN KEY(feed_id) REFERENCES feeds(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS episodes (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
article_id TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
audio_path TEXT NOT NULL,
|
|
||||||
duration INTEGER,
|
|
||||||
file_size INTEGER,
|
|
||||||
category TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
FOREIGN KEY(article_id) REFERENCES articles(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS processed_feed_items (
|
|
||||||
feed_url TEXT NOT NULL,
|
|
||||||
item_id TEXT NOT NULL,
|
|
||||||
processed_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY(feed_url, item_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tts_queue (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
item_id TEXT NOT NULL,
|
|
||||||
script_text TEXT NOT NULL,
|
|
||||||
retry_count INTEGER DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
last_attempted_at TEXT,
|
|
||||||
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'failed'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS feed_requests (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
url TEXT NOT NULL,
|
|
||||||
requested_by TEXT,
|
|
||||||
request_message TEXT,
|
|
||||||
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
reviewed_at TEXT,
|
|
||||||
reviewed_by TEXT,
|
|
||||||
admin_notes TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_articles_feed_id ON articles(feed_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_articles_pub_date ON articles(pub_date);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_articles_processed ON articles(processed);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_episodes_article_id ON episodes(article_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_feeds_active ON feeds(active);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tts_queue_status ON tts_queue(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tts_queue_created_at ON tts_queue(created_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_feed_requests_status ON feed_requests(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_feed_requests_created_at ON feed_requests(created_at);`);
|
|
||||||
|
|
||||||
// Perform database integrity checks and fixes
|
// Perform database integrity checks and fixes
|
||||||
performDatabaseIntegrityFixes(db);
|
performDatabaseIntegrityFixes(db);
|
||||||
|
|
||||||
|
// Initialize settings table with default values
|
||||||
|
initializeSettings();
|
||||||
|
|
||||||
// ALTER
|
// ALTER
|
||||||
// ALTER TABLE feeds ADD COLUMN category TEXT DEFAULT NULL;
|
// ALTER TABLE feeds ADD COLUMN category TEXT DEFAULT NULL;
|
||||||
// Ensure the category column exists in feeds
|
// Ensure the category column exists in feeds
|
||||||
@ -1666,6 +1601,267 @@ export async function getEpisodeCategoryMigrationStatus(): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Settings management functions
|
||||||
|
export interface Setting {
|
||||||
|
key: string;
|
||||||
|
value: string | null;
|
||||||
|
isCredential: boolean;
|
||||||
|
description: string;
|
||||||
|
defaultValue: string;
|
||||||
|
required: boolean;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initializeSettings(): Promise<void> {
|
||||||
|
const defaultSettings: Omit<Setting, "updatedAt">[] = [
|
||||||
|
{
|
||||||
|
key: "OPENAI_API_KEY",
|
||||||
|
value: null,
|
||||||
|
isCredential: true,
|
||||||
|
description: "OpenAI API Key for content generation",
|
||||||
|
defaultValue: "",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "OPENAI_API_ENDPOINT",
|
||||||
|
value: null,
|
||||||
|
isCredential: false,
|
||||||
|
description: "OpenAI API Endpoint URL",
|
||||||
|
defaultValue: "https://api.openai.com/v1",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "OPENAI_MODEL_NAME",
|
||||||
|
value: null,
|
||||||
|
isCredential: false,
|
||||||
|
description: "OpenAI Model Name",
|
||||||
|
defaultValue: "gpt-4o-mini",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "VOICEVOX_HOST",
|
||||||
|
value: null,
|
||||||
|
isCredential: false,
|
||||||
|
description: "VOICEVOX Server Host URL",
|
||||||
|
defaultValue: "http://localhost:50021",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "VOICEVOX_STYLE_ID",
|
||||||
|
value: null,
|
||||||
|
isCredential: false,
|
||||||
|
description: "VOICEVOX Voice Style ID",
|
||||||
|
defaultValue: "0",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "PODCAST_TITLE",
|
||||||
|
value: null,
|
||||||
|
isCredential: false,
|
||||||
|
description: "Podcast Title",
|
||||||
|
defaultValue: "自動生成ポッドキャスト",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "PODCAST_LINK",
|
||||||
|
value: null,
|
||||||
|
isCredential: false,
|
||||||
|
description: "Podcast Link URL",
|
||||||
|
defaultValue: "https://your-domain.com/podcast",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "PODCAST_DESCRIPTION",
|
||||||
|
value: null,
|
||||||
|
isCredential: false,
|
||||||
|
description: "Podcast Description",
|
||||||
|
defaultValue: "RSSフィードから自動生成された音声ポッドキャスト",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "PODCAST_LANGUAGE",
|
||||||
|
value: null,
|
||||||
|
isCredential: false,
|
||||||
|
description: "Podcast Language",
|
||||||
|
defaultValue: "ja",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "PODCAST_AUTHOR",
|
||||||
|
value: null,
|
||||||
|
isCredential: false,
|
||||||
|
description: "Podcast Author",
|
||||||
|
defaultValue: "管理者",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "PODCAST_CATEGORIES",
|
||||||
|
value: null,
|
||||||
|
isCredential: false,
|
||||||
|
description: "Podcast Categories",
|
||||||
|
defaultValue: "Technology",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "PODCAST_TTL",
|
||||||
|
value: null,
|
||||||
|
isCredential: false,
|
||||||
|
description: "Podcast TTL",
|
||||||
|
defaultValue: "60",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "PODCAST_BASE_URL",
|
||||||
|
value: null,
|
||||||
|
isCredential: false,
|
||||||
|
description: "Podcast Base URL",
|
||||||
|
defaultValue: "https://your-domain.com",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ADMIN_PORT",
|
||||||
|
value: null,
|
||||||
|
isCredential: false,
|
||||||
|
description: "Admin Panel Port",
|
||||||
|
defaultValue: "3001",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ADMIN_USERNAME",
|
||||||
|
value: null,
|
||||||
|
isCredential: true,
|
||||||
|
description: "Admin Panel Username",
|
||||||
|
defaultValue: "",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ADMIN_PASSWORD",
|
||||||
|
value: null,
|
||||||
|
isCredential: true,
|
||||||
|
description: "Admin Panel Password",
|
||||||
|
defaultValue: "",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "DISABLE_INITIAL_BATCH",
|
||||||
|
value: null,
|
||||||
|
isCredential: false,
|
||||||
|
description: "Disable Initial Batch Process",
|
||||||
|
defaultValue: "false",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "FEED_URLS_FILE",
|
||||||
|
value: null,
|
||||||
|
isCredential: false,
|
||||||
|
description: "Feed URLs File Path",
|
||||||
|
defaultValue: "feed_urls.txt",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
for (const setting of defaultSettings) {
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare(
|
||||||
|
"INSERT OR IGNORE INTO settings (key, value, is_credential, description, default_value, required, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
stmt.run(
|
||||||
|
setting.key,
|
||||||
|
setting.value,
|
||||||
|
setting.isCredential ? 1 : 0,
|
||||||
|
setting.description,
|
||||||
|
setting.defaultValue,
|
||||||
|
setting.required ? 1 : 0,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error initializing setting ${setting.key}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllSettings(): Promise<Setting[]> {
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare("SELECT * FROM settings ORDER BY key");
|
||||||
|
const rows = stmt.all() as any[];
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
key: row.key,
|
||||||
|
value: row.value,
|
||||||
|
isCredential: Boolean(row.is_credential),
|
||||||
|
description: row.description,
|
||||||
|
defaultValue: row.default_value,
|
||||||
|
required: Boolean(row.required),
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error getting all settings:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSetting(key: string): Promise<Setting | null> {
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare("SELECT * FROM settings WHERE key = ?");
|
||||||
|
const row = stmt.get(key) as any;
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: row.key,
|
||||||
|
value: row.value,
|
||||||
|
isCredential: Boolean(row.is_credential),
|
||||||
|
description: row.description,
|
||||||
|
defaultValue: row.default_value,
|
||||||
|
required: Boolean(row.required),
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error getting setting ${key}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSetting(key: string, value: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const stmt = db.prepare(
|
||||||
|
"UPDATE settings SET value = ?, updated_at = ? WHERE key = ?"
|
||||||
|
);
|
||||||
|
const result = stmt.run(value, now, key);
|
||||||
|
return result.changes > 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating setting ${key}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSetting(key: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare("DELETE FROM settings WHERE key = ?");
|
||||||
|
const result = stmt.run(key);
|
||||||
|
return result.changes > 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error deleting setting ${key}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSettingsForAdminUI(): Promise<Setting[]> {
|
||||||
|
try {
|
||||||
|
const settings = await getAllSettings();
|
||||||
|
return settings.map((setting) => ({
|
||||||
|
...setting,
|
||||||
|
// Mask credential values for security
|
||||||
|
value: setting.isCredential && setting.value ? "••••••••" : setting.value,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error getting settings for admin UI:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function closeDatabase(): void {
|
export function closeDatabase(): void {
|
||||||
db.close();
|
db.close();
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user