import sqlite3 import json import logging from typing import Dict, Any, List, Optional logger = logging.getLogger("personal") DB_PATH = "/app/data/personal.db" def _conn(): c = sqlite3.connect(DB_PATH, timeout=120.0) c.row_factory = sqlite3.Row c.execute("PRAGMA journal_mode=WAL;") c.execute("PRAGMA busy_timeout=120000;") c.execute("PRAGMA foreign_keys=ON;") return c def _row_to_dict(r) -> Dict[str, Any]: if r is None: return None d = {c: r[c] for c in r.keys()} if "tech_stack" in d and isinstance(d["tech_stack"], str): try: d["tech_stack"] = json.loads(d["tech_stack"]) except (json.JSONDecodeError, TypeError): d["tech_stack"] = [] return d def init_db(): with _conn() as conn: conn.execute(""" CREATE TABLE IF NOT EXISTS profile ( id INTEGER PRIMARY KEY CHECK (id = 1), name TEXT NOT NULL DEFAULT '', name_en TEXT NOT NULL DEFAULT '', role TEXT NOT NULL DEFAULT '', role_en TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '', phone TEXT NOT NULL DEFAULT '', github_url TEXT NOT NULL DEFAULT '', blog_url TEXT NOT NULL DEFAULT '', photo_url TEXT NOT NULL DEFAULT '', bio TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ) """) conn.execute(""" INSERT OR IGNORE INTO profile (id) VALUES (1) """) conn.execute(""" CREATE TABLE IF NOT EXISTS careers ( id INTEGER PRIMARY KEY AUTOINCREMENT, category TEXT NOT NULL DEFAULT 'company', organization TEXT NOT NULL DEFAULT '', role TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '', start_date TEXT NOT NULL DEFAULT '', end_date TEXT NOT NULL DEFAULT '', sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS projects ( id INTEGER PRIMARY KEY AUTOINCREMENT, category TEXT NOT NULL DEFAULT 'personal', title TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '', tech_stack TEXT NOT NULL DEFAULT '[]', role TEXT NOT NULL DEFAULT '', start_date TEXT NOT NULL DEFAULT '', end_date TEXT NOT NULL DEFAULT '', url TEXT NOT NULL DEFAULT '', image_url TEXT NOT NULL DEFAULT '', sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS skills ( id INTEGER PRIMARY KEY AUTOINCREMENT, category TEXT NOT NULL DEFAULT 'language', name TEXT NOT NULL DEFAULT '', level INTEGER NOT NULL DEFAULT 3, sort_order INTEGER NOT NULL DEFAULT 0 ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS introductions ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '', is_main INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ) """) # ── todos 테이블 ── conn.execute(""" CREATE TABLE IF NOT EXISTS todos ( id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2)))), title TEXT NOT NULL, description TEXT, status TEXT NOT NULL DEFAULT 'todo' CHECK(status IN ('todo','in_progress','done')), created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_todos_created ON todos(created_at DESC)") # ── blog_posts 테이블 ── conn.execute(""" CREATE TABLE IF NOT EXISTS blog_posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', excerpt TEXT NOT NULL DEFAULT '', tags TEXT NOT NULL DEFAULT '[]', date TEXT NOT NULL DEFAULT (date('now','localtime')), created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_blog_date ON blog_posts(date DESC)") logger.info("personal DB initialized") # ── Profile ── def get_profile() -> Dict[str, Any]: with _conn() as conn: row = conn.execute("SELECT * FROM profile WHERE id = 1").fetchone() return _row_to_dict(row) def update_profile(data: Dict[str, Any]) -> Dict[str, Any]: fields = {k: v for k, v in data.items() if k != "id" and v is not None} if not fields: return get_profile() set_clauses = ", ".join(f"{k} = ?" for k in fields) set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')" with _conn() as conn: conn.execute( f"UPDATE profile SET {set_clauses} WHERE id = 1", list(fields.values()), ) return get_profile() # ── Careers ── def get_careers() -> List[Dict[str, Any]]: with _conn() as conn: rows = conn.execute("SELECT * FROM careers ORDER BY sort_order, start_date DESC").fetchall() return [_row_to_dict(r) for r in rows] def create_career(data: Dict[str, Any]) -> Dict[str, Any]: with _conn() as conn: conn.execute( """INSERT INTO careers (category, organization, role, description, start_date, end_date, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)""", (data.get("category", "company"), data.get("organization", ""), data.get("role", ""), data.get("description", ""), data.get("start_date", ""), data.get("end_date", ""), data.get("sort_order", 0)), ) row = conn.execute("SELECT * FROM careers ORDER BY id DESC LIMIT 1").fetchone() return _row_to_dict(row) def update_career(career_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: fields = {k: v for k, v in data.items() if k not in ("id", "created_at") and v is not None} if not fields: return get_career(career_id) set_clauses = ", ".join(f"{k} = ?" for k in fields) set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')" with _conn() as conn: existing = conn.execute("SELECT id FROM careers WHERE id = ?", (career_id,)).fetchone() if not existing: return None conn.execute(f"UPDATE careers SET {set_clauses} WHERE id = ?", list(fields.values()) + [career_id]) row = conn.execute("SELECT * FROM careers WHERE id = ?", (career_id,)).fetchone() return _row_to_dict(row) def delete_career(career_id: int) -> bool: with _conn() as conn: cur = conn.execute("DELETE FROM careers WHERE id = ?", (career_id,)) return cur.rowcount > 0 def get_career(career_id: int) -> Optional[Dict[str, Any]]: with _conn() as conn: row = conn.execute("SELECT * FROM careers WHERE id = ?", (career_id,)).fetchone() return _row_to_dict(row) # ── Projects ── def get_projects() -> List[Dict[str, Any]]: with _conn() as conn: rows = conn.execute("SELECT * FROM projects ORDER BY sort_order, start_date DESC").fetchall() return [_row_to_dict(r) for r in rows] def create_project(data: Dict[str, Any]) -> Dict[str, Any]: tech = json.dumps(data.get("tech_stack", []), ensure_ascii=False) with _conn() as conn: conn.execute( """INSERT INTO projects (category, title, description, tech_stack, role, start_date, end_date, url, image_url, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (data.get("category", "personal"), data.get("title", ""), data.get("description", ""), tech, data.get("role", ""), data.get("start_date", ""), data.get("end_date", ""), data.get("url", ""), data.get("image_url", ""), data.get("sort_order", 0)), ) row = conn.execute("SELECT * FROM projects ORDER BY id DESC LIMIT 1").fetchone() return _row_to_dict(row) def update_project(project_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: fields = {k: v for k, v in data.items() if k not in ("id", "created_at") and v is not None} if "tech_stack" in fields and isinstance(fields["tech_stack"], list): fields["tech_stack"] = json.dumps(fields["tech_stack"], ensure_ascii=False) if not fields: return get_project(project_id) set_clauses = ", ".join(f"{k} = ?" for k in fields) set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')" with _conn() as conn: existing = conn.execute("SELECT id FROM projects WHERE id = ?", (project_id,)).fetchone() if not existing: return None conn.execute(f"UPDATE projects SET {set_clauses} WHERE id = ?", list(fields.values()) + [project_id]) row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone() return _row_to_dict(row) def delete_project(project_id: int) -> bool: with _conn() as conn: cur = conn.execute("DELETE FROM projects WHERE id = ?", (project_id,)) return cur.rowcount > 0 def get_project(project_id: int) -> Optional[Dict[str, Any]]: with _conn() as conn: row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone() return _row_to_dict(row) # ── Skills ── def get_skills() -> List[Dict[str, Any]]: with _conn() as conn: rows = conn.execute("SELECT * FROM skills ORDER BY sort_order, category, name").fetchall() return [_row_to_dict(r) for r in rows] def create_skill(data: Dict[str, Any]) -> Dict[str, Any]: with _conn() as conn: conn.execute( "INSERT INTO skills (category, name, level, sort_order) VALUES (?, ?, ?, ?)", (data.get("category", "language"), data.get("name", ""), data.get("level", 3), data.get("sort_order", 0)), ) row = conn.execute("SELECT * FROM skills ORDER BY id DESC LIMIT 1").fetchone() return _row_to_dict(row) def update_skill(skill_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: fields = {k: v for k, v in data.items() if k != "id" and v is not None} if not fields: return get_skill(skill_id) set_clauses = ", ".join(f"{k} = ?" for k in fields) with _conn() as conn: existing = conn.execute("SELECT id FROM skills WHERE id = ?", (skill_id,)).fetchone() if not existing: return None conn.execute(f"UPDATE skills SET {set_clauses} WHERE id = ?", list(fields.values()) + [skill_id]) row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone() return _row_to_dict(row) def delete_skill(skill_id: int) -> bool: with _conn() as conn: cur = conn.execute("DELETE FROM skills WHERE id = ?", (skill_id,)) return cur.rowcount > 0 def get_skill(skill_id: int) -> Optional[Dict[str, Any]]: with _conn() as conn: row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone() return _row_to_dict(row) # ── Introductions ── def get_introductions() -> List[Dict[str, Any]]: with _conn() as conn: rows = conn.execute("SELECT * FROM introductions ORDER BY is_main DESC, updated_at DESC").fetchall() return [_row_to_dict(r) for r in rows] def create_introduction(data: Dict[str, Any]) -> Dict[str, Any]: with _conn() as conn: conn.execute( "INSERT INTO introductions (title, content, is_main) VALUES (?, ?, ?)", (data.get("title", ""), data.get("content", ""), data.get("is_main", 0)), ) row = conn.execute("SELECT * FROM introductions ORDER BY id DESC LIMIT 1").fetchone() return _row_to_dict(row) def update_introduction(intro_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: fields = {k: v for k, v in data.items() if k not in ("id", "created_at") and v is not None} if not fields: return get_introduction(intro_id) set_clauses = ", ".join(f"{k} = ?" for k in fields) set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')" with _conn() as conn: existing = conn.execute("SELECT id FROM introductions WHERE id = ?", (intro_id,)).fetchone() if not existing: return None conn.execute(f"UPDATE introductions SET {set_clauses} WHERE id = ?", list(fields.values()) + [intro_id]) row = conn.execute("SELECT * FROM introductions WHERE id = ?", (intro_id,)).fetchone() return _row_to_dict(row) def delete_introduction(intro_id: int) -> bool: with _conn() as conn: cur = conn.execute("DELETE FROM introductions WHERE id = ?", (intro_id,)) return cur.rowcount > 0 def get_introduction(intro_id: int) -> Optional[Dict[str, Any]]: with _conn() as conn: row = conn.execute("SELECT * FROM introductions WHERE id = ?", (intro_id,)).fetchone() return _row_to_dict(row) def set_main_introduction(intro_id: int) -> Optional[Dict[str, Any]]: with _conn() as conn: existing = conn.execute("SELECT id FROM introductions WHERE id = ?", (intro_id,)).fetchone() if not existing: return None conn.execute("UPDATE introductions SET is_main = 0 WHERE is_main = 1") conn.execute("UPDATE introductions SET is_main = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", (intro_id,)) row = conn.execute("SELECT * FROM introductions WHERE id = ?", (intro_id,)).fetchone() return _row_to_dict(row) # ── Public (일괄 조회) ── # ── Todos ── def _todo_row_to_dict(r) -> Dict[str, Any]: return { "id": r["id"], "title": r["title"], "description": r["description"], "status": r["status"], "created_at": r["created_at"], "updated_at": r["updated_at"], } def get_all_todos() -> List[Dict[str, Any]]: with _conn() as conn: rows = conn.execute("SELECT * FROM todos ORDER BY created_at DESC").fetchall() return [_todo_row_to_dict(r) for r in rows] def create_todo(title: str, description: Optional[str], status: str) -> Dict[str, Any]: with _conn() as conn: conn.execute("INSERT INTO todos (title, description, status) VALUES (?, ?, ?)", (title, description, status)) row = conn.execute("SELECT * FROM todos WHERE rowid = last_insert_rowid()").fetchone() return _todo_row_to_dict(row) def update_todo(todo_id: str, fields: Dict[str, Any]) -> Optional[Dict[str, Any]]: allowed = {"title", "description", "status"} updates = {k: v for k, v in fields.items() if k in allowed} if not updates: with _conn() as conn: row = conn.execute("SELECT * FROM todos WHERE id = ?", (todo_id,)).fetchone() return _todo_row_to_dict(row) if row else None set_clauses = ", ".join(f"{k} = ?" for k in updates) set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')" args = list(updates.values()) + [todo_id] with _conn() as conn: conn.execute(f"UPDATE todos SET {set_clauses} WHERE id = ?", args) row = conn.execute("SELECT * FROM todos WHERE id = ?", (todo_id,)).fetchone() return _todo_row_to_dict(row) if row else None def delete_todo(todo_id: str) -> bool: with _conn() as conn: cur = conn.execute("DELETE FROM todos WHERE id = ?", (todo_id,)) return cur.rowcount > 0 def delete_done_todos() -> int: with _conn() as conn: cur = conn.execute("DELETE FROM todos WHERE status = 'done'") return cur.rowcount # ── Blog Posts ── def _post_row_to_dict(r) -> Dict[str, Any]: return { "id": r["id"], "title": r["title"], "body": r["body"], "excerpt": r["excerpt"], "tags": json.loads(r["tags"]) if r["tags"] else [], "date": r["date"], "created_at": r["created_at"], "updated_at": r["updated_at"], } def get_all_posts() -> List[Dict[str, Any]]: with _conn() as conn: rows = conn.execute("SELECT * FROM blog_posts ORDER BY date DESC, id DESC").fetchall() return [_post_row_to_dict(r) for r in rows] def create_post(title: str, body: str, excerpt: str, tags: List[str], date: str) -> Dict[str, Any]: with _conn() as conn: conn.execute( "INSERT INTO blog_posts (title, body, excerpt, tags, date) VALUES (?, ?, ?, ?, ?)", (title, body, excerpt, json.dumps(tags), date), ) row = conn.execute("SELECT * FROM blog_posts WHERE rowid = last_insert_rowid()").fetchone() return _post_row_to_dict(row) def update_post(post_id: int, fields: Dict[str, Any]) -> Optional[Dict[str, Any]]: allowed = {"title", "body", "excerpt", "tags", "date"} updates = {k: v for k, v in fields.items() if k in allowed} if not updates: with _conn() as conn: row = conn.execute("SELECT * FROM blog_posts WHERE id = ?", (post_id,)).fetchone() return _post_row_to_dict(row) if row else None if "tags" in updates: updates["tags"] = json.dumps(updates["tags"]) set_clauses = ", ".join(f"{k} = ?" for k in updates) set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')" args = list(updates.values()) + [post_id] with _conn() as conn: conn.execute(f"UPDATE blog_posts SET {set_clauses} WHERE id = ?", args) row = conn.execute("SELECT * FROM blog_posts WHERE id = ?", (post_id,)).fetchone() return _post_row_to_dict(row) if row else None def delete_post(post_id: int) -> bool: with _conn() as conn: cur = conn.execute("DELETE FROM blog_posts WHERE id = ?", (post_id,)) return cur.rowcount > 0 # ── Public (일괄 조회) ── def get_public_data() -> Dict[str, Any]: with _conn() as conn: profile = _row_to_dict(conn.execute("SELECT * FROM profile WHERE id = 1").fetchone()) careers = [_row_to_dict(r) for r in conn.execute("SELECT * FROM careers ORDER BY sort_order, start_date DESC").fetchall()] projects = [_row_to_dict(r) for r in conn.execute("SELECT * FROM projects ORDER BY sort_order, start_date DESC").fetchall()] skills = [_row_to_dict(r) for r in conn.execute("SELECT * FROM skills ORDER BY sort_order, category, name").fetchall()] main_intro_row = conn.execute("SELECT * FROM introductions WHERE is_main = 1 LIMIT 1").fetchone() main_introduction = _row_to_dict(main_intro_row) if main_intro_row else None return { "profile": profile, "careers": careers, "projects": projects, "skills": skills, "main_introduction": main_introduction, }