From 4fdfcc741512c4e16d0318ebd5ca76f1548313b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 09:07:22 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat(mcp):=20=D7=A4=D7=AA=D7=A7=D7=99=D7=9D?= =?UTF-8?q?=20=D7=93=D7=91=D7=99=D7=A7=D7=99=D7=9D=20=E2=80=94=20=D7=A6?= =?UTF-8?q?=D7=A4=D7=99=D7=99=D7=94,=20=D7=99=D7=A6=D7=99=D7=A8=D7=94=20?= =?UTF-8?q?=D7=95=D7=A2=D7=A8=D7=99=D7=9B=D7=94=20=D7=93=D7=A8=D7=9A=20?= =?UTF-8?q?=D7=94-MCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit שלושה כלים חדשים מעל קולקציית sticky_notes הקיימת של הוובאפ: - codekeeper_list_notes — פתקי הקובץ (קריאה טהורה, בלי ה-backfill של ה-GET בוובאפ) - codekeeper_create_note — פתק חדש על קובץ קיים; line מעגן לשורת מקור, בלעדיו הפתק נוצר צף עם sentinel __floating__ מפורש (אחרת ה-JS מעגן אוטומטית לשורה הקרובה ודורס את הכוונה) - codekeeper_update_note — עדכון חלקי לפי note_id, עם annotation נפרד (destructive+idempotent) כי פתק נדרס במקום ואין לו היסטוריית גרסאות עקרונות: - כותבים בדיוק את סכמת הוובאפ (scope_id מ-sticky_notes_scope.make_scope_id הקנוני, ברירות מחדל בפריטת הקליינט) — פתק מה-MCP מופיע מיד ב-UI - זהות תמיד מהטוקן; יצירה/עדכון מאחורי require_write - תוכן >5000 תווים נדחה בשגיאה (לא קיטום שקט — סוכן לא ישים לב לאובדן) - מגן אנטי-לולאה: עד 200 פתקים לקובץ ביצירה - אינדקס (user_id, scope_id) שחסר היום נוצר lazy/best-effort — משרת גם את שאילתת ה-scope הזהה של הוובאפ - בלי מחיקה ובלי תזכורות (non-goal מתועד); אפס שינויי קוד בוובאפ — דיפלוי לשירות ה-MCP בלבד בדיקות: 19 טסטים הרמטיים חדשים (סניטציה, ולידציות, sentinel, צבעים, scope filter מול make_scope_id האמיתי, סריאליזציה) + עדכון טסט הרישום. 188 טסטי MCP עוברים; black/flake8/doc8 נקיים; Sphinx נבנה עם 0 אזהרות. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WNFuSyshwpRcxozVZEui5K --- .../FEATURE_MCP_CLAUDE_INTEGRATION.md | 9 +- docs/mcp-server.rst | 12 +- mcp_server/README.md | 5 +- mcp_server/backend.py | 186 ++++++++++++++ mcp_server/handlers.py | 151 +++++++++++ mcp_server/server.py | 90 ++++++- tests/test_mcp_notes_handlers.py | 240 ++++++++++++++++++ tests/test_mcp_server_build.py | 12 + 8 files changed, 698 insertions(+), 7 deletions(-) create mode 100644 tests/test_mcp_notes_handlers.py diff --git a/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md b/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md index 7ea680ad5..faaebbbf7 100644 --- a/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md +++ b/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md @@ -1,6 +1,6 @@ # חיבור Claude.ai ל‑CodeKeeper דרך MCP — מסמך תכנון -> **סטטוס:** פאזות 0 (PAT) **+** 1 (OAuth 2.1 ל‑Claude.ai) **+** 3 (כתיבה: save + edit/append חלקיים) **+** ד' (דפדפן ריפו לאדמין, קריאה בלבד) **מומשו** — ראו `mcp_server/`. מחיקה וכתיבת אוספים עדיין בתכנון. +> **סטטוס:** פאזות 0 (PAT) **+** 1 (OAuth 2.1 ל‑Claude.ai) **+** 3 (כתיבה: save + edit/append חלקיים) **+** ד' (דפדפן ריפו לאדמין, קריאה בלבד) **+** פתקים דביקים (list/create/update) **מומשו** — ראו `mcp_server/`. מחיקה (קבצים ופתקים) וכתיבת אוספים עדיין בתכנון. > **ענף פיתוח:** `claude/mcp-codekeeper-webapp-ldnzsg` > **מתי להשתמש:** לפני מימוש חיבור MCP; מסמך זה הוא מקור האמת לתכנון. > **ראו גם:** `mcp_server/README.md` (שימוש), [CodeBot – Project Docs](https://amirbiron.github.io/CodeBot/), `CLAUDE.md` (מדיניות מחייבת). @@ -144,6 +144,11 @@ db.delete_file(user_id, file_name) # מחיקה רכה (recycle | `edit_file` *(פאזה 3 ✅)* | `file_name`, `old_string`, `new_string`, `replace_all?` | מצא‑והחלף → גרסה חדשה | `get_latest_version` + `save_file` | | `append_file` *(פאזה 3 ✅)* | `file_name`, `content` | הוספה לסוף → גרסה חדשה | `get_latest_version` + `save_file` | | `delete_file` *(פאזה 3 — טרם מומש)* | `file_name` | מחיקה רכה | `delete_file` | +| `list_notes` *(✅ מומש)* | `file_name` | פתקים דביקים של הקובץ (זהים ל‑UI) | `sticky_notes` + `make_scope_id` | +| `create_note` *(✅ מומש)* | `file_name`, `content`, `line?`, `color?`, `anchor_text?` | פתק חדש (צף בלי `line`) | `sticky_notes` + `get_latest_version` | +| `update_note` *(✅ מומש)* | `note_id`, שדות חלקיים | עדכון פתק במקום (ללא היסטוריה) | `sticky_notes` | + +**Non‑goal לפתקים:** אין מחיקת פתקים ואין תזכורות (`note_reminders`) דרך ה‑MCP — מוחקים/מתזמנים ב‑UI של הוובאפ. ### 5.2 Resources (אופציונלי, מומלץ) לחשוף כל קובץ כ‑`codekeeper://file/{file_name}` כדי לאפשר תיוג `@` של קובץ בתוך Claude. רשימת המשאבים = `get_user_files` (בלי תוכן), והקריאה בפועל = `get_latest_version`. @@ -215,7 +220,7 @@ db.delete_file(user_id, file_name) # מחיקה רכה (recycle | ג' | כתיבה: `save_file` + `edit_file`/`append_file` מאחורי scope (✅ מומש); `delete_file`, rate limiting, מסך "חיבורים פעילים" + revoke (טרם) | ~1–2 ימים | בדיקות כתיבה על tmp בלבד, אישור scope, revoke | | ד' | דפדפן הריפו (אדמין בלבד): `list_repos`/`list_repo_tree`/`get_repo_file`/`search_repo` מעל Repo Sync Engine + `require_admin` + סינון tools/list + מדיניות סודות (13.5) — ראו סעיף 13 | ~2–3 ימים | יחידה עם mirror מזויף/tmp, בדיקת fail‑closed לאדמין, מדיניות סודות (כל כלל + מקרי קצה + מדיניות‑חסרה), ברירות מחדל/תקרות/clamp, תקציב פלט, timeout, snippet בלבד בחיפוש | -**מיפוי ומצב (קנוני):** שלב א' = פאזה 0 (✅ מומש) · שלב ב' = פאזה 1 (✅ מומש) · שלב ג' = פאזה 3 (✅ מומש חלקית — save + edit/append; מחיקה טרם) · שלב ד' = פאזה ד' (✅ מומש — קריאה בלבד, אדמין). +**מיפוי ומצב (קנוני):** שלב א' = פאזה 0 (✅ מומש) · שלב ב' = פאזה 1 (✅ מומש) · שלב ג' = פאזה 3 (✅ מומש חלקית — save + edit/append; מחיקה טרם) · שלב ד' = פאזה ד' (✅ מומש — קריאה בלבד, אדמין) · פתקים דביקים (✅ מומש — list/create/update, בלי מחיקה/תזכורות). **בדיקות — לפי `CLAUDE.md`:** לעבוד רק על תיקיות זמניות, בלי מחיקות ב‑root, בידוד לכל טסט. לפני תיקוני טסטים — לעיין ב‑[CodeBot Docs](https://amirbiron.github.io/CodeBot/). diff --git a/docs/mcp-server.rst b/docs/mcp-server.rst index a774a6f88..6e15334b8 100644 --- a/docs/mcp-server.rst +++ b/docs/mcp-server.rst @@ -64,6 +64,15 @@ OAuth 2.1) וגם מול **Claude Code / Claude Desktop** (טוקן אישי). - **כתיבה:** הוספת טקסט לסוף קובץ קיים (גרסה חדשה). דורש ``write`` * - ``codekeeper_list_versions`` - היסטוריית גרסאות של קובץ + * - ``codekeeper_list_notes`` + - פתקים דביקים של קובץ (לפי ``file_name``) — אותם פתקים שמוצגים + ב-UI של הוובאפ + * - ``codekeeper_create_note`` + - **כתיבה:** יצירת פתק דביק על קובץ קיים; ``line`` אופציונלי מעגן + לשורת מקור (בלעדיו הפתק צף). דורש ``write`` + * - ``codekeeper_update_note`` + - **כתיבה:** עדכון חלקי של פתק לפי ``note_id`` (תוכן/שורה/צבע/מוזער) + — דורס במקום, אין היסטוריית גרסאות לפתקים. דורש ``write`` * - ``codekeeper_list_collections`` / ``codekeeper_get_collection`` / ``codekeeper_get_collection_items`` - האוספים והקבצים שבתוכם @@ -106,7 +115,8 @@ OAuth 2.1) וגם מול **Claude Code / Claude Desktop** (טוקן אישי). - ``read`` — ברירת המחדל לכל חיבור. - ``write`` — נדרש לכלי הכתיבה (``codekeeper_save_file`` / - ``codekeeper_edit_file`` / ``codekeeper_append_file``); ניתן רק באישור + ``codekeeper_edit_file`` / ``codekeeper_append_file`` / + ``codekeeper_create_note`` / ``codekeeper_update_note``); ניתן רק באישור מפורש (מסך ההרשאה ב-Claude.ai או טוקן ``write`` מהבוט). - **אדמין** — כלי הריפו זמינים רק למשתמשים שב-``ADMIN_USER_IDS``; לכל אחד אחר הם diff --git a/mcp_server/README.md b/mcp_server/README.md index ea2a8c14e..187f7d75d 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -19,7 +19,7 @@ Claude Desktop** (טוקן אישי). קריאה זמינה תמיד; **כתיב כל הכלים מקודמים ב‑`codekeeper_` (מונע התנגשות עם connectors אחרים). כולם read-only פרט לכלי הכתיבה `codekeeper_save_file`/`codekeeper_edit_file`/`codekeeper_append_file` -(דורשים הרשאת `write`). +וכלי הפתקים `codekeeper_create_note`/`codekeeper_update_note` (דורשים הרשאת `write`). | כלי | תיאור | |-----|-------| @@ -30,6 +30,9 @@ Claude Desktop** (טוקן אישי). קריאה זמינה תמיד; **כתיב | `codekeeper_edit_file` | **כתיבה:** מצא‑והחלף מדויק (`old_string`→`new_string`, אופציונלית `replace_all`) בלי לשלוח את כל הקובץ; גרסה חדשה, משמר שפה/תיאור/תגיות. דורש `write` | | `codekeeper_append_file` | **כתיבה:** הוספת טקסט לסוף קובץ קיים (מוסיף שורת‑הפרדה אם צריך); גרסה חדשה. דורש `write` | | `codekeeper_list_versions` | היסטוריית גרסאות של קובץ (מטא‑דאטה) | +| `codekeeper_list_notes` | פתקים דביקים של קובץ (לפי `file_name`) — אותם פתקים שמוצגים ב‑UI של הוובאפ | +| `codekeeper_create_note` | **כתיבה:** יצירת פתק דביק על קובץ קיים; `line` אופציונלי מעגן לשורת מקור (בלעדיו הפתק צף). דורש `write` | +| `codekeeper_update_note` | **כתיבה:** עדכון חלקי של פתק לפי `note_id` (תוכן/שורה/צבע/מוזער) — דורס במקום, אין היסטוריית גרסאות לפתקים. דורש `write` | | `codekeeper_list_collections` | האוספים של המשתמש | | `codekeeper_get_collection` | אוסף בודד לפי id | | `codekeeper_get_collection_items` | הקבצים בתוך אוסף (עם עימוד/סינון תיקייה) | diff --git a/mcp_server/backend.py b/mcp_server/backend.py index 495d293d2..85479b498 100644 --- a/mcp_server/backend.py +++ b/mcp_server/backend.py @@ -16,10 +16,17 @@ from __future__ import annotations import datetime as _dt +import html +import logging from typing import Any +logger = logging.getLogger(__name__) + _HEAVY_FIELDS = ("code", "content", "raw_data", "raw_content") +# שדות הפתק שנחשפים ל-MCP — רזה במכוון (בלי מיקום/גודל פיקסלים, שהם עניין ויזואלי) +_NOTE_FIELDS = ("content", "color", "line_start", "anchor_text", "is_minimized") + def _json_safe(value: Any) -> Any: """Recursively convert Mongo/BSON types to JSON-friendly values.""" @@ -70,6 +77,37 @@ def _strip_heavy(value: Any) -> Any: return value +def _as_note(doc: dict[str, Any]) -> dict[str, Any]: + """Serialize a sticky-note document for MCP output (lean, JSON-safe).""" + doc = doc or {} + out: dict[str, Any] = {"id": str(doc.get("_id") or "")} + for key in _NOTE_FIELDS: + out[key] = _json_safe(doc.get(key)) + # פתקי legacy נשמרו עם HTML entities — משחזרים טקסט כמו שהוובאפ עושה בקריאה + if isinstance(out.get("content"), str): + out["content"] = html.unescape(out["content"]) + out["created_at"] = _json_safe(doc.get("created_at")) + out["updated_at"] = _json_safe(doc.get("updated_at")) + return out + + +def _notes_scope_filter( + user_id: int, scope_id: str | None, related_ids: list[str] +) -> dict[str, Any]: + """The webapp-parity notes query: by scope_id, plus file_id for legacy notes. + + Module-level and pure so tests can assert the exact query shape. + """ + clauses: list[dict[str, Any]] = [] + if scope_id: + clauses.append({"scope_id": scope_id}) + if related_ids: + clauses.append({"file_id": {"$in": list(related_ids)}}) + if not clauses: + return {"user_id": int(user_id)} + return {"user_id": int(user_id), "$or": clauses} + + class ProductionBackend: """Backend backed by the real in-process ``database`` layer. @@ -83,6 +121,7 @@ def __init__( self._dbm = db_manager self._mongo = mongo_db self._cm = collections_manager + self._notes_idx_done = False # -- lazy wiring ------------------------------------------------------- def _require_dbm(self) -> Any: @@ -213,3 +252,150 @@ def get_collection_items( if isinstance(result, dict) and isinstance(result.get("items"), list): result["items"] = [_strip_heavy(item) for item in result["items"]] return result + + # -- sticky notes ------------------------------------------------------ + def _raw_mongo(self) -> Any: + mongo = self._mongo if self._mongo is not None else getattr(self._require_dbm(), "db", None) + if mongo is None: + raise RuntimeError("MongoDB handle unavailable for sticky notes") + return mongo + + def _notes_coll(self) -> Any: + coll = self._raw_mongo()["sticky_notes"] + # אינדקס שחסר היום לשאילתת ה-scope (משרת גם את הוובאפ); חד-פעמי, לא מפיל כלי + if not self._notes_idx_done: + self._notes_idx_done = True + try: + coll.create_index([("user_id", 1), ("scope_id", 1)], name="user_scope_idx") + except Exception: + logger.warning("sticky notes index creation failed (non-fatal)", exc_info=True) + return coll + + def _related_file_ids(self, user_id: int, file_name: str) -> list[str]: + """כל מזהי הגרסאות של השם הזה — לפריטת שאילתת הוובאפ (פתקי legacy בלי scope_id).""" + try: + rows = self._raw_mongo()["code_snippets"].find( + {"user_id": int(user_id), "file_name": file_name}, {"_id": 1} + ) + return [str(r["_id"]) for r in rows if r and r.get("_id") is not None] + except Exception: + logger.warning("related file ids lookup failed", exc_info=True) + return [] + + def list_notes(self, user_id: int, *, file_name: str) -> dict[str, Any]: + """List notes for a file (pure read — no backfill, unlike the webapp GET).""" + from sticky_notes_scope import make_scope_id # מודול טהור בשורש הריפו + + scope_id = make_scope_id(int(user_id), file_name) + related = self._related_file_ids(user_id, file_name) + query = _notes_scope_filter(user_id, scope_id, related) + rows = list(self._notes_coll().find(query).sort("created_at", 1).limit(500)) + return { + "ok": True, + "file_name": file_name, + "count": len(rows), + "notes": [_as_note(r) for r in rows], + } + + def create_note( + self, + user_id: int, + *, + file_name: str, + content: str, + line: int | None, + color: str, + anchor_text: str | None, + anchor_id: str | None, + ) -> dict[str, Any]: + """Insert a webapp-schema note attached to an existing file.""" + from .handlers import MAX_NOTES_PER_SCOPE + from sticky_notes_scope import make_scope_id + + doc = self._require_dbm().get_latest_version(int(user_id), file_name) + if not doc: + return { + "ok": False, + "error": "file_not_found", + "hint": "save the file first with codekeeper_save_file", + } + canonical_name = str(doc.get("file_name") or file_name) + scope_id = make_scope_id(int(user_id), canonical_name) + related = self._related_file_ids(user_id, canonical_name) + + coll = self._notes_coll() + try: + existing = int(coll.count_documents(_notes_scope_filter(user_id, scope_id, related))) + except Exception: + existing = 0 # המגן הוא soft-cap; כשל ספירה לא חוסם יצירה + if existing >= MAX_NOTES_PER_SCOPE: + return { + "ok": False, + "error": "too_many_notes", + "max": MAX_NOTES_PER_SCOPE, + "count": existing, + } + + now = _dt.datetime.now(_dt.timezone.utc) + note = { + "user_id": int(user_id), + "file_id": str(doc.get("_id") or ""), + "content": content, + # ברירות מחדל בפריטת הקליינט — פתק מה-MCP נראה כמו פתק שנוצר ביד + "position_x": 120, + "position_y": 120, + "width": 260, + "height": 200, + "color": color, + "is_minimized": False, + "line_start": line, + "line_end": None, + "anchor_id": anchor_id, + "anchor_text": anchor_text, + "scope_id": scope_id, + "file_name": canonical_name, + "created_at": now, + "updated_at": now, + } + res = coll.insert_one(note) + note["_id"] = getattr(res, "inserted_id", None) + return {"ok": True, "note": _as_note(note)} + + def update_note(self, user_id: int, *, note_id: str, fields: dict[str, Any]) -> dict[str, Any]: + """Partial in-place update by ObjectId, ownership enforced in the filter.""" + from bson import ObjectId # lazy heavy import + + try: + oid = ObjectId(str(note_id)) + except Exception: + return {"ok": False, "error": "invalid_note_id"} + + coll = self._notes_coll() + note = coll.find_one({"_id": oid, "user_id": int(user_id)}) + if not note: + return {"ok": False, "error": "not_found"} + + updates = dict(fields) + # backfill לפתק legacy בלי scope_id — רק במסלול הכתיבה (list נשאר קריאה טהורה) + if not note.get("scope_id"): + fname = note.get("file_name") + if not fname and note.get("file_id"): + try: + ref = self._raw_mongo()["code_snippets"].find_one( + {"_id": ObjectId(str(note["file_id"])), "user_id": int(user_id)}, + {"file_name": 1}, + ) + fname = (ref or {}).get("file_name") + except Exception: + fname = None + if fname: + from sticky_notes_scope import make_scope_id + + sid = make_scope_id(int(user_id), str(fname)) + if sid: + updates["scope_id"] = sid + updates["file_name"] = str(fname) + + updates["updated_at"] = _dt.datetime.now(_dt.timezone.utc) + coll.update_one({"_id": oid, "user_id": int(user_id)}, {"$set": updates}) + return {"ok": True, "note": _as_note({**note, **updates})} diff --git a/mcp_server/handlers.py b/mcp_server/handlers.py index 14eec3760..0edf61c21 100644 --- a/mcp_server/handlers.py +++ b/mcp_server/handlers.py @@ -10,6 +10,8 @@ from __future__ import annotations +import html +import re from typing import Any MAX_PER_PAGE = 200 @@ -264,3 +266,152 @@ def append_file(backend: Any, user_id: int, *, file_name: str, content: str) -> if not res.get("ok"): return res return {"ok": True, "appended_chars": len(content), "file": res.get("file")} + + +# -- sticky notes ---------------------------------------------------------- + +MAX_NOTE_CONTENT = 5000 +MAX_NOTES_PER_SCOPE = 200 +MAX_ANCHOR_TEXT = 256 +MAX_NOTE_LINE = 1_000_000 +DEFAULT_NOTE_COLOR = "#FFFFCC" +# sentinel של הוובאפ לפתק "צף": בלעדיו ה-JS מעגן פתק חדש אוטומטית לשורה הקרובה +NOTE_FLOATING_ANCHOR = "__floating__" + +_NOTE_ID_RE = re.compile(r"^[0-9a-fA-F]{24}$") +_NOTE_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{3,8}$") +# עותק של webapp/sticky_notes_api.py:_CONTROL_CHARS_RE — לשמור מסונכרן +_NOTE_CONTROL_CHARS_RE = re.compile(r"[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]") + + +def _sanitize_note_text(text: Any) -> str: + """Normalize note text like the webapp does — without truncating. + + Length is the caller's decision (reject, not clip): silent clipping is data + loss an agent won't notice. + """ + if text is None: + return "" + try: + s = str(text) + except Exception: + return "" + s = html.unescape(s) + s = s.replace("\r\n", "\n").replace("\r", "\n") + return _NOTE_CONTROL_CHARS_RE.sub("", s) + + +def _valid_note_line(line: Any) -> int | None: + """Coerce a 1-indexed source line; None on invalid.""" + try: + line_i = int(line) + except (TypeError, ValueError): + return None + if not 1 <= line_i <= MAX_NOTE_LINE: + return None + return line_i + + +def _clean_anchor_text(anchor_text: Any) -> str | None: + text = _sanitize_note_text(anchor_text).strip() + # קיטום קוסמטי בלבד (פריטת הוובאפ) — לא תוכן משתמש שאסור לאבד + return text[:MAX_ANCHOR_TEXT] or None + + +def list_notes(backend: Any, user_id: int, *, file_name: str) -> dict[str, Any]: + """List the user's sticky notes attached to ``file_name`` (read-only).""" + name = (file_name or "").strip() + if not name: + return {"ok": False, "error": "missing_file_name"} + return backend.list_notes(user_id, file_name=name) + + +def create_note( + backend: Any, + user_id: int, + *, + file_name: str, + content: str, + line: int | None = None, + color: str | None = None, + anchor_text: str | None = None, +) -> dict[str, Any]: + """Attach a sticky note to an existing file. + + With ``line`` the note anchors to that 1-indexed source line; without it the + note is created floating (explicit sentinel — otherwise the web client + auto-anchors it to the nearest line on first render). + """ + name = (file_name or "").strip() + if not name: + return {"ok": False, "error": "missing_file_name"} + + clean = _sanitize_note_text(content).strip() + if not clean: + return {"ok": False, "error": "empty_content"} + if len(clean) > MAX_NOTE_CONTENT: + return {"ok": False, "error": "content_too_long", "max": MAX_NOTE_CONTENT} + + line_i: int | None = None + if line is not None: + line_i = _valid_note_line(line) + if line_i is None: + return {"ok": False, "error": "invalid_line", "min": 1, "max": MAX_NOTE_LINE} + + color_s = (color or "").strip() + if not _NOTE_COLOR_RE.match(color_s): + color_s = DEFAULT_NOTE_COLOR # ביצירה: צבע לא חוקי נופל לברירת המחדל + + return backend.create_note( + user_id, + file_name=name, + content=clean, + line=line_i, + color=color_s, + anchor_text=_clean_anchor_text(anchor_text), + anchor_id=None if line_i else NOTE_FLOATING_ANCHOR, + ) + + +def update_note( + backend: Any, + user_id: int, + *, + note_id: str, + content: str | None = None, + line: int | None = None, + color: str | None = None, + anchor_text: str | None = None, + is_minimized: bool | None = None, +) -> dict[str, Any]: + """Partial update of a sticky note by its id (in-place, no version history).""" + nid = (note_id or "").strip() + if not _NOTE_ID_RE.match(nid): + return {"ok": False, "error": "invalid_note_id"} + + fields: dict[str, Any] = {} + if content is not None: + clean = _sanitize_note_text(content).strip() + if not clean: + return {"ok": False, "error": "empty_content"} + if len(clean) > MAX_NOTE_CONTENT: + return {"ok": False, "error": "content_too_long", "max": MAX_NOTE_CONTENT} + fields["content"] = clean + if line is not None: + line_i = _valid_note_line(line) + if line_i is None: + return {"ok": False, "error": "invalid_line", "min": 1, "max": MAX_NOTE_LINE} + # מעבר לעיגון-שורה מנקה עוגני כותרת/sentinel — כמו הקליינט של הוובאפ + fields.update({"line_start": line_i, "anchor_id": None, "line_end": None}) + if color is not None: + color_s = (color or "").strip() + if _NOTE_COLOR_RE.match(color_s): + fields["color"] = color_s # בעדכון: צבע לא חוקי נשמט, לא מוחלף בברירת מחדל + if anchor_text is not None: + fields["anchor_text"] = _clean_anchor_text(anchor_text) + if is_minimized is not None: + fields["is_minimized"] = bool(is_minimized) + + if not fields: + return {"ok": False, "error": "no_fields_to_update"} + return backend.update_note(user_id, note_id=nid, fields=fields) diff --git a/mcp_server/server.py b/mcp_server/server.py index 662f5ecb3..c3ddabbbd 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -35,6 +35,9 @@ "codekeeper_save_file to create or update a file, and prefer " "codekeeper_edit_file / codekeeper_append_file to change part of an existing " "file without resending all of it (write tools require write permission). " + "Sticky notes: codekeeper_list_notes reads a file's notes; " + "codekeeper_create_note / codekeeper_update_note add or change them (write " + "permission; notes appear in the CodeKeeper web UI). " "All data is scoped to the authenticated user." ) @@ -48,9 +51,10 @@ "openWorldHint": False, } -# The one write tool. Saves are append-only (an update creates a new version and -# never overwrites), so this is non-destructive; not idempotent because repeating -# it bumps the version each time. +# File-write tools (save/edit/append/create-note). These saves are append-only +# or additive (a file update creates a new version and never overwrites), so +# they are non-destructive; not idempotent because repeating one adds another +# version/note each time. _WRITE_TOOL = { "readOnlyHint": False, "destructiveHint": False, @@ -58,6 +62,16 @@ "openWorldHint": False, } +# In-place update (sticky-note edit): overwrites the note with no version +# history — destructive by the MCP definition, but idempotent (same input +# twice ⇒ same final state). +_UPDATE_IN_PLACE_TOOL = { + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": True, + "openWorldHint": False, +} + # Admin-only repo-browser tools (Phase D). Hidden from tools/list for # non-admins by AdminAwareFastMCP — but hiding is UX only, NOT access control: # every one of these also calls require_admin(ctx) in its body. @@ -287,6 +301,76 @@ def get_collection_items( folder=folder, ) + @mcp.tool( + name="codekeeper_list_notes", + description=( + "List the user's sticky notes attached to a file (by file_name): content, " + "color, anchored line, timestamps. Same notes shown in the web UI." + ), + annotations=_READ_ONLY_TOOL, + ) + def list_notes(ctx: Context, file_name: str) -> dict: + return handlers.list_notes(backend, current_user_id(ctx), file_name=file_name) + + @mcp.tool( + name="codekeeper_create_note", + description=( + "Attach a sticky note to an existing saved file. Optional line anchors it to " + "a 1-indexed source line (as read via codekeeper_get_file); without line the " + "note floats at a default position. Notes appear in the CodeKeeper web UI. " + "Requires write permission." + ), + annotations=_WRITE_TOOL, + ) + def create_note( + ctx: Context, + file_name: str, + content: str, + line: int | None = None, + color: str | None = None, + anchor_text: str | None = None, + ) -> dict: + require_write(ctx) # דחיית טוקן קריאה-בלבד לפני כל נגיעה בנתונים + return handlers.create_note( + backend, + current_user_id(ctx), + file_name=file_name, + content=content, + line=line, + color=color, + anchor_text=anchor_text, + ) + + @mcp.tool( + name="codekeeper_update_note", + description=( + "Update an existing sticky note by note_id (from codekeeper_list_notes): any " + "of content, line, color, anchor_text, is_minimized. Overwrites in place " + "(notes have no version history). Requires write permission." + ), + annotations=_UPDATE_IN_PLACE_TOOL, + ) + def update_note( + ctx: Context, + note_id: str, + content: str | None = None, + line: int | None = None, + color: str | None = None, + anchor_text: str | None = None, + is_minimized: bool | None = None, + ) -> dict: + require_write(ctx) # דחיית טוקן קריאה-בלבד לפני כל נגיעה בנתונים + return handlers.update_note( + backend, + current_user_id(ctx), + note_id=note_id, + content=content, + line=line, + color=color, + anchor_text=anchor_text, + is_minimized=is_minimized, + ) + if repo_backend is not None: _register_repo_tools(mcp, repo_backend) diff --git a/tests/test_mcp_notes_handlers.py b/tests/test_mcp_notes_handlers.py new file mode 100644 index 000000000..da89f701e --- /dev/null +++ b/tests/test_mcp_notes_handlers.py @@ -0,0 +1,240 @@ +"""טסטים ל-handlers של הפתקים הדביקים ב-MCP (list/create/update). + +הכול הרמטי: fake backend שמקליט kwargs, בלי Flask ובלי Mongo. את נכונות +ה-scope בודקים מול make_scope_id האמיתי (sticky_notes_scope בשורש — מודול טהור). +""" + +from mcp_server import handlers +from mcp_server.backend import _as_note, _notes_scope_filter +from mcp_server.handlers import ( + DEFAULT_NOTE_COLOR, + MAX_NOTE_CONTENT, + NOTE_FLOATING_ANCHOR, + _sanitize_note_text, +) + + +class _NotesBackend: + """Fake שמקליט את הקריאה האחרונה ומחזיר תשובת הצלחה קבועה.""" + + def __init__(self): + self.calls = [] + + def list_notes(self, user_id, *, file_name): + self.calls.append(("list", user_id, file_name)) + return {"ok": True, "file_name": file_name, "count": 0, "notes": []} + + def create_note(self, user_id, *, file_name, content, line, color, anchor_text, anchor_id): + self.calls.append( + ( + "create", + user_id, + { + "file_name": file_name, + "content": content, + "line": line, + "color": color, + "anchor_text": anchor_text, + "anchor_id": anchor_id, + }, + ) + ) + return {"ok": True, "note": {"id": "a" * 24, "content": content}} + + def update_note(self, user_id, *, note_id, fields): + self.calls.append(("update", user_id, note_id, dict(fields))) + return {"ok": True, "note": {"id": note_id, **fields}} + + @property + def last_kwargs(self): + return self.calls[-1][2] + + +# -- sanitizer -------------------------------------------------------------- + + +def test_sanitize_normalizes_crlf_and_strips_control_chars(): + assert _sanitize_note_text("a\r\nb\rc") == "a\nb\nc" + assert _sanitize_note_text("xyz") == "xyz" + assert _sanitize_note_text("שלום\tעולם") == "שלום\tעולם" # טאב מותר, כמו בוובאפ + + +def test_sanitize_unescapes_html_entities_and_handles_none(): + assert _sanitize_note_text(""ציטוט"") == '"ציטוט"' + assert _sanitize_note_text(None) == "" + + +# -- list_notes ------------------------------------------------------------- + + +def test_list_notes_requires_file_name(): + be = _NotesBackend() + assert handlers.list_notes(be, 7, file_name=" ") == { + "ok": False, + "error": "missing_file_name", + } + assert be.calls == [] + + +def test_list_notes_strips_name_and_delegates(): + be = _NotesBackend() + out = handlers.list_notes(be, 7, file_name=" notes.md ") + assert out["ok"] is True + assert be.calls[0] == ("list", 7, "notes.md") + + +# -- create_note ------------------------------------------------------------ + + +def test_create_note_rejects_empty_and_control_only_content(): + be = _NotesBackend() + assert handlers.create_note(be, 7, file_name="a.md", content=" ")["error"] == "empty_content" + assert handlers.create_note(be, 7, file_name="a.md", content="")["error"] == "empty_content" + assert be.calls == [] + + +def test_create_note_rejects_oversize_content(): + be = _NotesBackend() + out = handlers.create_note(be, 7, file_name="a.md", content="x" * (MAX_NOTE_CONTENT + 1)) + assert out == {"ok": False, "error": "content_too_long", "max": MAX_NOTE_CONTENT} + assert be.calls == [] + + +def test_create_note_sanitizes_content(): + be = _NotesBackend() + handlers.create_note(be, 7, file_name="a.md", content="a\r\nb"c") + assert be.last_kwargs["content"] == 'a\nb"c' + + +def test_create_note_invalid_line_rejected(): + be = _NotesBackend() + for bad in (0, -3, "x", 10**7): + out = handlers.create_note(be, 7, file_name="a.md", content="hi", line=bad) + assert out["error"] == "invalid_line", bad + assert be.calls == [] + + +def test_create_note_without_line_sets_floating_sentinel(): + be = _NotesBackend() + handlers.create_note(be, 7, file_name="a.md", content="hi") + assert be.last_kwargs["anchor_id"] == NOTE_FLOATING_ANCHOR + assert be.last_kwargs["line"] is None + + +def test_create_note_with_line_sets_no_sentinel(): + be = _NotesBackend() + handlers.create_note(be, 7, file_name="a.md", content="hi", line=42) + assert be.last_kwargs["anchor_id"] is None + assert be.last_kwargs["line"] == 42 + + +def test_create_note_color_default_and_fallback(): + be = _NotesBackend() + handlers.create_note(be, 7, file_name="a.md", content="hi") + assert be.last_kwargs["color"] == DEFAULT_NOTE_COLOR + handlers.create_note(be, 7, file_name="a.md", content="hi", color="red") + assert be.last_kwargs["color"] == DEFAULT_NOTE_COLOR # לא-חוקי ⇒ ברירת מחדל + handlers.create_note(be, 7, file_name="a.md", content="hi", color="#AABBCC") + assert be.last_kwargs["color"] == "#AABBCC" + + +def test_create_note_anchor_text_trimmed_and_capped(): + be = _NotesBackend() + handlers.create_note(be, 7, file_name="a.md", content="hi", anchor_text=" " + "t" * 300) + assert be.last_kwargs["anchor_text"] == "t" * 256 + handlers.create_note(be, 7, file_name="a.md", content="hi", anchor_text=" ") + assert be.last_kwargs["anchor_text"] is None + + +# -- update_note ------------------------------------------------------------ + + +def test_update_note_rejects_bad_note_id(): + be = _NotesBackend() + for bad in ("", "zzz", "a" * 23, "g" * 24): + out = handlers.update_note(be, 7, note_id=bad, content="x") + assert out == {"ok": False, "error": "invalid_note_id"}, bad + assert be.calls == [] + + +def test_update_note_requires_some_field(): + be = _NotesBackend() + out = handlers.update_note(be, 7, note_id="a" * 24) + assert out == {"ok": False, "error": "no_fields_to_update"} + # צבע לא-חוקי בעדכון נשמט — ואם זה השדה היחיד, אין מה לעדכן + out = handlers.update_note(be, 7, note_id="a" * 24, color="red") + assert out == {"ok": False, "error": "no_fields_to_update"} + assert be.calls == [] + + +def test_update_note_line_clears_anchor_fields(): + be = _NotesBackend() + handlers.update_note(be, 7, note_id="a" * 24, line=7) + assert be.calls[0][3] == {"line_start": 7, "anchor_id": None, "line_end": None} + + +def test_update_note_partial_fields_forwarded(): + be = _NotesBackend() + handlers.update_note(be, 7, note_id="b" * 24, content="new", is_minimized=1) + fields = be.calls[0][3] + assert fields == {"content": "new", "is_minimized": True} # רק מה שנמסר, bool אמיתי + + +def test_update_note_rejects_empty_and_oversize_content(): + be = _NotesBackend() + assert handlers.update_note(be, 7, note_id="a" * 24, content=" ")["error"] == "empty_content" + out = handlers.update_note(be, 7, note_id="a" * 24, content="x" * (MAX_NOTE_CONTENT + 1)) + assert out["error"] == "content_too_long" + assert be.calls == [] + + +# -- scope filter + serialization (מול make_scope_id האמיתי) --------------- + + +def test_notes_scope_filter_matches_webapp_shape(): + from sticky_notes_scope import make_scope_id + + sid = make_scope_id(42, "Notes.md") + assert sid is not None and sid.startswith("user:42:file:") + assert len(sid.rsplit(":", 1)[-1]) == 16 # 16 תווי hex + # נרמול רווחים ואותיות — שמות שקולים מקבלים אותו scope + assert make_scope_id(42, " notes.MD ") == sid + assert make_scope_id(42, "notes .md") == make_scope_id(42, "notes .md") + + q = _notes_scope_filter(42, sid, ["id1", "id2"]) + assert q == {"user_id": 42, "$or": [{"scope_id": sid}, {"file_id": {"$in": ["id1", "id2"]}}]} + assert _notes_scope_filter(42, sid, []) == {"user_id": 42, "$or": [{"scope_id": sid}]} + assert _notes_scope_filter(42, None, []) == {"user_id": 42} + + +def test_as_note_serialization(): + import datetime as dt + + doc = { + "_id": "OID", + "content": "a "b"", + "color": "#FFFFCC", + "line_start": 3, + "anchor_text": None, + "is_minimized": False, + "created_at": dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc), + "updated_at": dt.datetime(2026, 1, 2, tzinfo=dt.timezone.utc), + "position_x": 120, # שדה ויזואלי — לא נחשף + } + out = _as_note(doc) + assert out["id"] == "OID" + assert out["content"] == 'a "b"' # פתקי legacy עם entities משוחזרים + assert out["line_start"] == 3 + assert out["created_at"].startswith("2026-01-01") + assert out["updated_at"].startswith("2026-01-02") + assert "position_x" not in out + assert set(out) == { + "id", + "content", + "color", + "line_start", + "anchor_text", + "is_minimized", + "created_at", + "updated_at", + } diff --git a/tests/test_mcp_server_build.py b/tests/test_mcp_server_build.py index d56c02f28..829964b8b 100644 --- a/tests/test_mcp_server_build.py +++ b/tests/test_mcp_server_build.py @@ -14,6 +14,9 @@ "codekeeper_save_file", "codekeeper_edit_file", "codekeeper_append_file", + "codekeeper_list_notes", + "codekeeper_create_note", + "codekeeper_update_note", "codekeeper_list_versions", "codekeeper_list_collections", "codekeeper_get_collection", @@ -46,6 +49,15 @@ def get_collection_items(self, *a, **k): def save_file(self, *a, **k): return {"ok": True, "created": True, "file": {}} + def list_notes(self, *a, **k): + return {"ok": True, "notes": [], "count": 0} + + def create_note(self, *a, **k): + return {"ok": True, "note": {}} + + def update_note(self, *a, **k): + return {"ok": True, "note": {}} + class _FakeStore: def verify(self, token): From fd6b7063c644bedc2e76b6d38e3347e7e0d79bf9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 12:12:50 +0000 Subject: [PATCH 2/6] =?UTF-8?q?feat(webapp):=20=D7=A7=D7=99=D7=A6=D7=95?= =?UTF-8?q?=D7=A8=20=D7=93=D7=A8=D7=9A=20=D7=9C=D7=A2=D7=A8=D7=99=D7=9B?= =?UTF-8?q?=D7=AA=20=D7=AA=D7=99=D7=90=D7=95=D7=A8=20=D7=9E=D7=AA=D7=A4?= =?UTF-8?q?=D7=A8=D7=99=D7=98=20=E2=8B=AE=20=D7=91=D7=A2=D7=9E=D7=95=D7=93?= =?UTF-8?q?=20=D7=94=D7=A7=D7=95=D7=91=D7=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit הוספת פריט "תיאור" בתפריט 3-הנקודות בעמוד הצפייה בקובץ (view_file), ראשון ברשימה, שפותח מודאל קטן לעריכת שדה התיאור — חוסך את הכניסה לעריכת קובץ מלאה רק כדי לשנות תיאור. "נעץ לדשבורד" עלה לשני, "שתף" לשלישי. - משתמש ב-endpoint הקיים POST /api/file//quick-update שמעדכן את התיאור in-place (מטא-דאטה, בלי גרסה חדשה) — אין קוד שרת חדש - מודאל בדפוס מודאל השיתוף הקיים (Escape, לחיצה על הרקע, טוסט הצלחה), עם textarea ומונה תווים (עד 500, תואם למגבלת ה-endpoint) - עדכון חי של התצוגה מתחת לשם ושל תווית התפריט בלי רענון עמוד - זמין לכל הקבצים (התיאור הוא מטא-דאטה אוניברסלי) - אין בעיית מודאל-בתוך-מודאל: התפריט (dropdown) נסגר לפני שהמודאל נפתח, בדיוק כמו "שתף קובץ" הקיים - שינוי template בלבד; דורש דיפלוי לוובאפ בדיקות: Jinja parse תקין, תחביר JS תקין; אין קוד Python שהשתנה. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WNFuSyshwpRcxozVZEui5K --- webapp/templates/view_file.html | 193 +++++++++++++++++++++++++++++++- 1 file changed, 187 insertions(+), 6 deletions(-) diff --git a/webapp/templates/view_file.html b/webapp/templates/view_file.html index 738bf5565..954945171 100644 --- a/webapp/templates/view_file.html +++ b/webapp/templates/view_file.html @@ -764,10 +764,9 @@

{{ file.file_name }}

- {% if file.description %} -

{{ file.description }}

- {% endif %} - + {# התיאור תמיד קיים ב-DOM (מוסתר כשריק) כדי שנוכל לעדכנו חי אחרי שמירה #} +

{{ file.description }}

+ {% if file.tags %}
{% for tag in file.tags %} @@ -871,8 +870,11 @@

{{ file.file_name }}

+{# מודאל עריכת תיאור — קיצור דרך לשדה התיאור בלי להיכנס לעריכת קובץ מלאה #} + + `; @@ -2036,8 +2052,8 @@ itemsContainer.querySelectorAll('.collection-cards').forEach(section => { wireDnd(section, collectionId); }); - // Auto fit text for both old and new card styles - autoFitText('#collectionItems .collection-card__name', { minPx: 12, maxPx: 16, allowWrap: true }); + // Auto fit text for both old and new card styles + מיקום אייקון התיאור (Slot 1 ליד השם / Slot 2 בשורת הכפתורים) + layoutDescIcons(); autoFitText('#collectionItems .file', { minPx: 12, maxPx: 16 }); const loadMoreBtn = container.querySelector('.load-more'); if (loadMoreBtn) { @@ -2068,6 +2084,24 @@ ensureCollectionsSidebar(); container.innerHTML = '
האוסף נמחק. הקבצים נשארים זמינים בבוט ובמסך הקבצים.
'; }); + // ארכוב/שחזור אוסף — מנצל את PUT הקיים דרך שדה is_archived (כמו is_favorite) + const archiveBtn = container.querySelector('.collection-header .archive'); + const unarchiveBtn = container.querySelector('.collection-header .unarchive'); + if (archiveBtn) archiveBtn.addEventListener('click', async () => { + if (!confirm('להעביר את האוסף לארכיון? הוא ייעלם מהרשימה אך יישמר, וניתן לשחזר אותו דרך 🗄️ "הצג ארכיון".')) return; + const res = await api.updateCollection(collectionId, { is_archived: true }); + if (!res || !res.ok) return alert((res && res.error) || 'שגיאה בארכוב האוסף'); + ensureCollectionsSidebar(); + container.innerHTML = '
האוסף הועבר לארכיון. אפשר לשחזר אותו דרך 🗄️ "הצג ארכיון".
'; + }); + if (unarchiveBtn) unarchiveBtn.addEventListener('click', async () => { + const res = await api.updateCollection(collectionId, { is_archived: false }); + if (!res || !res.ok) return alert((res && res.error) || 'שגיאה בשחזור מהארכיון'); + // חוזרים לתצוגת האוספים הרגילים ומציגים את האוסף המשוחזר + showArchived = false; + ensureCollectionsSidebar(); + await renderCollectionItems(collectionId); + }); if (!isWorkspace && itemsContainer) { itemsContainer.addEventListener('click', async (ev) => { @@ -2863,13 +2897,15 @@ ${selectBox} ⋮⋮
-
- ${escapeHtml(fileName)} +
+ + ${item.description ? `` : ''}
${tagsHtml} ${item.note ? `📝 ${escapeHtml(item.note)}` : ''} - ${item.description ? `` : ''}
@@ -3539,6 +3575,42 @@ } catch(_e){ /* ignore */ } } + // מיקום אייקון התיאור בכרטיס אוסף: ברירת מחדל ליד השם (Slot 1); ואם השם ארוך + // ונשבר לשתי שורות — אין מקום בשורת השם, אז האייקון "קופץ" לשורת 4 הכפתורים, + // משמאל להם (RTL ⇒ ילד אחרון), עם רווח קטן (Slot 2). מנצל את זיהוי ה-is-wrapped + // של autoFitText, ולכן מודד את השם *ליד* האייקון (sibling ב-flex). + function layoutDescIcons(){ + try { + const cards = document.querySelectorAll('#collectionItems .collection-card'); + if (!cards || !cards.length) return; + // שלב 1: reset — כל אייקון חוזר ל-Slot 1 (מיד אחרי השם, בתוך ה-title-row) + cards.forEach((card) => { + const icon = card.querySelector('.desc-info'); + const titleRow = card.querySelector('.collection-card__title-row'); + if (icon && titleRow && icon.parentElement !== titleRow) { + titleRow.appendChild(icon); + } + }); + // שלב 2: התאמת גודל השם — מודד את הרוחב שנשאר *ליד* האייקון (הוא sibling ב-flex) + autoFitText('#collectionItems .collection-card__name', { minPx: 12, maxPx: 16, allowWrap: true }); + // שלב 3: שם שנשבר לשתי שורות ⇒ אין מקום ⇒ האייקון עובר לשורת הכפתורים (ילד אחרון) + let moved = false; + cards.forEach((card) => { + const name = card.querySelector('.collection-card__name'); + const icon = card.querySelector('.desc-info'); + const actions = card.querySelector('.collection-card__actions'); + if (name && icon && actions && name.classList.contains('is-wrapped')) { + actions.appendChild(icon); + moved = true; + } + }); + // רה-פיט לשמות שהאייקון עזב (עכשיו ברוחב מלא) — רק אם באמת הזזנו משהו + if (moved) { + autoFitText('#collectionItems .collection-card__name', { minPx: 12, maxPx: 16, allowWrap: true }); + } + } catch (_e) { /* ignore */ } + } + // תזמון/חסימה של ארועי resize כדי לא להציף חישובים function debounce(fn, wait){ let t = null; @@ -3562,7 +3634,7 @@ // עדכון אוטומטי בהתאמת חלון const onResize = throttle(() => { autoFitText('#collectionItems .file', { minPx: 12, maxPx: 16 }); - autoFitText('#collectionItems .collection-card__name', { minPx: 12, maxPx: 16, allowWrap: true }); + layoutDescIcons(); autoFitText('#collectionsSidebar .sidebar-item .name', { minPx: 12, maxPx: 16 }); autoFitText('.workspace-card__name', { minPx: 12, maxPx: 16, allowWrap: true }); }, 150); From 991b1ad24c335dc0b1f869083dac451729c50a13 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 19:16:27 +0000 Subject: [PATCH 6/6] =?UTF-8?q?fix(webapp):=20=D7=94=D7=9E=D7=A8=D7=AA=20?= =?UTF-8?q?=D7=A6=D7=91=D7=A2=D7=99=D7=9D=20=D7=A7=D7=A9=D7=99=D7=97=D7=99?= =?UTF-8?q?=D7=9D=20=D7=9C=D7=98=D7=95=D7=A7=D7=A0=D7=99=20=D7=A2=D7=A8?= =?UTF-8?q?=D7=9B=D7=94=20=D7=91=D7=9E=D7=95=D7=93=D7=90=D7=9C=20=D7=94?= =?UTF-8?q?=D7=AA=D7=99=D7=90=D7=95=D7=A8=20=D7=95=D7=91=D7=9B=D7=A4=D7=AA?= =?UTF-8?q?=D7=95=D7=A8=20=D7=94=D7=90=D7=A8=D7=9B=D7=99=D7=95=D7=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit לפי theming_and_css.rst אסורים צבעים קשיחים בקבצי רכיבים — רק var(--token). שתי חריגות הומרו: - מודאל "ערוך תיאור" (view_file.html): הרקע הכהה הקבוע (#1f2a44, #fff, rgba לבנים, focus בצבע primary קשיח) הוחלף בטוקנים סמנטיים — bg-secondary/tertiary, text-primary/secondary/muted, glass-border, primary; ה-scrim וה-shadow קיבלו טוקן-רכיב עם fallback (var(--modal-backdrop, ...), var(--solid-surface-shadow, ...)). כך המודאל מקבל את צבעי הערכה גם בערכות בהירות (rose-pine-dawn, classic). - כפתור "הצג ארכיון" במצב לחוץ (collections.css): rgba לבנים קשיחים הוחלפו בטוקני glass קיימים (glass-hover/glass-border/glass). מודאל ה-ℹ️ באוספים נבדק ונמצא תקין (יורש var(--collections-modal-*)) — ללא שינוי. מודאל השיתוף הסמוך הוא legacy קיים ולא נכלל (חוב נפרד). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WNFuSyshwpRcxozVZEui5K --- webapp/static/css/collections.css | 4 ++-- webapp/templates/view_file.html | 26 +++++++++++++------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/webapp/static/css/collections.css b/webapp/static/css/collections.css index 8cd1ba59c..8b70a8812 100644 --- a/webapp/static/css/collections.css +++ b/webapp/static/css/collections.css @@ -347,8 +347,8 @@ .sidebar-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:.5rem} .sidebar-header .title{font-weight:700} .sidebar-header__actions{display:flex;align-items:center;gap:.35rem} -/* כפתור "הצג ארכיון" במצב פעיל (aria-pressed) — מודגש כדי לסמן שאנחנו בתצוגת ארכיון */ -#toggleArchivedBtn[aria-pressed="true"],#toggleArchivedBtn.is-active{background:rgba(255,255,255,.2);border-color:rgba(255,255,255,.45);box-shadow:0 0 0 1px rgba(255,255,255,.1) inset} +/* כפתור "הצג ארכיון" במצב פעיל (aria-pressed) — מודגש דרך טוקני glass (בלי צבעים קשיחים) */ +#toggleArchivedBtn[aria-pressed="true"],#toggleArchivedBtn.is-active{background:var(--glass-hover);border-color:var(--glass-border);box-shadow:0 0 0 1px var(--glass) inset} .sidebar-search{margin:.5rem 0} .sidebar-search input{width:100%;padding:.5rem .6rem;border-radius:8px;border:1px solid var(--glass-border);background:rgba(255,255,255,.1);color:var(--text-primary)} .sidebar-list{display:flex;flex-direction:column;gap:.25rem;max-height:60vh;overflow:auto;scrollbar-width:thin} diff --git a/webapp/templates/view_file.html b/webapp/templates/view_file.html index 954945171..401062a14 100644 --- a/webapp/templates/view_file.html +++ b/webapp/templates/view_file.html @@ -1166,28 +1166,28 @@

🗑️ העבר לסל

color: #fff; } -/* מודאל עריכת תיאור — מחקה את מודאל השיתוף, עם textarea ומונה תווים */ +/* מודאל עריכת תיאור — כמו מודאל השיתוף אך על טוקני ערכה (theming_and_css.rst: בלי צבעים קשיחים) */ .desc-modal { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; - background: rgba(0, 0, 0, 0.55); + background: var(--modal-backdrop, rgba(0, 0, 0, 0.55)); z-index: 9999; } .desc-modal[hidden] { display: none; } .desc-modal__content { - background: #1f2a44; + background: var(--bg-secondary); border-radius: 16px; padding: 1.5rem; max-width: 460px; width: calc(100% - 2rem); - color: #fff; + color: var(--text-primary); position: relative; - box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35); + box-shadow: var(--solid-surface-shadow, 0 12px 30px rgba(0, 0, 0, 0.35)); } .desc-modal__title { margin: 0 0 0.5rem; @@ -1195,15 +1195,15 @@

🗑️ העבר לסל

} .desc-modal__subtitle { margin: 0 0 1rem; - color: rgba(255, 255, 255, 0.8); + color: var(--text-secondary); } .desc-modal__textarea { width: 100%; box-sizing: border-box; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.2); + background: var(--bg-tertiary); + border: 1px solid var(--glass-border); border-radius: 10px; - color: #fff; + color: var(--text-primary); padding: 0.75rem; font-size: 1rem; line-height: 1.5; @@ -1212,12 +1212,12 @@

🗑️ העבר לסל

} .desc-modal__textarea:focus { outline: none; - border-color: rgba(102, 126, 234, 0.8); + border-color: var(--primary); } .desc-modal__counter { text-align: left; font-size: 0.85rem; - color: rgba(255, 255, 255, 0.6); + color: var(--text-muted); margin: 0.35rem 0 1rem; } .desc-modal__actions { @@ -1234,13 +1234,13 @@

🗑️ העבר לסל

left: 0.75rem; background: transparent; border: none; - color: rgba(255, 255, 255, 0.7); + color: var(--text-secondary); font-size: 1.1rem; cursor: pointer; } .desc-modal__close:hover, .desc-modal__close:focus { - color: #fff; + color: var(--text-primary); } .glass-modal {