import json import threading import uuid from copy import deepcopy from datetime import datetime, timedelta from pathlib import Path from typing import Any from zoneinfo import ZoneInfo TZ = ZoneInfo("Asia/Shanghai") DEFAULT_SCHEDULE_SETTINGS = { "semester_start": "2026-03-09", "day_start": "08:15", "day_end": "23:00", "default_task_duration_minutes": 45, } COURSE_COLOR_PALETTE = [ "#64d7f5", "#7ee082", "#ffbf69", "#ff8a80", "#b197fc", "#83c5be", ] DEFAULT_COURSE_SEED_VERSION = "2026-spring-v1" DEFAULT_PERIOD_TIMES = { 1: ("08:15", "09:00"), 2: ("09:10", "09:55"), 3: ("10:15", "11:00"), 4: ("11:10", "11:55"), 5: ("13:50", "14:35"), 6: ("14:45", "15:30"), 7: ("15:40", "16:25"), 8: ("16:45", "17:30"), 9: ("17:40", "18:25"), 10: ("19:20", "20:05"), 11: ("20:15", "21:00"), 12: ("21:10", "21:55"), } def build_default_time_slots() -> list[dict[str, str]]: slots: list[dict[str, str]] = [] for index, (start, end) in DEFAULT_PERIOD_TIMES.items(): slots.append( { "label": f"第{index:02d}节课", "start": start, "end": end, } ) return slots DEFAULT_SCHEDULE_SETTINGS["time_slots"] = build_default_time_slots() DEFAULT_IMPORTED_COURSES = [ { "title": "形势与政策-2_20", "day_of_week": 7, "start_period": 10, "end_period": 11, "start_week": 9, "end_week": 15, "week_pattern": "odd", "location": "江安综合楼C座C407", "color": "#E0B100", }, { "title": "数字逻辑:应用与设计_09", "day_of_week": 1, "start_period": 5, "end_period": 7, "start_week": 1, "end_week": 16, "week_pattern": "all", "location": "江安一教A座A412", "color": "#FF5AA5", }, { "title": "中国近现代史纲要_59", "day_of_week": 1, "start_period": 10, "end_period": 12, "start_week": 1, "end_week": 16, "week_pattern": "all", "location": "江安综合楼C座C403", "color": "#66BB6A", }, { "title": "人工智能导论_666", "day_of_week": 2, "start_period": 1, "end_period": 2, "start_week": 1, "end_week": 16, "week_pattern": "all", "location": "江安一教B座B201", "color": "#C77400", }, { "title": "微积分(I)-2_33", "day_of_week": 2, "start_period": 3, "end_period": 4, "start_week": 1, "end_week": 16, "week_pattern": "all", "location": "江安一教B座B101", "color": "#DD8E88", }, { "title": "体育-2游泳_12", "day_of_week": 2, "start_period": 5, "end_period": 6, "start_week": 1, "end_week": 12, "week_pattern": "all", "location": "江安未来游泳馆", "color": "#717171", }, { "title": "城市经济学_03", "day_of_week": 2, "start_period": 8, "end_period": 9, "start_week": 1, "end_week": 16, "week_pattern": "all", "location": "江安一教A座A308", "color": "#F6AD9A", }, { "title": "新中国史_02", "day_of_week": 2, "start_period": 10, "end_period": 12, "start_week": 1, "end_week": 11, "week_pattern": "all", "location": "江安综合楼C座C407", "color": "#FF9A6A", }, { "title": "通用英语 I-2_49", "day_of_week": 3, "start_period": 1, "end_period": 2, "start_week": 1, "end_week": 16, "week_pattern": "all", "location": "江安二基楼B座B409", "color": "#55C08D", }, { "title": "线性代数(理工)_35", "day_of_week": 3, "start_period": 3, "end_period": 4, "start_week": 1, "end_week": 16, "week_pattern": "all", "location": "江安综合楼C座C303", "color": "#9A6EAB", }, { "title": "微积分(I)-2_33", "day_of_week": 4, "start_period": 1, "end_period": 3, "start_week": 1, "end_week": 16, "week_pattern": "all", "location": "江安一教B座B101", "color": "#DD8E88", }, { "title": "面向对象程序设计(Java篇)_03", "day_of_week": 4, "start_period": 5, "end_period": 8, "start_week": 1, "end_week": 13, "week_pattern": "all", "location": "江安综合楼B座B205", "color": "#C99B89", }, { "title": "深度学习_01", "day_of_week": 4, "start_period": 10, "end_period": 12, "start_week": 6, "end_week": 16, "week_pattern": "all", "location": "江安一教A座A207", "color": "#FA8D92", }, { "title": "大学物理(理工)III-1_09", "day_of_week": 5, "start_period": 1, "end_period": 2, "start_week": 1, "end_week": 16, "week_pattern": "all", "location": "江安一教B座B401", "color": "#8A860C", }, { "title": "线性代数(理工)_35", "day_of_week": 5, "start_period": 3, "end_period": 4, "start_week": 1, "end_week": 16, "week_pattern": "all", "location": "江安一教B座B301", "color": "#9A6EAB", }, { "title": "线性代数习题课_35", "day_of_week": 6, "start_period": 7, "end_period": 8, "start_week": 2, "end_week": 16, "week_pattern": "all", "location": "江安一教B座B104", "color": "#F0A794", }, ] def beijing_now() -> datetime: return datetime.now(TZ) def iso_now() -> str: return beijing_now().isoformat() def make_id(prefix: str) -> str: return f"{prefix}_{uuid.uuid4().hex[:10]}" def build_default_courses() -> list[dict[str, Any]]: created_at = iso_now() courses: list[dict[str, Any]] = [] for index, item in enumerate(DEFAULT_IMPORTED_COURSES): start_time = DEFAULT_PERIOD_TIMES[item["start_period"]][0] end_time = DEFAULT_PERIOD_TIMES[item["end_period"]][1] courses.append( { "id": make_id("course"), "title": item["title"], "day_of_week": item["day_of_week"], "start_time": start_time, "end_time": end_time, "start_week": item["start_week"], "end_week": item["end_week"], "week_pattern": item["week_pattern"], "location": item["location"], "color": item.get("color") or COURSE_COLOR_PALETTE[index % len(COURSE_COLOR_PALETTE)], "created_at": created_at, } ) return courses class ReminderStore: def __init__(self, path: Path): self.path = path self.lock = threading.Lock() self.path.parent.mkdir(parents=True, exist_ok=True) if not self.path.exists(): self._write(self._seed_data()) else: self._ensure_schema() def _seed_data(self) -> dict[str, Any]: now = beijing_now().replace(minute=0, second=0, microsecond=0) def task(title: str, hours_from_now: int) -> dict[str, Any]: created = now - timedelta(hours=max(hours_from_now - 4, 1)) due = now + timedelta(hours=hours_from_now) return { "id": make_id("task"), "title": title, "created_at": created.isoformat(), "due_at": due.isoformat(), "completed": False, "completed_at": None, "schedule": None, } return { "categories": [ { "id": make_id("cat"), "name": "今日节奏", "created_at": iso_now(), "tasks": [ task("整理今天的重点任务", 10), task("晚间复盘 15 分钟", 14), ], }, { "id": make_id("cat"), "name": "学习推进", "created_at": iso_now(), "tasks": [ task("完成一节课程并记笔记", 26), ], }, { "id": make_id("cat"), "name": "生活安排", "created_at": iso_now(), "tasks": [ task("补充下周需要采购的清单", 40), ], }, ], "courses": build_default_courses(), "course_seed_version": DEFAULT_COURSE_SEED_VERSION, "schedule_settings": deepcopy(DEFAULT_SCHEDULE_SETTINGS), } def _normalize_data(self, data: dict[str, Any]) -> tuple[dict[str, Any], bool]: changed = False categories = data.setdefault("categories", []) courses = data.setdefault("courses", []) settings = data.setdefault("schedule_settings", {}) course_seed_version = data.get("course_seed_version") for key, value in DEFAULT_SCHEDULE_SETTINGS.items(): if key not in settings: settings[key] = deepcopy(value) changed = True time_slots = settings.get("time_slots") if not isinstance(time_slots, list) or not time_slots: settings["time_slots"] = build_default_time_slots() changed = True else: normalized_time_slots = [] for index, slot in enumerate(time_slots, start=1): normalized_time_slots.append( { "label": str(slot.get("label", "")).strip() or f"第{index:02d}节课", "start": str(slot.get("start", DEFAULT_PERIOD_TIMES.get(index, ("08:15", "09:00"))[0])), "end": str(slot.get("end", DEFAULT_PERIOD_TIMES.get(index, ("08:15", "09:00"))[1])), } ) if normalized_time_slots != time_slots: settings["time_slots"] = normalized_time_slots changed = True if course_seed_version != DEFAULT_COURSE_SEED_VERSION: if not courses: data["courses"] = build_default_courses() courses = data["courses"] data["course_seed_version"] = DEFAULT_COURSE_SEED_VERSION changed = True for category in categories: if "created_at" not in category: category["created_at"] = iso_now() changed = True tasks = category.setdefault("tasks", []) for task in tasks: if "completed" not in task: task["completed"] = False changed = True if "completed_at" not in task: task["completed_at"] = None changed = True if "schedule" not in task: task["schedule"] = None changed = True for index, course in enumerate(courses): if "created_at" not in course: course["created_at"] = iso_now() changed = True if "week_pattern" not in course: course["week_pattern"] = "all" changed = True if "location" not in course: course["location"] = "" changed = True if "color" not in course: course["color"] = COURSE_COLOR_PALETTE[index % len(COURSE_COLOR_PALETTE)] changed = True return data, changed def _ensure_schema(self) -> None: with self.lock: data = self._read() normalized, changed = self._normalize_data(data) if changed: self._write(normalized) def _read(self) -> dict[str, Any]: with self.path.open("r", encoding="utf-8") as file: return json.load(file) def _write(self, data: dict[str, Any]) -> None: temp_path = self.path.with_suffix(".tmp") with temp_path.open("w", encoding="utf-8") as file: json.dump(data, file, ensure_ascii=False, indent=2) temp_path.replace(self.path) def snapshot(self) -> dict[str, Any]: with self.lock: data = self._read() normalized, changed = self._normalize_data(data) if changed: self._write(normalized) return deepcopy(normalized) def list_categories(self) -> list[dict[str, Any]]: data = self.snapshot() categories = data.get("categories", []) for category in categories: category["tasks"] = sorted( category.get("tasks", []), key=lambda item: ( item.get("completed", False), item.get("due_at", ""), item.get("created_at", ""), ), ) return categories def list_tasks(self) -> list[dict[str, Any]]: data = self.snapshot() flattened: list[dict[str, Any]] = [] for category in data.get("categories", []): for task in category.get("tasks", []): task_copy = deepcopy(task) task_copy["category_id"] = category["id"] task_copy["category_name"] = category["name"] flattened.append(task_copy) return sorted( flattened, key=lambda task: ( task.get("completed", False), task.get("due_at", ""), task.get("created_at", ""), ), ) def list_courses(self) -> list[dict[str, Any]]: data = self.snapshot() return sorted( data.get("courses", []), key=lambda course: ( course.get("day_of_week", 1), course.get("start_time", ""), course.get("start_week", 1), ), ) def get_schedule_settings(self) -> dict[str, Any]: data = self.snapshot() return deepcopy(data.get("schedule_settings", DEFAULT_SCHEDULE_SETTINGS)) def update_schedule_settings(self, payload: dict[str, Any]) -> dict[str, Any]: with self.lock: data = self._read() settings = data.setdefault("schedule_settings", deepcopy(DEFAULT_SCHEDULE_SETTINGS)) for key, value in payload.items(): settings[key] = value self._write(data) return deepcopy(settings) def create_category(self, name: str) -> dict[str, Any]: with self.lock: data = self._read() category = { "id": make_id("cat"), "name": name, "created_at": iso_now(), "tasks": [], } data.setdefault("categories", []).append(category) self._write(data) return deepcopy(category) def rename_category(self, category_id: str, name: str) -> dict[str, Any]: with self.lock: data = self._read() for category in data.get("categories", []): if category["id"] == category_id: category["name"] = name self._write(data) return deepcopy(category) raise KeyError("Category not found") def delete_category(self, category_id: str) -> None: with self.lock: data = self._read() original_count = len(data.get("categories", [])) data["categories"] = [ category for category in data.get("categories", []) if category["id"] != category_id ] if len(data["categories"]) == original_count: raise KeyError("Category not found") self._write(data) def add_task(self, category_id: str, title: str, due_at: str) -> dict[str, Any]: with self.lock: data = self._read() for category in data.get("categories", []): if category["id"] == category_id: item = { "id": make_id("task"), "title": title, "created_at": iso_now(), "due_at": due_at, "completed": False, "completed_at": None, "schedule": None, } category.setdefault("tasks", []).append(item) self._write(data) return deepcopy(item) raise KeyError("Category not found") def toggle_task(self, task_id: str, completed: bool) -> dict[str, Any]: with self.lock: data = self._read() for category in data.get("categories", []): for task in category.get("tasks", []): if task["id"] == task_id: task["completed"] = completed task["completed_at"] = iso_now() if completed else None self._write(data) return deepcopy(task) raise KeyError("Task not found") def delete_task(self, task_id: str) -> None: with self.lock: data = self._read() for category in data.get("categories", []): original_count = len(category.get("tasks", [])) category["tasks"] = [ task for task in category.get("tasks", []) if task["id"] != task_id ] if len(category["tasks"]) != original_count: self._write(data) return raise KeyError("Task not found") def schedule_task(self, task_id: str, schedule: dict[str, Any] | None) -> dict[str, Any]: with self.lock: data = self._read() for category in data.get("categories", []): for task in category.get("tasks", []): if task["id"] == task_id: task["schedule"] = deepcopy(schedule) if schedule else None self._write(data) task_copy = deepcopy(task) task_copy["category_id"] = category["id"] task_copy["category_name"] = category["name"] return task_copy raise KeyError("Task not found") def create_course(self, payload: dict[str, Any]) -> dict[str, Any]: with self.lock: data = self._read() courses = data.setdefault("courses", []) course = { "id": make_id("course"), "title": payload["title"], "day_of_week": payload["day_of_week"], "start_time": payload["start_time"], "end_time": payload["end_time"], "start_week": payload["start_week"], "end_week": payload["end_week"], "week_pattern": payload.get("week_pattern", "all"), "location": payload.get("location", ""), "color": payload.get( "color", COURSE_COLOR_PALETTE[len(courses) % len(COURSE_COLOR_PALETTE)], ), "created_at": iso_now(), } courses.append(course) self._write(data) return deepcopy(course) def update_course(self, course_id: str, payload: dict[str, Any]) -> dict[str, Any]: with self.lock: data = self._read() for course in data.get("courses", []): if course["id"] == course_id: course.update(payload) self._write(data) return deepcopy(course) raise KeyError("Course not found") def delete_course(self, course_id: str) -> None: with self.lock: data = self._read() original_count = len(data.get("courses", [])) data["courses"] = [ course for course in data.get("courses", []) if course["id"] != course_id ] if len(data["courses"]) == original_count: raise KeyError("Course not found") self._write(data)