66import json
77import subprocess
88import sys
9+ import threading
910from http .server import BaseHTTPRequestHandler , ThreadingHTTPServer
1011from pathlib import Path
1112from typing import Any
12- from urllib .parse import urlparse
13+ from urllib .parse import unquote , urlparse
1314
1415ROOT = Path (__file__ ).resolve ().parents [1 ]
1516SCRIPTS = ROOT / "scripts"
2627SOLVED_PATH = CATALOG_DIR / "solved-list.yaml"
2728PORT = 8765
2829
30+ # Allowed status values per catalog (see catalog/schema.md).
31+ READING_STATUSES = {"inbox" , "active" , "done" , "archived" }
32+ PROBLEM_STATUSES = {"open" , "wip" , "blocked" , "deferred" , "solved" }
33+
34+ # Serialize read-modify-write on YAML files (ThreadingHTTPServer is multi-threaded).
35+ _write_lock = threading .Lock ()
36+
2937
3038def refresh_data () -> None :
3139 subprocess .run (
@@ -35,14 +43,23 @@ def refresh_data() -> None:
3543 )
3644
3745
46+ def extract_id (path : str , prefix : str , suffix : str ) -> str | None :
47+ """Pull the URL-decoded id out of /<prefix>/<id>/<suffix> style paths."""
48+ if not (path .startswith (prefix ) and path .endswith (suffix )):
49+ return None
50+ middle = path [len (prefix ) : - len (suffix )].strip ("/" )
51+ return unquote (middle ) or None
52+
53+
3854def update_item_status (catalog : Path , item_id : str , status : str ) -> bool :
39- items = load_yaml (catalog )
40- for item in items :
41- if item .get ("id" ) == item_id :
42- item ["status" ] = status
43- item ["updated_at" ] = today ()
44- save_yaml (catalog , items )
45- return True
55+ with _write_lock :
56+ items = load_yaml (catalog )
57+ for item in items :
58+ if item .get ("id" ) == item_id :
59+ item ["status" ] = status
60+ item ["updated_at" ] = today ()
61+ save_yaml (catalog , items )
62+ return True
4663 return False
4764
4865
@@ -80,21 +97,15 @@ def do_GET(self) -> None:
8097 if not DATA_JSON .is_file ():
8198 refresh_data ()
8299 return self ._send_json (200 , json .loads (DATA_JSON .read_text (encoding = "utf-8" )))
83- if path .startswith ("/api/reading/" ) and path .endswith ("/detail" ):
84- item_id = path .split ("/" )[3 ]
85- for item in load_yaml (READING_PATH ):
86- if item .get ("id" ) == item_id :
87- return self ._send_json (200 , item )
88- return self ._send_json (404 , {"error" : "not found" })
89- if path .startswith ("/api/solved/" ) and path .endswith ("/detail" ):
90- item_id = path .split ("/" )[3 ]
91- for item in load_yaml (SOLVED_PATH ):
92- if item .get ("id" ) == item_id :
93- return self ._send_json (200 , item )
94- return self ._send_json (404 , {"error" : "not found" })
95- if path .startswith ("/api/problem/" ) and path .endswith ("/detail" ):
96- item_id = path .split ("/" )[3 ]
97- for item in load_yaml (PROBLEM_PATH ):
100+ for prefix , catalog in (
101+ ("/api/reading/" , READING_PATH ),
102+ ("/api/solved/" , SOLVED_PATH ),
103+ ("/api/problem/" , PROBLEM_PATH ),
104+ ):
105+ item_id = extract_id (path , prefix , "/detail" )
106+ if item_id is None :
107+ continue
108+ for item in load_yaml (catalog ):
98109 if item .get ("id" ) == item_id :
99110 return self ._send_json (200 , item )
100111 return self ._send_json (404 , {"error" : "not found" })
@@ -113,16 +124,18 @@ def do_PATCH(self) -> None:
113124 if not status :
114125 return self ._send_json (400 , {"error" : "status required" })
115126
116- if path .startswith ("/api/reading/" ) and path .endswith ("/status" ):
117- item_id = path .split ("/" )[3 ]
118- if update_item_status (READING_PATH , item_id , status ):
119- refresh_data ()
120- return self ._send_json (200 , {"ok" : True , "id" : item_id , "status" : status })
121- return self ._send_json (404 , {"error" : "not found" })
122-
123- if path .startswith ("/api/problem/" ) and path .endswith ("/status" ):
124- item_id = path .split ("/" )[3 ]
125- if update_item_status (PROBLEM_PATH , item_id , status ):
127+ for prefix , catalog , allowed in (
128+ ("/api/reading/" , READING_PATH , READING_STATUSES ),
129+ ("/api/problem/" , PROBLEM_PATH , PROBLEM_STATUSES ),
130+ ):
131+ item_id = extract_id (path , prefix , "/status" )
132+ if item_id is None :
133+ continue
134+ if status not in allowed :
135+ return self ._send_json (
136+ 400 , {"error" : f"invalid status; allowed: { sorted (allowed )} " }
137+ )
138+ if update_item_status (catalog , item_id , status ):
126139 refresh_data ()
127140 return self ._send_json (200 , {"ok" : True , "id" : item_id , "status" : status })
128141 return self ._send_json (404 , {"error" : "not found" })
0 commit comments