From 9553ca5ce754d81362a214cf37d542ec4287fd8e Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Wed, 13 May 2026 19:27:54 +0200 Subject: [PATCH] ideas: add JSON-file-backed ideas API (list, create, vote, delete) Ideas and votes are stored as flat JSON files in /var/bincio/_ideas/, following the same filesystem-first philosophy as segments and efforts. Vote toggling uses fcntl exclusive locking to prevent concurrent writes. --- bincio/serve/server.py | 107 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/bincio/serve/server.py b/bincio/serve/server.py index cadefd1..e5a3812 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -2719,6 +2719,113 @@ async def user_segment_summary(handle: str) -> JSONResponse: return JSONResponse(result) +# ── Ideas ──────────────────────────────────────────────────────────────────── + +import fcntl as _fcntl + +def _ideas_dir(data_dir: Path) -> Path: + d = data_dir / "_ideas" + d.mkdir(parents=True, exist_ok=True) + return d + + +class IdeaBody(BaseModel): + title: str + body: str = "" + + +@app.get("/api/ideas") +async def list_ideas( + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + user = _require_user(bincio_session) + dd = _get_data_dir() + ideas = [] + for path in sorted(_ideas_dir(dd).glob("*.json")): + try: + idea = json.loads(path.read_text(encoding="utf-8")) + except Exception: + continue + votes = idea.get("votes", []) + idea["vote_count"] = len(votes) + idea["my_vote"] = user.handle in votes + ideas.append(idea) + ideas.sort(key=lambda x: (-x["vote_count"], -x["created_at"])) + return JSONResponse({"ideas": ideas}) + + +@app.post("/api/ideas") +async def create_idea( + data: IdeaBody, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + user = _require_user(bincio_session) + title = data.title.strip()[:200] + body = data.body.strip()[:2000] + if not title: + raise HTTPException(400, "Title required") + dd = _get_data_dir() + idea_id = secrets.token_hex(8) + idea = { + "id": idea_id, + "title": title, + "body": body, + "author": user.handle, + "created_at": int(time.time()), + "votes": [], + } + path = _ideas_dir(dd) / f"{idea_id}.json" + path.write_text(json.dumps(idea, ensure_ascii=False, indent=2), encoding="utf-8") + return JSONResponse({"id": idea_id}) + + +@app.post("/api/ideas/{idea_id}/vote") +async def toggle_idea_vote( + idea_id: str, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + user = _require_user(bincio_session) + dd = _get_data_dir() + path = _ideas_dir(dd) / f"{idea_id}.json" + if not path.exists(): + raise HTTPException(404, "Not found") + with open(path, "r+", encoding="utf-8") as f: + _fcntl.flock(f, _fcntl.LOCK_EX) + idea = json.load(f) + votes: list = idea.get("votes", []) + if user.handle in votes: + votes.remove(user.handle) + voted = False + else: + votes.append(user.handle) + voted = True + idea["votes"] = votes + f.seek(0) + f.truncate() + json.dump(idea, f, ensure_ascii=False, indent=2) + return JSONResponse({"voted": voted, "votes": len(votes)}) + + +@app.delete("/api/ideas/{idea_id}") +async def delete_idea( + idea_id: str, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + user = _require_user(bincio_session) + dd = _get_data_dir() + path = _ideas_dir(dd) / f"{idea_id}.json" + if not path.exists(): + raise HTTPException(404, "Not found") + try: + idea = json.loads(path.read_text(encoding="utf-8")) + except Exception: + raise HTTPException(500, "Could not read idea") + if not user.is_admin and idea.get("author") != user.handle: + raise HTTPException(403, "Forbidden") + path.unlink() + return JSONResponse({"ok": True}) + + # ── Feedback ────────────────────────────────────────────────────────────────── _FEEDBACK_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"}