509b1a1320
Tasks can now have a start/end time on their deadline date. The new Calendar view shows a day as a timeline (with a week strip and projected repeats of recurring tasks), and the task form lists what is already planned that day and flags overlaps. Existing databases are migrated to schema v2 automatically, with a backup copy taken first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
102 lines
3.4 KiB
Python
102 lines
3.4 KiB
Python
import os
|
|
import sqlite3
|
|
from pathlib import Path
|
|
|
|
DB_PATH = os.environ.get("DB_PATH", "/data/whattodo.db")
|
|
SCHEMA_VERSION = 2
|
|
|
|
DEFAULT_CATEGORIES = [
|
|
("Work", "#2563eb"),
|
|
("Life", "#16a34a"),
|
|
("Personal", "#9333ea"),
|
|
("YouTube", "#dc2626"),
|
|
]
|
|
|
|
SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS meta (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS categories (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
color TEXT NOT NULL DEFAULT '#6b7280',
|
|
position INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
id INTEGER PRIMARY KEY,
|
|
title TEXT NOT NULL,
|
|
notes TEXT NOT NULL DEFAULT '',
|
|
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
|
|
priority INTEGER NOT NULL DEFAULT 0,
|
|
due_date TEXT,
|
|
start_time TEXT,
|
|
end_time TEXT,
|
|
tags TEXT NOT NULL DEFAULT '[]',
|
|
recurrence TEXT NOT NULL DEFAULT 'none',
|
|
recur_interval INTEGER NOT NULL DEFAULT 1,
|
|
done INTEGER NOT NULL DEFAULT 0,
|
|
done_at TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS subtasks (
|
|
id INTEGER PRIMARY KEY,
|
|
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
title TEXT NOT NULL,
|
|
done INTEGER NOT NULL DEFAULT 0,
|
|
position INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_subtasks_task ON subtasks(task_id);
|
|
"""
|
|
|
|
|
|
def connect() -> sqlite3.Connection:
|
|
# FastAPI may open a request's connection and run its endpoint on different threads;
|
|
# each connection is still used by only one request at a time.
|
|
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA foreign_keys = ON")
|
|
return conn
|
|
|
|
|
|
def init() -> None:
|
|
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
|
conn = connect()
|
|
try:
|
|
conn.execute("PRAGMA journal_mode = WAL")
|
|
conn.executescript(SCHEMA)
|
|
row = conn.execute("SELECT value FROM meta WHERE key = 'schema_version'").fetchone()
|
|
if row is not None and int(row["value"]) < SCHEMA_VERSION:
|
|
migrate(conn, int(row["value"]))
|
|
elif row is None:
|
|
# Fresh database: seed default categories.
|
|
with conn:
|
|
for i, (name, color) in enumerate(DEFAULT_CATEGORIES):
|
|
conn.execute(
|
|
"INSERT INTO categories (name, color, position) VALUES (?, ?, ?)",
|
|
(name, color, i),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO meta (key, value) VALUES ('schema_version', ?)",
|
|
(str(SCHEMA_VERSION),),
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def migrate(conn: sqlite3.Connection, version: int) -> None:
|
|
# Keep a copy of the database as it was before migrating, next to it.
|
|
backup = sqlite3.connect(f"{DB_PATH}.before-v{SCHEMA_VERSION}.bak")
|
|
try:
|
|
conn.backup(backup)
|
|
finally:
|
|
backup.close()
|
|
with conn:
|
|
if version < 2:
|
|
conn.execute("ALTER TABLE tasks ADD COLUMN start_time TEXT")
|
|
conn.execute("ALTER TABLE tasks ADD COLUMN end_time TEXT")
|
|
conn.execute(
|
|
"UPDATE meta SET value = ? WHERE key = 'schema_version'", (str(SCHEMA_VERSION),)
|
|
)
|