diff --git a/docs/database/detailed-schema.rst b/docs/database/detailed-schema.rst index 75f8fe98b..1bdb0a85b 100644 --- a/docs/database/detailed-schema.rst +++ b/docs/database/detailed-schema.rst @@ -522,6 +522,100 @@ { name: "type_idx" } ) +אוסף: sticky_notes +------------------- + +**תיאור:** פתקים דביקים. פתק שייך **או** לקובץ **או** ללוח — בדיוק אחד מהשניים. + +.. list-table:: שדות sticky_notes (חלקי) + :header-rows: 1 + :widths: 18 14 16 52 + + * - שדה + - סוג + - חובה + - תיאור + * - ``user_id`` + - int + - כן + - בעל הפתק. נמצא בכל שאילתה. + * - ``file_id`` + - str + - מותנה + - הקובץ שאליו הפתק צמוד. ריק בפתקי לוח. + * - ``board_id`` + - str + - מותנה + - הלוח שעליו הפתק יושב. ריק בפתקי קובץ. + * - ``scope_id`` + - str + - לא + - מפתח פילוח לפי שם הקובץ. קיים רק בפתקי קובץ. + * - ``mode`` + - str + - לא + - ``surface`` (נע עם הלוח) או ``screen`` (צמוד למסך). נכתב רק בפתקי לוח; בפתקי קובץ המצב עדיין נגזר מ-``anchor_id``. + * - ``content`` + - str + - כן + - עד 5,000 תווים. + * - ``position_x`` / ``position_y`` + - int + - כן + - מיקום. בפתק לוח הקואורדינטות יחסיות למשטח, ולא למסמך. + * - ``width`` / ``height`` + - int + - כן + - גודל. + +.. important:: + **האילוץ "בדיוק אחד" נאכף ב-**\ ``sticky_notes_target.build_note_target``, שכל ארבעת מסלולי הכתיבה עוברים דרכו. אין ``$jsonSchema`` validator ברמת מונגו, כי אין בריפו תשתית migrations שתחזיק אותו — במקומו ``scripts/migrate_note_boards.py`` מדפיס דוח הפרות, והרצה חוזרת שלו היא הבדיקה. + +**אינדקסים:** ``(user_id, file_id)``, ``(user_id, file_id, created_at)``, ``(user_id, scope_id)``, ``(user_id, board_id)``, ``(updated_at)``. + +אוסף: note_boards +------------------ + +**תיאור:** לוחות פתקים. משטח שעליו יושבים פתקים שאינם צמודים לקובץ. + +.. list-table:: שדות note_boards + :header-rows: 1 + :widths: 18 14 16 52 + + * - שדה + - סוג + - חובה + - תיאור + * - ``_id`` + - ObjectId + - כן + - מזהה הלוח. יציב — ולכן אין צורך ב-``scope_id`` לפתקי לוח. + * - ``user_id`` + - int + - כן + - בעל הלוח. + * - ``name`` + - str + - כן + - שם הלוח. ניתן לשינוי, כולל בלוח ברירת המחדל. + * - ``is_default`` + - bool + - כן + - לוח ברירת המחדל. **הזיהוי הוא לפי השדה הזה ולא לפי השם**, כי השם ניתן לשינוי. + * - ``order`` + - int + - כן + - סדר תצוגה. + * - ``created_at`` / ``updated_at`` + - datetime + - כן + - חותמות זמן. + +**אינדקסים:** ``(user_id, order)``, ו-``(user_id)`` **ייחודי-חלקי** על ``is_default: true``. + +.. note:: + האינדקס הייחודי-החלקי הוא מה שסוגר את המרוץ שבו שתי בקשות מקבילות מגלות שאין לוח ברירת מחדל ושתיהן יוצרות. הגנת קוד לבדה אינה מספיקה שם — המסד חייב לדחות, והקוד קורא שוב ומחזיר את הלוח שנוצר. + יחסים בין אוספים ------------------ diff --git a/docs/user/sticky_notes.rst b/docs/user/sticky_notes.rst index 1d537d54a..24be9ea08 100644 --- a/docs/user/sticky_notes.rst +++ b/docs/user/sticky_notes.rst @@ -43,9 +43,51 @@ מגבלות, פרטיות ואבטחה ----------------------- -- מגבלות שימוש: עד 200 פתקים לקובץ ועד 1000 פתקים לכל משתמש. +- מגבלות שימוש: עד 200 פתקים ללוח, ועד 1000 פתקים לכל משתמש — התקרה השנייה חלה על כל הפתקים, גם אלה שעל קבצים. עד לגרסה זו שתיהן היו מתועדות אך נאכפו רק במסלול ה-MCP, וגם שם באופן חלקי; כעת הן נאכפות בשכבת ה-API. משתמשי אדמין פטורים. +- אורך פתק: עד 5,000 תווים. מעבר לתקרה השרת שומר את 5,000 התווים הראשונים בלבד; הממשק מציג חיווי אדום עם מספר התווים בפועל ברגע שחורגים, כדי שהחיתוך לא יקרה בשקט. - פרטיות: פתקים נשמרים לחשבון המשתמש בלבד, ואינם מוצגים לאחרים כברירת מחדל. +לוחות פתקים +------------- +פתק לא חייב להיות צמוד לקובץ. לוח הוא משטח שעליו יושבים פתקים שאינם שייכים לשום מסמך — רשימת משימות, רעיונות, או כל דבר שאין לו מקום טבעי בתוך קובץ. הכניסה דרך כפתור קיצורי הדרך בסרגל העליון, בפריט "לוחות פתקים". + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - נושא + - התנהגות + * - יצירת לוח + - "לוח חדש" בעמוד הלוחות. השם ניתן לשינוי בכל עת מתוך הלוח עצמו. + * - לוח ברירת המחדל + - לוח בשם ``לוח עבודה`` נוצר אוטומטית בכניסה הראשונה. אפשר לשנות את שמו, אבל **אי אפשר למחוק אותו** — הוא היעד של כל פתק שנשאר בלי לוח. + * - מחיקת לוח + - הפתקים שעליו עוברים ללוח ברירת המחדל, והממשק מדווח כמה עברו. אם ההעברה לא הושלמה — הלוח **אינו** נמחק, כדי שאף פתק לא יתייתם. + * - מצב הפתק + - "מוצמד ללוח" (ברירת המחדל) — הפתק חלק מהמשטח וזז איתו. "צמוד למסך" — נשאר מול העיניים גם כשגוללים בלוח. הכפתור ``📌`` בראש הפתק מחליף ביניהם, והוא דלוק רק כשהפתק צמוד למסך. + * - גבולות המשטח + - פתק אינו יוצא מגבולות הלוח. גרירה כלפי מעלה נעצרת בקצה העליון, כי הכותרת של הפתק היא גם ידית הגרירה — פתק שהכותרת שלו מעל הקצה היה נשאר תקוע. + +בלוח אין עיגון לשורת מקור, כי אין טקסט להיצמד אליו — ולכן גם **בעיית הפתק המיותם אינה קיימת שם**. שום דבר לא זז מתחת לפתק על לוח. + +צ'קבוקסים בפתק +~~~~~~~~~~~~~~~ +שורה בצורת ``- [ ] טקסט`` מוצגת כתיבת סימון שאפשר ללחוץ עליה. הלחיצה נשמרת מיד לתוכן הפתק. + +.. important:: + הלחיצה היא **כתיבה למסד ולא שינוי תצוגה**. השרת קורא את הפתק מחדש אחרי הכתיבה ומוודא שהתו אכן השתנה; אם לא — התצוגה חוזרת למצב הקודם ומוצג חיווי שגיאה. סימון שנראה כאילו נשמר ולא נשמר הוא בדיוק מה שהתכנון הזה מונע. + +שתי התנהגויות שכדאי להכיר: שורות ``- [ ]`` זהות מזוהות לפי **סדר ההופעה** שלהן, כך שאפשר לסמן כל אחת בנפרד; ושורת ``- [ ]`` בתוך בלוק קוד נספרת גם היא, כי הפתק אינו עובר פרסור מארקדאון מלא. + +כשיש בפתק צ'קבוקס אחד לפחות, הפתק עובר לתצוגה שבה התיבות ניתנות ללחיצה. **התצוגה מציגה את כל תוכן הפתק** — שורות שאינן משימה נשארות גלויות כטקסט. לחיצה על טקסט כלשהו בתצוגה מחזירה לעריכה, עם הסמן בתחילת השורה שנלחצה. + +.. note:: + התצוגה היא **חד-כיוונית**: ``content`` הופך ל-HTML, ולעולם לא להפך. הדברים היחידים שכותבים לתוכן הפתק הם תיבת העריכה עצמה וראוט הצ'קבוקס, שמקבל מספר סידורי ומצב רצוי — לא טקסט. לכן רינדור אינו יכול לאבד שורה. + +תזכורות בפתקי לוח +~~~~~~~~~~~~~~~~~~ +עובדות בדיוק כמו בפתקי קובץ. לחיצה על ההתראה או על הפעמון פותחת את הקישור הקבוע ``/note/``, שמפנה למקום שבו הפתק באמת יושב — קובץ או לוח. + טיפים שימושיים --------------- - העדיפו עוגנים יציבים כדי לשמר דיוק גם לאחר עריכות. diff --git a/docs/whats-new.rst b/docs/whats-new.rst index 3385b1449..14c67c53a 100644 --- a/docs/whats-new.rst +++ b/docs/whats-new.rst @@ -2,6 +2,17 @@ What's New ========== :summary: יומן השינויים של הבוט וה-WebApp לפי תאריך — מה נוסף, מה השתנה ומה תוקן בכל עדכון, עם קישורים ל-Issues הרלוונטיים. +2026-08-21 +---------- +- feat: **לוחות פתקים** — פתקים שאינם צמודים לקובץ. יצירה, שינוי שם, מחיקה עם העברת הפתקים ללוח ברירת המחדל, ושני מצבי מיקום (מוצמד ללוח / צמוד למסך). כניסה מכפתור קיצורי הדרך בסרגל העליון. +- feat: צ'קבוקסים לחיצים בתוך פתק. הלחיצה נשמרת למסד, והשרת מאמת בקריאה חוזרת שהתו אכן השתנה — בכשל התצוגה חוזרת אחורה עם חיווי. +- feat: קישור קבוע ``/note/`` שמפנה לקובץ או ללוח לפי סוג הפתק. התראות תזכורת והפעמון עברו להשתמש בו במקום לבנות ``/md/``, מה שגרם לפתקי לוח ליפול לשורש האתר. +- fix: תקרות הפתקים (200 ללוח, 1000 למשתמש) נאכפות בשכבת ה-API. הן היו מתועדות ונאכפו רק חלקית במסלול ה-MCP. אדמין פטור. +- fix: פתקי לוח שורדים גיבוי ושחזור. השחזור דילג בשקט על כל פתק בלי ``file_name``. +- fix: ``_notes_scope_filter`` ב-MCP החזיר את כל הפתקים של המשתמש כשאין scope, במקום את הפתקים של הקובץ המבוקש. +- fix: נוסף אינדקס ``(user_id, scope_id)`` שחסר ב-webapp, למרות שזה ענף מרכזי בשאילתת הפתקים. +- refactor: ``is_admin``/``is_premium`` אוחדו ל-``user_roles``. הלוגיקה הייתה משוכפלת מילה במילה בשני קבצים. + 2026-01-29 ---------- - feat: תיוג פריטים ב"אוספים שלי" עם תגיות אימוג'י, עורך תגיות, סינון/מיון ובחירה מרובה. diff --git a/mcp_server/backend.py b/mcp_server/backend.py index e19051f35..bd1bc81fe 100644 --- a/mcp_server/backend.py +++ b/mcp_server/backend.py @@ -104,7 +104,12 @@ def _notes_scope_filter( if related_ids: clauses.append({"file_id": {"$in": list(related_ids)}}) if not clauses: - return {"user_id": int(user_id)} + # בלי אף clause השאילתה הייתה ``{"user_id": uid}`` — כלומר **כל** + # הפתקים של המשתמש, ולא הפתקים של הקובץ שהתבקש. היום זה לא נגיש, + # כי scope_id תמיד מחושב משם קובץ לא-ריק; משנוספו פתקי לוח, פתק + # שאינו שייך לשום קובץ היה נשאב לתשובה. שאילתה שלא תופסת דבר היא + # התשובה הנכונה ל"אין לי לפי מה לחפש". + return {"user_id": int(user_id), "_id": {"$in": []}} return {"user_id": int(user_id), "$or": clauses} diff --git a/note_boards.py b/note_boards.py new file mode 100644 index 000000000..92daac798 --- /dev/null +++ b/note_boards.py @@ -0,0 +1,150 @@ +""" +Note Boards — לוחות פתקים, ולוח ברירת המחדל שאי אפשר לאבד. + +לוח הוא משטח שעליו יושבים פתקים שאינם שייכים לשום קובץ. המודול הזה מחזיק את +המנגנון של לוח ברירת המחדל ואת ריפוי הפתקים היתומים, ומקבל ``db`` כפרמטר — +בדיוק כמו ``sticky_notes_scope.sync_sticky_notes_on_rename`` — כדי שיהיה +ניתן לבדיקה מול stub בלי להרים אפליקציה. + +**במה זה נבדל מ"שולחן עבודה" באוספים, ולמה במכוון.** ב-``collections_manager`` +אין שדה ``is_default`` בכלל: הזיהוי הוא השוואת המחרוזת ``"שולחן עבודה"`` +בשישה קבצים, אין שום חסימת מחיקה, וכשהאוסף נמחק נוצר בטעינה הבאה אוסף +**חדש וריק**. באוספים זה נסבל, כי הקבצים עצמם חיים ב-``code_snippets`` +ושורדים — מוחקים אוסף ומאבדים את הסידור, לא את התוכן. + +בלוח זה לא נסבל: הלוח **הוא** המקום היחיד של הפתק. לוח ברירת מחדל שנמחק +והוחלף בלוח חדש וריק היה משאיר את הפתקים במסד עם ``board_id`` שמצביע ללוח +מת — בלתי נראים בממשק ובלתי ניתנים לשחזור בלי גישה ישירה למונגו. ובנוסף, +זיהוי לפי שם נשבר ברגע שהמשתמש משנה את שם הלוח, וזה מותר במפורש. + +לכן: ``is_default`` אמיתי, אינדקס ייחודי שמונע שני לוחות ברירת מחדל, וריפוי +שמתקן את ה**קישור** של פתק יתום במקום לייצר לוח פנטום. +""" +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +#: שם לוח ברירת המחדל. ניתן לשינוי על ידי המשתמש — הזיהוי הוא ``is_default`` +#: ולא השם, ולכן שינוי שם אינו שובר כלום. +DEFAULT_BOARD_NAME = "לוח עבודה" + +#: אורך מרבי לשם לוח. +MAX_BOARD_NAME = 120 + + +def _boards_coll(db: Any): + return getattr(db, "note_boards", None) + + +def _notes_coll(db: Any): + return getattr(db, "sticky_notes", None) + + +def normalize_board_name(value: Any) -> str: + """שם לוח מנורמל. ריק ⇒ שם ברירת המחדל, כדי שלא ייווצר לוח בלי שם.""" + name = " ".join(str(value or "").split())[:MAX_BOARD_NAME] + return name or DEFAULT_BOARD_NAME + + +def ensure_default_board(db: Any, user_id: int) -> Optional[str]: + """מזהה לוח ברירת המחדל של המשתמש, ויוצר אותו אם אינו קיים. + + אידמפוטנטית. מחזירה את מזהה הלוח כמחרוזת, או ``None`` אם אין אוסף. + + **על המרוץ:** שתי בקשות מקבילות של אותו משתמש יכולות שתיהן לגלות שאין + לוח ולנסות ליצור. הגנת הקוד לבדה אינה מספיקה כאן, ולכן האינדקס + ``one_default_per_user`` הוא ייחודי-חלקי (``is_default: True``) והמסד + דוחה את השני. הכשל הזה צפוי ולא חריג, ולכן אחריו קוראים שוב — ומחזירים + את הלוח שהמנצח יצר. + """ + coll = _boards_coll(db) + if coll is None: + return None + uid = int(user_id) + + existing = coll.find_one({"user_id": uid, "is_default": True}) + if existing: + return str(existing.get("_id") or "") or None + + now = datetime.now(timezone.utc) + doc: Dict[str, Any] = { + "user_id": uid, + "name": DEFAULT_BOARD_NAME, + "is_default": True, + "order": 0, + "created_at": now, + "updated_at": now, + } + try: + coll.insert_one(doc) + except Exception as exc: + # מרוץ, או כל כשל אחר — לא מסיקים דבר מהחריגה עצמה. + logger.info("default board insert did not complete: %s", exc) + + # אימות בקריאה חוזרת, ולא הסתמכות על ערך ההחזרה של הכתיבה: ``insert_one`` + # שהחזיר ``inserted_id`` אינו מוכיח שהמסמך נמצא, והמרוץ מייצר בדיוק את + # המצב שבו הכתיבה שלנו נדחתה אבל הלוח כן קיים. + confirmed = coll.find_one({"user_id": uid, "is_default": True}) + if not confirmed: + logger.warning("default board missing after ensure", extra={"user_id": uid}) + return None + return str(confirmed.get("_id") or "") or None + + +def list_boards(db: Any, user_id: int) -> List[Dict[str, Any]]: + """לוחות המשתמש לפי סדר תצוגה. ברירת המחדל תמיד ראשונה.""" + coll = _boards_coll(db) + if coll is None: + return [] + cursor = coll.find({"user_id": int(user_id)}) + docs = [d for d in (list(cursor) if cursor is not None else []) if isinstance(d, dict)] + docs.sort(key=lambda d: (0 if d.get("is_default") else 1, int(d.get("order") or 0), str(d.get("name") or ""))) + return docs + + +def reattach_orphan_notes(db: Any, user_id: int, known_board_ids: List[str], default_board_id: str) -> int: + """מחזיר פתקים שמצביעים ללוח שאינו קיים אל לוח ברירת המחדל. + + מחזירה כמה פתקים אכן הועברו — נספר ב**קריאה חוזרת** ולא מ- + ``modified_count``, שמדווח בחסר כשמסמך כבר היה בערך היעד. + + זהו ריפוי-עצמי מהסוג הנכון: הוא מתקן את הקישור של פתק קיים. ההפך + מ-``collections_api``, שבמצב מקביל יוצר אוסף חדש וריק ומשאיר את הפריטים + הישנים מאחור. + """ + coll = _notes_coll(db) + if coll is None or not default_board_id: + return 0 + uid = int(user_id) + # היעד עצמו חייב להיחשב תקין, אחרת פתק שכבר יושב על לוח ברירת + # המחדל ייספר כיתום — וה-update_many "יעביר" אותו לאותו מקום, + # כך שהספירה החוזרת לעולם לא תרד לאפס והדיווח יהיה שגוי. + valid = [str(b) for b in (known_board_ids or []) if str(b)] + if str(default_board_id) not in valid: + valid.append(str(default_board_id)) + orphan_query = { + "user_id": uid, + "board_id": {"$nin": valid, "$exists": True, "$ne": None}, + } + try: + before = coll.count_documents(orphan_query) + except Exception: + return 0 + if not before: + return 0 + try: + coll.update_many( + orphan_query, + {"$set": {"board_id": str(default_board_id), "updated_at": datetime.now(timezone.utc)}}, + ) + except Exception as exc: + logger.warning("orphan notes reattach failed: %s", exc) + try: + after = coll.count_documents(orphan_query) + except Exception: + return 0 + return max(0, before - after) diff --git a/scripts/migrate_note_boards.py b/scripts/migrate_note_boards.py new file mode 100644 index 000000000..39e5c3e32 --- /dev/null +++ b/scripts/migrate_note_boards.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""מיגרציה ובדיקת שפיות ללוחות פתקים. + +הרצה:: + + python scripts/migrate_note_boards.py # דוח בלבד, לא כותב + python scripts/migrate_note_boards.py --apply # יוצר לוחות ברירת מחדל + +הסקריפט בטוח להרצה חוזרת. הוא עושה שני דברים: + +1. **דוח הפרות של האילוץ "בדיוק אחד".** אין בריפו תשתית migrations שתחזיק + ``$jsonSchema`` validator ברמת מונגו, והוספת אחד בלעדיה תישבר בפריסה + הבאה. ההרצה החוזרת של הסקריפט הזה היא הבדיקה: כמה פתקים נושאים גם + ``file_id`` וגם ``board_id``, וכמה לא נושאים אף אחד. + +2. **יצירת לוח ברירת מחדל** למשתמשים קיימים, ב-``--apply`` בלבד. בתבנית + ``scripts/migrate_workspace_collections.py``. + +ברירת המחדל היא **דוח בלבד**: סקריפט שכותב ברגע שמריצים אותו הוא סקריפט +שאי אפשר להריץ כדי פשוט לראות מה המצב. +""" + +from __future__ import annotations + +import argparse +import logging +import sys +from typing import Any, Optional + +log = logging.getLogger(__name__) + + +def _safe_int(value: Any) -> Optional[int]: + try: + return int(value) + except Exception: + return None + + +def audit_note_targets(db: Any) -> dict: + """סופר הפרות של האילוץ. לא כותב דבר.""" + notes = db.sticky_notes + empty = {"$in": [None, ""]} + + both = notes.count_documents({ + "file_id": {"$nin": [None, ""]}, + "board_id": {"$nin": [None, ""]}, + }) + neither = notes.count_documents({ + "$and": [ + {"$or": [{"file_id": {"$exists": False}}, {"file_id": empty}]}, + {"$or": [{"board_id": {"$exists": False}}, {"board_id": empty}]}, + ] + }) + total = notes.count_documents({}) + board_notes = notes.count_documents({"board_id": {"$nin": [None, ""]}}) + + return { + "total": total, + "board_notes": board_notes, + "violations_both": both, + "violations_neither": neither, + } + + +def create_missing_default_boards(db: Any, *, apply: bool) -> dict: + """לוח ברירת מחדל לכל משתמש שחסר לו.""" + from note_boards import ensure_default_board + + created = 0 + checked = 0 + failed = 0 + + for raw_user in db.users.find({}, {"user_id": 1}): + user_id = _safe_int((raw_user or {}).get("user_id")) + if user_id is None: + continue + checked += 1 + existing = db.note_boards.find_one({"user_id": user_id, "is_default": True}) + if existing: + continue + if not apply: + created += 1 + continue + if ensure_default_board(db, user_id): + created += 1 + else: + failed += 1 + log.warning("failed to create default board for user %s", user_id) + + return {"checked": checked, "created": created, "failed": failed} + + +def main(argv: Optional[list] = None) -> int: + parser = argparse.ArgumentParser(description="מיגרציה ובדיקה ללוחות פתקים") + parser.add_argument("--apply", action="store_true", help="לכתוב בפועל, ולא רק לדווח") + args = parser.parse_args(argv) + + logging.basicConfig(level=logging.INFO, format="%(message)s") + + try: + from webapp.app import get_db + except Exception as exc: # pragma: no cover + raise RuntimeError("לא ניתן לייבא את get_db מ-webapp.app") from exc + + db = get_db() + + audit = audit_note_targets(db) + print("— בדיקת האילוץ 'בדיוק אחד' —") + print(f" סה\"כ פתקים: {audit['total']}") + print(f" מהם פתקי לוח: {audit['board_notes']}") + print(f" הפרות (שני היעדים): {audit['violations_both']}") + print(f" הפרות (אף יעד): {audit['violations_neither']}") + + boards = create_missing_default_boards(db, apply=args.apply) + mode = "נוצרו" if args.apply else "חסרים (לא נכתב — הרץ עם --apply)" + print("\n— לוחות ברירת מחדל —") + print(f" משתמשים שנבדקו: {boards['checked']}") + print(f" {mode}: {boards['created']}") + if boards["failed"]: + print(f" נכשלו: {boards['failed']}") + + violations = audit["violations_both"] + audit["violations_neither"] + if violations: + print(f"\n⚠️ {violations} פתקים מפרים את האילוץ. זו אינה תקלה שהסקריפט מתקן —") + print(" היא מצביעה על מסלול כתיבה שעוקף את build_note_target.") + return 1 + if boards["failed"]: + return 1 + return 0 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/services/personal_backup_service.py b/services/personal_backup_service.py index 3f52348ca..0a2a4161c 100644 --- a/services/personal_backup_service.py +++ b/services/personal_backup_service.py @@ -427,6 +427,20 @@ def _export_sticky_notes(self, user_id: int) -> List[Dict]: except Exception: pass notes = list(cur) if not isinstance(cur, list) else list(cur)[: int(STICKY_NOTES_EXPORT_LIMIT)] + # פתק לוח מיוצא עם **שם** הלוח ולא רק עם המזהה: ה-``_id`` לא + # יהיה תקף אחרי שחזור לסביבה אחרת, והשם הוא מה שמאפשר לשייך + # מחדש. בלי זה פתקי לוח היו נופלים בשחזור על ה-``continue`` + # שדורש ``file_name``. + try: + board_names = {} + for board in raw_db.note_boards.find({"user_id": int(user_id)}, {"name": 1}): + board_names[str(board.get("_id"))] = str(board.get("name") or "") + for note in notes: + bid = str(note.get("board_id") or "") + if bid and bid in board_names: + note["board_name"] = board_names[bid] + except Exception: + pass # המרת datetime ל-string for note in notes: for key in ("created_at", "updated_at", "remind_at"): @@ -1107,6 +1121,78 @@ def _restore_bookmarks(self, user_id: int, bookmarks: List[Dict], errors: List[s return count + def _restore_board_note(self, raw_db, user_id: int, note: Dict) -> bool: + """משחזר פתק לוח. מחזיר ``True`` אם נכתב. + + השיוך הוא לפי **שם** הלוח ולא לפי מזהה, כי ה-``_id`` שבגיבוי לא + יהיה תקף בסביבה אחרת. אין התאמה ⇒ לוח ברירת המחדל, שתמיד קיים — + עדיף פתק שנחת במקום הלא-מדויק מאשר פתק שנעלם בשקט. + """ + from note_boards import ensure_default_board + from sticky_notes_target import build_note_target, normalize_mode + + board_name = str(note.get("board_name") or "").strip() + target_board_id = None + if board_name: + try: + match = raw_db.note_boards.find_one({"user_id": int(user_id), "name": board_name}) + if match: + target_board_id = str(match.get("_id")) + except Exception: + target_board_id = None + if not target_board_id: + target_board_id = ensure_default_board(raw_db, int(user_id)) + if not target_board_id: + return False + + content = note.get("content", "") + existing = raw_db.sticky_notes.find_one({ + "user_id": int(user_id), + "board_id": str(target_board_id), + "content": content, + }) + if existing: + return False + + # המכסות חלות גם על שחזור. בלי זה גיבוי גדול היה עוקף תקרה + # שנאכפת בכל מסלול אחר — כלומר "אכיפה" עם דלת אחורית. + from sticky_notes_target import NoteQuotaError, check_note_quota + from webapp.sticky_notes_api import MAX_NOTES_PER_BOARD, MAX_NOTES_PER_USER + + def _count(query): + try: + return int(raw_db.sticky_notes.count_documents(query)) + except Exception: + return None + + try: + check_note_quota( + _count({"user_id": int(user_id), "board_id": str(target_board_id)}), + MAX_NOTES_PER_BOARD, + ) + check_note_quota(_count({"user_id": int(user_id)}), MAX_NOTES_PER_USER) + except NoteQuotaError as exc: + raise ValueError(f"quota:{exc}") from exc + + doc = { + "user_id": int(user_id), + "content": content, + "color": note.get("color", "#FFFFCC"), + "position_x": note.get("position_x", 100), + "position_y": note.get("position_y", 100), + "width": note.get("width", 250), + "height": note.get("height", 200), + "is_minimized": bool(note.get("is_minimized", False)), + "mode": normalize_mode(note.get("mode")), + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + } + # שדות היעד דרך הבנאי, ולא ביד — כך המסמך אינו יכול לצאת עם שני + # משטחים או בלי אף אחד. + doc.update(build_note_target(board_id=target_board_id)) + raw_db.sticky_notes.insert_one(doc) + return True + def _restore_sticky_notes(self, user_id: int, notes: List[Dict], errors: List[str]) -> int: """משחזר פתקיות (best-effort). @@ -1125,6 +1211,22 @@ def _restore_sticky_notes(self, user_id: int, notes: List[Dict], errors: List[st file_name = note.get("file_name", "") new_file_id = None + # פתק לוח: משוחזר לפי **שם** הלוח, ובהיעדר התאמה — ללוח + # ברירת המחדל, שתמיד קיים. עד היום הוא היה נופל כאן על + # ה-``continue`` שדורש file_name — כלומר פתקי לוח לא + # שרדו גיבוי-שחזור, בשקט. + if note.get("board_id") or note.get("board_name"): + try: + restored = self._restore_board_note(raw_db, user_id, note) + except ValueError as quota_exc: + # חריגה ממכסה אינה שקטה: היא נכנסת לסיכום + # השחזור, כדי שהמשתמש יידע שפתקים לא שוחזרו. + errors.append(f"דילגתי על פתק לוח: חריגה ממכסה ({quota_exc})") + continue + if restored: + count += 1 + continue + if not file_name: continue # לא ניתן לשייך את הפתקית בלי file_name diff --git a/sticky_notes_target.py b/sticky_notes_target.py new file mode 100644 index 000000000..e5659c683 --- /dev/null +++ b/sticky_notes_target.py @@ -0,0 +1,168 @@ +""" +Sticky Notes Target — לאיזה משטח הפתק שייך, ומי אוכף שהוא שייך לאחד בדיוק. + +פתק דביק שייך **או** לקובץ **או** ללוח — לעולם לא לשניהם ולעולם לא לאף אחד. +המודול הזה הוא המקום היחיד שיודע את הכלל הזה, והוא טהור בכוונה: בלי Flask, +בלי pymongo, בלי ``get_db``. כך גם ``webapp``, גם ``mcp_server`` וגם +``services`` יכולים לייבא אותו — בדיוק כמו ``sticky_notes_scope`` שכבר מיובא +משלושה מודולים. + +למה מודול ולא בדיקה בראוט: שישה מודולים כותבים היום ישירות ל-``sticky_notes`` +(``webapp/sticky_notes_api``, ``mcp_server/backend``, ``sticky_notes_scope``, +``services/personal_backup_service``, ``webapp/app``, ``webapp/push_api``). +"אילוץ בשכבת הוולידציה" הוא הצהרה ריקה כל עוד השכבה לא קיימת, ולכן +:func:`build_note_target` מקבלת **כוונה** ומחזירה את שדות היעד — ומריצה +ולידציה לפני ההחזרה. מי שקורא לה אינו יכול לייצר מסמך לא חוקי. + +בעדכון אין צורך באכיפה נוספת: ``update_note`` ו-``batch`` בונים את השדות +מ-allowlist מפורש, ו-``file_id``/``board_id`` פשוט אינם ברשימה. זו הצורה +החזקה של האילוץ — לא בדיקה שאפשר לשכוח, אלא שדה שלא קיים בקלט. +""" +from __future__ import annotations + +from typing import Any, Dict, List, Mapping, Optional + + +class NoteTargetError(ValueError): + """יעד הפתק אינו חוקי — שני משטחים, או אף אחד.""" + + +class NoteQuotaError(ValueError): + """חריגה מתקרת הפתקים, או ספירה שלא הצליחה.""" + + +#: מצבי המיקום של פתק. ``surface`` = מעוגן למשטח שמתחתיו (הלוח), ``screen`` = +#: צמוד לחלון גם כשהמשטח זז. ``anchored`` שמור לפתקי קבצים, שבהם המיקום נגזר +#: משורת מקור — היום הם עדיין מקודדים את המצב בתוך ``anchor_id`` בעזרת +#: sentinels, ולכן הערך הזה אינו בשימוש עדיין. הוא ברשימה כדי שכשהם יעברו +#: לשדה אמיתי לא יידרש שינוי שם. +NOTE_MODES = ("surface", "screen", "anchored") + +#: מה שפתק **לוח** רשאי להיות. ``anchored`` דורש שורות מקור, ובלוח אין +#: כאלה — ערך כזה שיגיע מה-API היה מייצר פתק שמחשב את מיקומו מול עוגן +#: שאינו קיים. +BOARD_NOTE_MODES = ("surface", "screen") +DEFAULT_BOARD_MODE = "surface" + + +def _clean(value: Any) -> str: + """מזהה כמחרוזת מנורמלת. ``None``, ``''`` ומחרוזת רווחים — כולם ריקים.""" + return str(value or "").strip() + + +def validate_note_target(doc: Mapping[str, Any]) -> None: + """מוודא שבמסמך מלא בדיוק אחד מבין ``file_id`` ו-``board_id``. + + :raises NoteTargetError: אם שניהם מלאים או ששניהם ריקים. + """ + has_file = bool(_clean(doc.get("file_id"))) + has_board = bool(_clean(doc.get("board_id"))) + if has_file and has_board: + raise NoteTargetError("note_target_ambiguous") + if not has_file and not has_board: + raise NoteTargetError("note_target_missing") + + +def build_note_target( + *, + file_id: Any = None, + board_id: Any = None, + scope_id: Optional[str] = None, + file_name: Optional[str] = None, +) -> Dict[str, Any]: + """שדות היעד למסמך פתק חדש — אחרי ולידציה, לא לפניה. + + ``scope_id`` ו-``file_name`` הם מושגים של קובץ בלבד. העברתם יחד עם + ``board_id`` היא באג של הקורא ולא קלט שיש להשלים בשקט, ולכן היא נדחית. + """ + fid = _clean(file_id) + bid = _clean(board_id) + + if bid and (scope_id or file_name): + raise NoteTargetError("board_note_cannot_carry_file_metadata") + + target: Dict[str, Any] = {} + if fid: + target["file_id"] = fid + if scope_id: + target["scope_id"] = scope_id + if file_name: + target["file_name"] = file_name + if bid: + target["board_id"] = bid + + validate_note_target(target) + return target + + +def file_notes_filter( + user_id: int, + scope_id: Optional[str], + related_ids: Optional[List[str]], + file_id: Any = None, +) -> Dict[str, Any]: + """שאילתת פתקים של קובץ — משקפת את הלוגיקה שהייתה בראוט. + + פתק לוח אינו יכול להיתפס כאן: אין לו ``scope_id`` ואין לו ``file_id``, + ושלושת הענפים דורשים אחד מהם. + """ + query: Dict[str, Any] = {"user_id": int(user_id)} + criteria: List[Dict[str, Any]] = [] + if scope_id: + criteria.append({"scope_id": scope_id}) + if related_ids: + criteria.append({"file_id": {"$in": related_ids}}) + if criteria: + query["$or"] = criteria + else: + query["file_id"] = _clean(file_id) + return query + + +def board_notes_filter(user_id: int, board_id: Any) -> Dict[str, Any]: + """שאילתת פתקים של לוח — ישירה, בלי ``$or`` ובלי מעבר דרך ``code_snippets``. + + ללוח יש ``_id`` יציב, ולכן אין צורך ב-``scope_id``: המנגנון ההוא הוא hash + של **שם**, וכל קיומו של ``sync_sticky_notes_on_rename`` הוא כדי לרוץ אחרי + שינויי שם ולתקן. לוח ניתן לשינוי שם בלי שהמזהה שלו יזוז. + """ + return {"user_id": int(user_id), "board_id": _clean(board_id)} + + +def normalize_mode(value: Any, default: str = DEFAULT_BOARD_MODE) -> str: + """מצב מיקום חוקי, או ברירת המחדל. לא זורק — ולכן מתאים לקלט משתמש.""" + candidate = _clean(value).lower() + return candidate if candidate in NOTE_MODES else default + + +def is_valid_mode(value: Any) -> bool: + """``True`` רק לערך שנמצא ב-:data:`NOTE_MODES`. לשימוש בוולידציה שדוחה 400.""" + return _clean(value).lower() in NOTE_MODES + + +def is_valid_board_mode(value: Any) -> bool: + """``True`` רק למצב שפתק לוח רשאי להיות בו. + + נפרד מ-:func:`is_valid_mode` בכוונה: ``anchored`` חוקי לפתק קובץ אבל + לא לפתק לוח. אימות עם הפונקציה הכללית היה מקבל ``anchored`` מה-API + ומייצר פתק שמחשב ``top`` מול עוגן שאינו קיים — כלומר פתק שנעלם. + """ + return _clean(value).lower() in BOARD_NOTE_MODES + + +def check_note_quota(existing: Optional[int], cap: int, *, is_admin: bool = False) -> None: + """אכיפת תקרת פתקים. אדמין פטור. + + ``existing=None`` פירושו שהספירה נכשלה — ואז **דוחים**. זו הנקודה שבה + ההתנהגות כאן נבדלת במכוון מ-``mcp_server/backend``, שמתייחס לכשל ספירה + כאילו אין פתקים ומעביר את היצירה. תקרה שנפתחת לרווחה בדיוק כשהמסד + מתקשה היא לא תקרה. + + :raises NoteQuotaError: בחריגה, או כשהספירה אינה ידועה. + """ + if is_admin: + return + if existing is None: + raise NoteQuotaError("note_quota_unknown") + if int(existing) >= int(cap): + raise NoteQuotaError("note_quota_exceeded") diff --git a/sticky_notes_tasks.py b/sticky_notes_tasks.py new file mode 100644 index 000000000..80e155c18 --- /dev/null +++ b/sticky_notes_tasks.py @@ -0,0 +1,108 @@ +""" +Sticky Notes Tasks — צ'קבוקסים בתוך תוכן פתק. + +שורה בצורת ``- [ ] טקסט`` היא משימה. לחיצה על התיבה היא **כתיבה למסד**, לא +שינוי תצוגה, ולכן הזיהוי והכתיבה חיים כאן — בשתי פונקציות טהורות שאפשר +לבדוק בלי Flask ובלי מסד. + +**שתי החלטות מפורשות, לא מקריות:** + +1. **התאמה לפי מספר סידורי ולא לפי טקסט.** שתי שורות ``- [ ] לבדוק`` זהות + באותו פתק חייבות להיות ניתנות לסימון בנפרד. ``str.replace`` היה מסמן את + הראשונה בשני המקרים, ולחיצה על השנייה הייתה "קופצת" לראשונה. + +2. **לא מפרשים בלוקי קוד.** שורת ``- [ ]`` בתוך גדר ``` נספרת כמו כל שורה + אחרת. האלטרנטיבה היא פרסר מארקדאון מלא, שהוא מחוץ להיקף — וכבר למדנו + בריפו הזה מה עולה פרסור מארקדאון שנכתב ביד. ההחלטה מכוסה בבדיקה, כדי + שהיא תהיה מתועדת ולא הפתעה. + +הערה על אורך: ההתמרה שומרת אורך (``- [ ]`` ו-``- [x]`` זהים באורכם), ולכן +תקרת 5,000 התווים אינה יכולה להישבר כאן. יש על כך בדיקה. +""" +from __future__ import annotations + +import re +from typing import List, Optional, Tuple + +#: שורת משימה: הזחה, תו רשימה, תיבה, והמשך אופציונלי. +#: הקבוצות: 1=הזחה+תו רשימה+פתיחה, 2=תו הסימון, 3=סגירה+המשך. +_TASK_RE = re.compile(r"^([ \t]*[-*][ \t]\[)([ xX])(\].*)$") + +#: תווי סימון אפשריים. +_CHECKED = "x" +_UNCHECKED = " " + + +def _split_lines(content: str) -> Tuple[List[str], str]: + """פיצול לשורות תוך שמירת שובר השורה. + + ``\\r\\n`` **אינו** דורש טיפול מיוחד, וזה נבדק ולא הונח: פיצול על + ``\\n`` משאיר ``\\r`` בסוף השורה, ה-regex עדיין תואם (``.`` תואם + ``\\r``, ו-``$`` תופס לפניו), והחיבור מחדש מרכיב את ``\\r\\n`` בחזרה. + ענף מיוחד לזה היה קוד שאי אפשר להפיל — מוטציה שביטלה אותו לא הפילה + אף בדיקה. + + ``\\r`` לבדו כן דורש טיפול: בלעדיו כל התוכן נקרא כשורה אחת, ושתי + משימות נספרות כאחת. + """ + if "\r" in content and "\n" not in content: + return content.split("\r"), "\r" + return content.split("\n"), "\n" + + +def count_tasks(content: str) -> int: + """כמה צ'קבוקסים יש בתוכן.""" + lines, _ = _split_lines(content or "") + return sum(1 for line in lines if _TASK_RE.match(line)) + + +def task_state_at_index(content: str, index: int) -> Optional[bool]: + """מצב המשימה במיקום הסידורי הנתון, או ``None`` אם אין כזו. + + זו הפונקציה שמשמשת ל**אימות אחרי הכתיבה**: קוראים את המסמך מחדש + מהמסד ושואלים אותה אם התו באמת השתנה. + """ + if index is None or int(index) < 0: + return None + lines, _ = _split_lines(content or "") + seen = 0 + for line in lines: + m = _TASK_RE.match(line) + if not m: + continue + if seen == int(index): + return m.group(2).lower() == _CHECKED + seen += 1 + return None + + +def toggle_task_at_index(content: str, index: int, checked: bool) -> Tuple[str, bool]: + """מסמן או מבטל את המשימה במיקום הסידורי הנתון. + + מחזירה ``(תוכן_חדש, השתנה)``. ``השתנה=False`` פירושו שהמשימה כבר + במצב המבוקש — ואז אין טעם לכתוב, וזה גם מה שהופך את הפעולה + לאידמפוטנטית. + + מוחלף **רק התו בתוך הסוגריים**. ההזחה, תו הרשימה (``-`` או ``*``) + והמשך השורה נשמרים מילה במילה. + """ + if index is None or int(index) < 0: + return content, False + + lines, sep = _split_lines(content or "") + want = _CHECKED if checked else _UNCHECKED + seen = 0 + for i, line in enumerate(lines): + m = _TASK_RE.match(line) + if not m: + continue + if seen == int(index): + # השוואת **מצב** ולא של תווים: ``X`` ו-``x`` הם אותו דבר, ולכן + # השוואת תווים ישירה הייתה מייצרת כתיבה מיותרת על ``- [X]``. + is_checked = m.group(2).lower() == _CHECKED + if is_checked == bool(checked): + return content, False + lines[i] = f"{m.group(1)}{want}{m.group(3)}" + return sep.join(lines), True + seen += 1 + return content, False diff --git a/tests/sticky-notes-target.test.js b/tests/sticky-notes-target.test.js new file mode 100644 index 000000000..6d25dd41f --- /dev/null +++ b/tests/sticky-notes-target.test.js @@ -0,0 +1,334 @@ +/** + * טסטים ל-webapp/static/js/sticky-notes.js — הפרמטריזציה ליעד (קובץ מול לוח). + * + * הרצה: node tests/sticky-notes-target.test.js + * (אין ברפו runner ל-JS, ולכן הקובץ עצמאי ומחזיר קוד יציאה 1 בכישלון — + * אותה תבנית כמו tests/md-anchors.test.js.) + * + * מה נבדק כאן: **רק** בחירת היעד ומצב המיקום. גרירה, שינוי גודל ותור + * השמירה אינם נבדקים — הם דורשים DOM אמיתי, והם גם לא השתנו. + * + * הדבר החשוב ביותר שהקובץ הזה מגן עליו: שהצורה ההיסטורית + * ``new StickyNotesManager('')`` ממשיכה להתנהג בדיוק כמו קודם. + * md_preview.html קורא כך, ורגרסיה שם שוברת פיצ'ר קיים בפרודקשן. + */ +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MODULE_PATH = path.join(__dirname, '..', 'webapp', 'static', 'js', 'sticky-notes.js'); + +/** DOM מינימלי — רק מה ש-``_init`` נוגע בו לפני שהוא נכשל בשקט. */ +function makeSandbox() { + const el = () => ({ + style: {}, dataset: {}, classList: { add() {}, remove() {}, contains: () => false }, + appendChild() {}, addEventListener() {}, querySelectorAll: () => [], + getBoundingClientRect: () => ({ left: 0, top: 0, width: 0, height: 0 }), + setAttribute() {}, getAttribute: () => null, + }); + const body = el(); + const mdContent = el(); + const sandbox = { + console, + document: { + body, + // מחזיר אלמנט אמיתי, אחרת הבדיקה על _anchorHost לא יכולה להיכשל + getElementById: (id) => (id === 'md-content' ? mdContent : null), + createElement: () => el(), + addEventListener() {}, + querySelectorAll: () => [], + }, + window: { + addEventListener() {}, + innerWidth: 1024, + innerHeight: 768, + matchMedia: () => ({ matches: false }), + location: { search: '', hash: '' }, + }, + localStorage: { getItem: () => null, setItem() {}, removeItem() {} }, + fetch: async () => ({ json: async () => ({ ok: true, notes: [] }) }), + setTimeout, clearTimeout, setInterval, clearInterval, + MutationObserver: undefined, ResizeObserver: undefined, + }; + sandbox.globalThis = sandbox; + vm.createContext(sandbox); + vm.runInContext(fs.readFileSync(MODULE_PATH, 'utf8'), sandbox); + return sandbox; +} + +const sandbox = makeSandbox(); +const StickyNotesManager = sandbox.window.StickyNotesManager; + +let passed = 0; +let failed = 0; + +function check(name, fn) { + try { + fn(); + passed += 1; + } catch (err) { + failed += 1; + console.error(`✗ ${name}\n ${err && err.message}`); + } +} + +function eq(actual, expected, what) { + if (actual !== expected) { + throw new Error(`${what || ''} — ציפיתי ל-${JSON.stringify(expected)}, קיבלתי ${JSON.stringify(actual)}`); + } +} + +// -- תאימות לאחור: הצורה ההיסטורית -- + +check('מחרוזת נקראת כמזהה קובץ', () => { + const m = new StickyNotesManager('abc123'); + eq(m.fileId, 'abc123', 'fileId'); + eq(m.boardId, null, 'boardId'); + eq(m._scopeUrl, '/api/sticky-notes/abc123', 'scopeUrl'); + eq(m.container, sandbox.document.body, 'container'); +}); + +check('מפתח הקאש של קובץ לא השתנה', () => { + const m = new StickyNotesManager('abc123'); + eq(m._cacheKey, 'sticky-notes:abc123'); +}); + +// -- לוח -- + +check('אובייקט עם board נקרא כלוח', () => { + const m = new StickyNotesManager({ board: 'b1' }); + eq(m.boardId, 'b1', 'boardId'); + eq(m.fileId, null, 'fileId'); + eq(m._scopeUrl, '/api/sticky-notes/board/b1', 'scopeUrl'); +}); + +check('מפתח הקאש של לוח נפרד מזה של קובץ', () => { + // בלי ההפרדה, לוח וקובץ עם אותה מחרוזת מזהה היו מרנדרים זה את הפתקים של זה + const board = new StickyNotesManager({ board: 'same' }); + const file = new StickyNotesManager('same'); + eq(board._cacheKey, 'sticky-notes:board:same'); + eq(file._cacheKey, 'sticky-notes:same'); + if (board._cacheKey === file._cacheKey) throw new Error('מפתחות הקאש התנגשו'); +}); + +check('פתק קובץ מקבל את #md-content כמקור עוגנים', () => { + // רגרסיה אמיתית: החלפה גורפת של getElementById פגעה גם בשורת + // הקונסטרקטור, ו-_anchorHost יצא undefined — כלומר כל מסלול העיגון + // בפתקי קובץ מנוטרל, בשקט. ה-sandbox כאן מחזיר אלמנט אמיתי ל-md-content. + const m = new StickyNotesManager('f1'); + if (m._anchorHost === undefined) throw new Error('_anchorHost הוא undefined'); + eq(m._hasAnchorHost, true, '_hasAnchorHost'); +}); + +check('לוח מקבל anchorHost ריק במפורש', () => { + const m = new StickyNotesManager({ board: 'b1', anchorHost: null }); + eq(m._hasAnchorHost, false); +}); + +check('הקונטיינר של לוח אינו ה-body', () => { + const surface = { appendChild() {}, querySelectorAll: () => [] }; + const m = new StickyNotesManager({ board: 'b1', container: surface }); + eq(m.container, surface); +}); + +check('יעד חסר נכשל מיד ולא בשקט', () => { + let threw = false; + try { new StickyNotesManager({}); } catch (_) { threw = true; } + if (!threw) throw new Error('ציפיתי לחריגה כשאין file ואין board'); +}); + +// -- _resolveMode -- + +const fileMgr = new StickyNotesManager('f1'); +const boardMgr = new StickyNotesManager({ board: 'b1' }); + +check('סנטינל הנעיצה ממופה ל-surface', () => { + eq(fileMgr._resolveMode({ anchor_id: '__pinned__' }), 'surface'); +}); + +check('סנטינל הציפה ממופה ל-screen', () => { + eq(fileMgr._resolveMode({ anchor_id: '__floating__' }), 'screen'); +}); + +check('פתק בלי שום סימון הוא screen', () => { + eq(fileMgr._resolveMode({}), 'screen'); +}); + +check('שורת מקור ממפה ל-anchored — רק בקובץ', () => { + eq(fileMgr._resolveMode({ line_start: 12 }), 'anchored', 'קובץ'); + // בלוח אין שורות מקור, ולכן אין מצב anchored בכלל. בלי זה, כל מחרוזת + // שתזלוג ל-anchor_id הייתה מעבירה את הפתק למצב שבו ה-top מחושב מול + // עוגן שאינו קיים — פתק שנעלם. + eq(boardMgr._resolveMode({ line_start: 12 }), 'screen', 'לוח'); +}); + +check('עוגן טקסטואלי ממפה ל-anchored רק בקובץ', () => { + eq(fileMgr._resolveMode({ anchor_id: 'some-heading' }), 'anchored', 'קובץ'); + eq(boardMgr._resolveMode({ anchor_id: 'some-heading' }), 'screen', 'לוח'); +}); + +check('שדה mode מנצח את הסנטינלים', () => { + eq(boardMgr._resolveMode({ mode: 'screen', anchor_id: '__pinned__' }), 'screen'); + eq(boardMgr._resolveMode({ mode: 'surface' }), 'surface'); +}); + +check('mode לא חוקי נופל חזרה לגזירה מהסנטינלים', () => { + eq(boardMgr._resolveMode({ mode: 'diagonal', anchor_id: '__pinned__' }), 'surface'); +}); + +check('anchored בלי מקור שורות יורד ל-surface', () => { + eq(boardMgr._resolveMode({ mode: 'anchored' }), 'surface'); +}); + + +// -- צ'קבוקסים: תצוגת התוכן -- +// +// הזרימה המלאה (fetch, אימות, חזרה אחורה) נבדקת בפייתון ב- +// tests/test_sticky_notes_tasks.py, כי שם היא באמת נכתבת למסד. כאן נבדק +// מה שחי בדפדפן: מה ``_syncTaskView`` מרנדר, ומה הוא **לא** נוגע בו. +// +// למה DOM מדומה ולא בדיקת פונקציית עזר טהורה: הגרסה הקודמת בדקה +// ``_parseTasks``, שהחזירה רק את שורות המשימה. היא עברה בהצלחה בזמן +// שהתצוגה הסתירה 400 שורות טקסט — כי היא בדקה את המסננת, לא את מה +// שהמשתמש רואה. הבדיקה חייבת לגעת במה שנשבר. + +/** DOM מינימלי — בדיוק מה ש-``_syncTaskView`` ו-``_enterEditAt`` נוגעים בו. */ +class FakeEl { + constructor(tag) { + this.tagName = tag; this.children = []; this.dataset = {}; this.style = {}; + this.hidden = false; this._text = ''; this._attrs = {}; this._classes = new Set(); + this.classList = { + add: (c) => this._classes.add(c), + remove: (c) => this._classes.delete(c), + contains: (c) => this._classes.has(c), + toggle: (c, on) => { if (on) this._classes.add(c); else this._classes.delete(c); }, + }; + } + set className(v) { this._classes = new Set(String(v || '').split(/\s+/).filter(Boolean)); } + get className() { return [...this._classes].join(' '); } + appendChild(c) { this.children.push(c); return c; } + set textContent(v) { this.children = []; this._text = String(v); } + get textContent() { return this.children.length ? this.children.map((c) => c.textContent).join('') : this._text; } + setAttribute(k, v) { this._attrs[k] = String(v); } + getAttribute(k) { return k in this._attrs ? this._attrs[k] : null; } + addEventListener() {} + focus() { FakeEl.focused = this; } + _matches(sel) { return sel.startsWith('.') ? this._classes.has(sel.slice(1)) : this.tagName === sel; } + querySelector(sel) { + for (const c of this.children) { + if (c._matches(sel)) return c; + const deep = c.querySelector(sel); + if (deep) return deep; + } + return null; + } + querySelectorAll(sel) { + const out = []; + for (const c of this.children) { if (c._matches(sel)) out.push(c); out.push(...c.querySelectorAll(sel)); } + return out; + } + getBoundingClientRect() { return { left: 0, top: 0, width: 0, height: 0 }; } +} + +sandbox.document.createElement = (tag) => new FakeEl(tag); + +/** פתק מדומה עם טקסטריה ותצוגה, כמו ש-``_renderNote`` בונה. */ +function makeNote(content) { + const el = new FakeEl('div'); + el.className = 'sticky-note'; + const ta = new FakeEl('textarea'); + ta.className = 'sticky-note-content'; + ta.value = content; + const view = new FakeEl('div'); + view.className = 'sticky-note-tasks'; + el.appendChild(ta); + el.appendChild(view); + return { el, ta, view }; +} + +const CONTENT = 'כותרת\n- [ ] אחת\nשורת טקסט\n- פריט רגיל\n- [x] שתיים'; + +check('תצוגה: **כל** השורות מרונדרות, לא רק המשימות', () => { + // זה הבאג שמחק 400 שורות מהמסך. הגרסה הקודמת הייתה מרנדרת 2. + const { el, view } = makeNote(CONTENT); + fileMgr._syncTaskView(el); + eq(view.children.length, 5, 'מספר השורות'); +}); + +check('תצוגה: טקסט שאינו משימה נשאר גלוי', () => { + const { el, view } = makeNote(CONTENT); + fileMgr._syncTaskView(el); + eq(view.textContent.includes('שורת טקסט'), true, 'טקסט חופשי'); + eq(view.textContent.includes('- פריט רגיל'), true, 'פריט רשימה שאינו משימה'); +}); + +check('תצוגה: הסידור נספר על שורות משימה בלבד', () => { + // הסידור הוא מה שנשלח לשרת. ספירה על כל השורות הייתה מסמנת שורה אחרת. + const { el, view } = makeNote(CONTENT); + fileMgr._syncTaskView(el); + const boxes = view.querySelectorAll('.sticky-task-box'); + eq(boxes.length, 2, 'מספר תיבות'); + eq(boxes[0].dataset.taskIndex, '0'); + eq(boxes[1].dataset.taskIndex, '1'); + eq(boxes[1].checked, true, '[x] מסומנת'); +}); + +check('תצוגה: X גדולה נחשבת מסומנת', () => { + const { el, view } = makeNote('- [X] בוצע'); + fileMgr._syncTaskView(el); + eq(view.querySelectorAll('.sticky-task-box')[0].checked, true); +}); + +check('תצוגה: תוכן בלי משימות משאיר את הטקסטריה גלויה', () => { + // זה מה ששומר על פתק רגיל בדיוק כפי שהיה + const { el, ta, view } = makeNote('סתם טקסט'); + fileMgr._syncTaskView(el); + eq(view.hidden, true, 'התצוגה מוסתרת'); + eq(ta.hidden, false, 'הטקסטריה גלויה'); +}); + +check('רינדור הוא חד-כיווני: התוכן לא נכתב מהתצוגה', () => { + // **החוק שמעל כל השאר.** ``content → HTML``, לעולם לא חזרה. היחידים + // שרשאים לכתוב ל-content הם הטקסטריה וראוט ``/task``. + const { el, ta } = makeNote(CONTENT); + const before = ta.value; + const saves = []; + const realQueue = fileMgr._queueSave; + fileMgr._queueSave = (...a) => { saves.push(a); }; + try { + fileMgr._syncTaskView(el); + fileMgr._syncTaskView(el, { editing: true }); + fileMgr._syncTaskView(el); + } finally { + fileMgr._queueSave = realQueue; + } + eq(ta.value, before, 'התוכן לא השתנה'); + eq(saves.length, 0, 'לא נשלחה שום שמירה'); +}); + +check('חזרה לעריכה: הסמן נוחת בתחילת השורה שנלחצה', () => { + // בלי זה התצוגה היא דלת חד-כיוונית — אי אפשר להוסיף או למחוק מלל + const { el, ta, view } = makeNote(CONTENT); + fileMgr._syncTaskView(el); + const row = view.children[2]; // 'שורת טקסט' + fileMgr._enterEditAt(el, parseInt(row.dataset.charOffset, 10)); + eq(ta.hidden, false, 'הטקסטריה חזרה'); + eq(FakeEl.focused, ta, 'הפוקוס עבר לטקסטריה'); + eq(CONTENT.slice(ta.selectionStart, ta.selectionStart + 4), 'שורת', 'מיקום הסמן'); +}); + +check('אורך: מעל התקרה מוצג חיווי, מתחתיה לא', () => { + // השרת חותך ל-5,000 תווים בשקט. אם זה לא נראה כאן — זו מחיקת מידע. + const { el, ta } = makeNote('קצר'); + eq(fileMgr._checkContentLength(el, ta.value), false, 'תוכן קצר'); + eq(el.querySelector('.sticky-note-warn'), null, 'אין חיווי'); + eq(fileMgr._checkContentLength(el, 'א'.repeat(5001)), true, 'תוכן ארוך'); + const warn = el.querySelector('.sticky-note-warn'); + eq(warn !== null, true, 'יש חיווי'); + eq(warn.textContent.includes('5001'), true, 'החיווי מציין את האורך בפועל'); +}); + +console.log(`\n${passed} עברו, ${failed} נכשלו`); +process.exit(failed === 0 ? 0 : 1); diff --git a/tests/test_mcp_notes_handlers.py b/tests/test_mcp_notes_handlers.py index da89f701e..038175ff7 100644 --- a/tests/test_mcp_notes_handlers.py +++ b/tests/test_mcp_notes_handlers.py @@ -204,7 +204,20 @@ def test_notes_scope_filter_matches_webapp_shape(): 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_notes_scope_filter_without_clauses_matches_nothing(): + """בלי scope ובלי related — שאילתה שלא תופסת דבר, לא "הכול". + + הטענה כאן הייתה קודם ``== {"user_id": 42}``, כלומר היא **קיבעה באג**: + שאילתה כזו מחזירה את כל הפתקים של המשתמש במקום את הפתקים של הקובץ + שהתבקש. היום המסלול הזה לא נגיש, כי ``scope_id`` תמיד מחושב משם קובץ + לא-ריק — אבל עם פתקי לוח, פתק שאינו שייך לשום קובץ היה נשאב לתשובה של + ``list_notes`` על קובץ אקראי. + + ``{"_id": {"$in": []}}`` הוא הביטוי המפורש ל"אין לי לפי מה לחפש". + """ + assert _notes_scope_filter(42, None, []) == {"user_id": 42, "_id": {"$in": []}} def test_as_note_serialization(): diff --git a/tests/test_note_boards.py b/tests/test_note_boards.py new file mode 100644 index 000000000..bdae3bfec --- /dev/null +++ b/tests/test_note_boards.py @@ -0,0 +1,248 @@ +"""בדיקות ל-``note_boards`` — לוח ברירת המחדל וריפוי פתקים יתומים. + +התבנית היא זו שכבר בשימוש ב-``tests/test_sticky_notes_api.py``: stub של +אוסף בפייתון טהור, בלי pymongo ובלי ספריות mock, כדי שהבדיקה תוכל לתאר גם +מצבים שקשה לייצר מול מסד אמיתי — למשל כתיבה שמדווחת הצלחה ולא נקלטה. +""" + +from note_boards import ( + DEFAULT_BOARD_NAME, + ensure_default_board, + list_boards, + normalize_board_name, + reattach_orphan_notes, +) + + +class _StubColl: + """אוסף מינימלי. ``insert_fails`` מדמה דחייה של המסד (מרוץ/אינדקס ייחודי).""" + + def __init__(self, docs=None, *, insert_fails=False): + self.docs = list(docs or []) + self.insert_fails = insert_fails + self.inserted = [] + self.updates = [] + self._next_id = 100 + + def find_one(self, query): + for doc in self.docs: + if all(doc.get(k) == v for k, v in query.items()): + return doc + return None + + def find(self, query): + return [d for d in self.docs if all(d.get(k) == v for k, v in query.items())] + + def insert_one(self, doc): + self.inserted.append(dict(doc)) + if self.insert_fails: + raise RuntimeError("E11000 duplicate key") + doc = dict(doc) + doc["_id"] = self._next_id + self._next_id += 1 + self.docs.append(doc) + return type("Res", (), {"inserted_id": doc["_id"]})() + + def count_documents(self, query): + return len(self._matching(query)) + + def update_many(self, query, update): + self.updates.append((query, update)) + for doc in self._matching(query): + doc.update(update.get("$set", {})) + return type("Res", (), {"modified_count": 1})() + + def _matching(self, query): + out = [] + for doc in self.docs: + ok = True + for key, cond in query.items(): + value = doc.get(key) + if isinstance(cond, dict): + if "$nin" in cond and value in cond["$nin"]: + ok = False + if "$exists" in cond and (key in doc) != cond["$exists"]: + ok = False + if "$ne" in cond and value == cond["$ne"]: + ok = False + elif value != cond: + ok = False + if ok: + out.append(doc) + return out + + +class _StubDB: + def __init__(self, boards=None, notes=None): + self.note_boards = boards if boards is not None else _StubColl() + self.sticky_notes = notes if notes is not None else _StubColl() + + +# -- normalize_board_name -- + +def test_blank_name_falls_back_to_default(): + """לוח בלי שם אינו ניתן לזיהוי בממשק.""" + assert normalize_board_name("") == DEFAULT_BOARD_NAME + assert normalize_board_name(" ") == DEFAULT_BOARD_NAME + assert normalize_board_name(None) == DEFAULT_BOARD_NAME + + +def test_name_whitespace_is_collapsed_and_capped(): + assert normalize_board_name(" לוח שלי ") == "לוח שלי" + assert len(normalize_board_name("x" * 500)) == 120 + + +# -- ensure_default_board -- + +def test_creates_default_board_when_missing(): + db = _StubDB() + board_id = ensure_default_board(db, 7) + + assert board_id is not None + created = db.note_boards.docs[0] + assert created["user_id"] == 7 + assert created["name"] == DEFAULT_BOARD_NAME + assert created["is_default"] is True + + +def test_is_idempotent(): + """קריאה שנייה לא יוצרת לוח שני — היא נקראת בכל טעינת רשימה.""" + db = _StubDB() + first = ensure_default_board(db, 7) + second = ensure_default_board(db, 7) + + assert first == second + assert len(db.note_boards.docs) == 1 + + +def test_identifies_by_flag_not_by_name(): + """לוח ברירת מחדל ששמו שונה עדיין מזוהה. + + זו הסיבה המרכזית שלא חיקינו את "שולחן עבודה" באוספים, שמזוהה לפי + השוואת מחרוזת. האפיון מרשה במפורש לשנות את שם לוח ברירת המחדל, ולכן + זיהוי לפי שם היה נשבר בפעולה חוקית לגמרי. + """ + db = _StubDB(boards=_StubColl([ + {"_id": 55, "user_id": 7, "name": "הלוח האישי שלי", "is_default": True}, + ])) + + assert ensure_default_board(db, 7) == "55" + assert len(db.note_boards.docs) == 1 + + +def test_race_loser_returns_the_winners_board(): + """הכתיבה נדחתה, אבל הלוח קיים — ולכן מחזירים אותו ולא ``None``. + + זה בדיוק המרוץ שהאינדקס הייחודי-החלקי ``one_default_per_user`` מייצר: + שתי בקשות מקבילות מגלות שאין לוח, שתיהן מנסות ליצור, והמסד דוחה את + השנייה. הבדיקה נופלת אם מסיקים מהצלחת/כשלון הכתיבה במקום לקרוא שוב. + """ + boards = _StubColl(insert_fails=True) + db = _StubDB(boards=boards) + + # המנצח כותב בין הבדיקה לכתיבה שלנו + def _insert_then_win(doc, _orig=boards.insert_one): + boards.docs.append({"_id": 99, "user_id": 7, "name": DEFAULT_BOARD_NAME, "is_default": True}) + return _orig(doc) + + boards.insert_one = _insert_then_win + + assert ensure_default_board(db, 7) == "99" + + +def test_returns_none_when_board_absent_after_write(): + """כתיבה שלא נקלטה ואין לוח — מדווחים כישלון, לא מזהה מדומה.""" + boards = _StubColl(insert_fails=True) + db = _StubDB(boards=boards) + + assert ensure_default_board(db, 7) is None + + +def test_successful_insert_that_did_not_land_is_not_trusted(): + """``inserted_id`` שחזר אינו ראיה שהמסמך קיים. + + זו הבדיקה שמגנה על הקריאה החוזרת. ה-stub מדווח הצלחה ומחזיר מזהה, + אבל לא מוסיף את המסמך — בדיוק הדפוס שבו כתיבה "מצליחה" ולא נקלטת. + בלי האימות הפונקציה הייתה מחזירה מזהה של לוח שאינו קיים, וכל פתק + שהיה נוחת עליו נעלם מהממשק. + + אומת במוטציה: החזרת ``inserted_id`` ישירות מפילה בדיוק את הטסט הזה. + """ + boards = _StubColl() + boards.insert_one = lambda doc: type("Res", (), {"inserted_id": 12345})() + db = _StubDB(boards=boards) + + assert ensure_default_board(db, 7) is None + + +def test_no_collection_is_survivable(): + assert ensure_default_board(object(), 7) is None + + +# -- list_boards -- + +def test_default_board_sorts_first(): + db = _StubDB(boards=_StubColl([ + {"_id": 2, "user_id": 7, "name": "ב", "order": 1}, + {"_id": 1, "user_id": 7, "name": "ברירת מחדל", "is_default": True, "order": 9}, + {"_id": 3, "user_id": 7, "name": "א", "order": 0}, + ])) + + names = [b["name"] for b in list_boards(db, 7)] + assert names == ["ברירת מחדל", "א", "ב"] + + +# -- reattach_orphan_notes -- + +def test_orphan_notes_move_to_default(): + """פתק שמצביע ללוח שנעלם חוזר לברירת המחדל — הקישור מתוקן, לא הפתק.""" + notes = _StubColl([ + {"_id": 1, "user_id": 7, "board_id": "gone"}, + {"_id": 2, "user_id": 7, "board_id": "alive"}, + ]) + db = _StubDB(notes=notes) + + moved = reattach_orphan_notes(db, 7, ["alive", "home"], "home") + + assert moved == 1 + assert notes.docs[0]["board_id"] == "home" + assert notes.docs[1]["board_id"] == "alive" + + +def test_file_notes_are_untouched(): + """פתק קובץ אינו נושא ``board_id`` ולכן אינו נסרק.""" + notes = _StubColl([{"_id": 1, "user_id": 7, "file_id": "f1"}]) + db = _StubDB(notes=notes) + + assert reattach_orphan_notes(db, 7, ["home"], "home") == 0 + assert "board_id" not in notes.docs[0] + + +def test_moved_count_comes_from_a_recount_not_from_the_write(): + """הספירה היא הפרש בין שתי קריאות, ולא ``modified_count``. + + ``update_many`` כאן מדווח ``modified_count=1`` תמיד, גם כשלא זז דבר. + בלי הקריאה החוזרת הפונקציה הייתה מדווחת על העברה שלא קרתה. + """ + notes = _StubColl([{"_id": 1, "user_id": 7, "board_id": "gone"}]) + notes.update_many = lambda q, u: type("Res", (), {"modified_count": 1})() + db = _StubDB(notes=notes) + + assert reattach_orphan_notes(db, 7, ["home"], "home") == 0 + + +def test_nothing_to_do_skips_the_write_entirely(): + notes = _StubColl([{"_id": 1, "user_id": 7, "board_id": "home"}]) + db = _StubDB(notes=notes) + + assert reattach_orphan_notes(db, 7, ["home"], "home") == 0 + assert notes.updates == [] + + +def test_missing_default_id_is_a_noop(): + """בלי יעד אין לאן להעביר — לא נוגעים בפתקים.""" + notes = _StubColl([{"_id": 1, "user_id": 7, "board_id": "gone"}]) + db = _StubDB(notes=notes) + + assert reattach_orphan_notes(db, 7, ["alive"], "") == 0 + assert notes.docs[0]["board_id"] == "gone" diff --git a/tests/test_note_boards_api.py b/tests/test_note_boards_api.py new file mode 100644 index 000000000..381f6f584 --- /dev/null +++ b/tests/test_note_boards_api.py @@ -0,0 +1,544 @@ +"""בדיקות ל-API של לוחות הפתקים ולראוטי פתקי הלוח. + +התבנית: stub של אוסף בפייתון טהור, כמו ב-``tests/test_sticky_notes_api.py``. +היתרון המרכזי כאן הוא שאפשר לתאר מצבים שקשה לייצר מול מסד אמיתי — למשל +``update_many`` שמדווח הצלחה ולא מזיז כלום, שזה בדיוק המצב שהמחיקה חייבת +לשרוד בלי לאבד פתקים. +""" + +import pytest + +flask = pytest.importorskip("flask") + + +class _Res: + def __init__(self, inserted_id=None, modified_count=1, deleted_count=1): + self.inserted_id = inserted_id + self.modified_count = modified_count + self.deleted_count = deleted_count + + +class _StubColl: + def __init__(self, docs=None): + self.docs = list(docs or []) + self._next = 1000 + self.count_fails = False + self.move_is_noop = False + + # -- קריאה -- + def find_one(self, query, projection=None): + for doc in self._matching(query): + return doc + return None + + def find(self, query, projection=None): + return _Cursor(self._matching(query)) + + def count_documents(self, query): + if self.count_fails: + raise RuntimeError("count failed") + return len(self._matching(query)) + + def aggregate(self, pipeline): + if self.count_fails: + raise RuntimeError("aggregate failed") + match = pipeline[0].get("$match", {}) + group_key = pipeline[1]["$group"]["_id"].lstrip("$") + buckets = {} + for doc in self._matching(match): + buckets[doc.get(group_key)] = buckets.get(doc.get(group_key), 0) + 1 + return [{"_id": k, "n": v} for k, v in buckets.items()] + + # -- כתיבה -- + def insert_one(self, doc): + doc = dict(doc) + doc["_id"] = doc.get("_id") or self._next + self._next += 1 + self.docs.append(doc) + return _Res(inserted_id=doc["_id"]) + + def update_one(self, query, update): + for doc in self._matching(query): + doc.update(update.get("$set", {})) + return _Res() + return _Res(modified_count=0) + + def update_many(self, query, update): + if self.move_is_noop: + return _Res() # מדווח הצלחה, לא מזיז דבר + n = 0 + for doc in self._matching(query): + doc.update(update.get("$set", {})) + n += 1 + return _Res(modified_count=n) + + def delete_one(self, query): + for doc in self._matching(query): + self.docs.remove(doc) + return _Res() + return _Res(deleted_count=0) + + def create_index(self, *a, **k): + return None + + def create_indexes(self, *a, **k): + return None + + def _matching(self, query): + out = [] + for doc in self.docs: + if all(self._match(doc, k, v) for k, v in query.items()): + out.append(doc) + return out + + @staticmethod + def _match(doc, key, cond): + value = doc.get(key) + if isinstance(cond, dict): + if "$in" in cond and value not in cond["$in"]: + return False + if "$nin" in cond and value in cond["$nin"]: + return False + if "$exists" in cond and (key in doc) != cond["$exists"]: + return False + if "$ne" in cond and value == cond["$ne"]: + return False + return True + return value == cond + + +class _Cursor(list): + def sort(self, *_a, **_k): + return self + + +class _StubDB: + def __init__(self, boards=None, notes=None): + self.note_boards = boards or _StubColl() + self.sticky_notes = notes or _StubColl() + + +@pytest.fixture +def client(monkeypatch): + """אפליקציית Flask מינימלית עם שני ה-blueprints ו-stub למסד.""" + from webapp import note_boards_api, sticky_notes_api + + db = _StubDB() + monkeypatch.setattr(note_boards_api, "get_db", lambda: db) + monkeypatch.setattr(sticky_notes_api, "get_db", lambda: db) + monkeypatch.setattr(sticky_notes_api, "_ensure_indexes", lambda: None) + # ObjectId של bson דוחה מזהים שאינם 24-hex; ה-stub עובד עם int + monkeypatch.setattr(note_boards_api, "ObjectId", lambda x: int(x)) + monkeypatch.setattr(sticky_notes_api, "ObjectId", lambda x: int(x)) + + app = flask.Flask(__name__) + app.secret_key = "test" + app.register_blueprint(note_boards_api.note_boards_bp) + app.register_blueprint(sticky_notes_api.sticky_notes_bp) + + test_client = app.test_client() + with test_client.session_transaction() as sess: + sess["user_id"] = 7 + test_client.db = db + return test_client + + +# -- רשימה ולוח ברירת מחדל -- + +def test_list_returns_default_board(client): + """הרשימה היא הנקודה שבה לוח ברירת המחדל נוצר — היא נטענת בכל כניסה.""" + res = client.get("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/api/note-boards") + body = res.get_json() + + assert res.status_code == 200 + assert body["count"] == 1 + assert body["boards"][0]["is_default"] is True + assert body["boards"][0]["note_count"] == 0 + + +def test_list_reattaches_orphan_notes(client): + """פתק שמצביע ללוח שנעלם חוזר לברירת המחדל בטעינת הרשימה.""" + client.db.sticky_notes.docs.append({"_id": 1, "user_id": 7, "board_id": "ghost"}) + + body = client.get("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/api/note-boards").get_json() + + assert body["reattached"] == 1 + default_id = body["boards"][0]["id"] + assert client.db.sticky_notes.docs[0]["board_id"] == default_id + + +# -- יצירה -- + +def test_create_board(client): + res = client.post("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/api/note-boards", json={"name": " לוח שלי "}) + body = res.get_json() + + assert res.status_code == 201 + assert body["board"]["name"] == "לוח שלי" + assert body["board"]["is_default"] is False + + +def test_create_verifies_the_write_landed(client, monkeypatch): + """``inserted_id`` שחזר אינו ראיה. לוח שלא נוצר יבלע כל פתק שינחת עליו. + + נופל בלי הקריאה החוזרת — שם הראוט היה מחזיר 201 על לוח שאינו קיים. + """ + monkeypatch.setattr(client.db.note_boards, "insert_one", lambda doc: _Res(inserted_id=999)) + + res = client.post("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/api/note-boards", json={"name": "רפאים"}) + + assert res.status_code == 500 + assert res.get_json()["error"] == "board_create_not_applied" + + +def test_create_rejects_when_count_failed(client): + """ספירה שנכשלה אינה "אין לוחות" — אותה הכרעה כמו בתקרת הפתקים.""" + client.db.note_boards.count_fails = True + + res = client.post("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/api/note-boards", json={"name": "x"}) + + assert res.status_code == 409 + assert res.get_json()["error"] == "board_quota_unknown" + + +# -- שינוי שם -- + +def test_rename_default_board_is_allowed(client): + """שינוי שם מותר גם לברירת המחדל — הזיהוי הוא is_default ולא השם. + + זו בדיוק הסיבה שלא חיקינו את "שולחן עבודה", שמזוהה לפי מחרוזת. + """ + board_id = client.get("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/api/note-boards").get_json()["boards"][0]["id"] + + res = client.patch(f"/api/note-boards/{board_id}", json={"name": "הלוח שלי"}) + assert res.status_code == 200 + assert res.get_json()["board"]["name"] == "הלוח שלי" + + after = client.get("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/api/note-boards").get_json()["boards"][0] + assert after["is_default"] is True # עדיין ברירת מחדל + + +def test_rename_verifies_the_write(client, monkeypatch): + board_id = client.get("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/api/note-boards").get_json()["boards"][0]["id"] + monkeypatch.setattr(client.db.note_boards, "update_one", lambda q, u: _Res()) + + res = client.patch(f"/api/note-boards/{board_id}", json={"name": "שם חדש"}) + + assert res.status_code == 409 + assert res.get_json()["error"] == "rename_not_applied" + + +# -- מחיקה -- + +def test_cannot_delete_default_board(client): + """החסימה שאין לה מקבילה באוספים — ובכוונה. + + מחיקת אוסף מאבדת סידור; מחיקת לוח מאבדת את המקום היחיד של הפתק. + """ + board_id = client.get("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/api/note-boards").get_json()["boards"][0]["id"] + + res = client.delete(f"/api/note-boards/{board_id}") + + assert res.status_code == 409 + assert res.get_json()["error"] == "cannot_delete_default" + assert client.db.note_boards.find_one({"_id": int(board_id)}) is not None + + +def test_delete_moves_notes_to_default(client): + default_id = client.get("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/api/note-boards").get_json()["boards"][0]["id"] + other_id = client.post("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/api/note-boards", json={"name": "זמני"}).get_json()["board"]["id"] + client.db.sticky_notes.docs.append({"_id": 1, "user_id": 7, "board_id": other_id}) + + res = client.delete(f"/api/note-boards/{other_id}") + body = res.get_json() + + assert res.status_code == 200 + assert body["moved"] == 1 + assert client.db.sticky_notes.docs[0]["board_id"] == default_id + assert client.db.note_boards.find_one({"_id": int(other_id)}) is None + + +def test_delete_aborts_when_notes_remain(client): + """``update_many`` מדווח הצלחה ולא מזיז דבר — והלוח **לא** נמחק. + + זו הבדיקה המרכזית של הפעולה הזו. בלי הספירה החוזרת אחרי ההעברה הראוט + היה מחזיר 200, מוחק את הלוח, ומשאיר את הפתקים מצביעים ללוח מת: בלתי + נראים בממשק, ובלתי ניתנים לשחזור בלי גישה ישירה למונגו. + """ + other_id = client.post("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/api/note-boards", json={"name": "זמני"}).get_json()["board"]["id"] + client.db.sticky_notes.docs.append({"_id": 1, "user_id": 7, "board_id": other_id}) + client.db.sticky_notes.move_is_noop = True + + res = client.delete(f"/api/note-boards/{other_id}") + body = res.get_json() + + assert res.status_code == 500 + assert body["error"] == "notes_move_incomplete" + assert body["remaining"] == 1 + # הלוח שרד, ואפשר לנסות שוב + assert client.db.note_boards.find_one({"_id": int(other_id)}) is not None + assert client.db.sticky_notes.docs[0]["board_id"] == other_id + + +def test_delete_of_foreign_board_is_404(client): + client.db.note_boards.docs.append({"_id": 555, "user_id": 99, "name": "של מישהו אחר"}) + + assert client.delete("/api/note-boards/555").status_code == 404 + + +# -- פתקי לוח -- + +def test_create_board_note_writes_board_id_only(client): + """פתק לוח נושא ``board_id`` ולא ``file_id``, ולא מקבל ``scope_id``.""" + board_id = client.get("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/api/note-boards").get_json()["boards"][0]["id"] + + res = client.post(f"/api/sticky-notes/board/{board_id}", json={"content": "שלום"}) + assert res.status_code == 201 + + note = client.db.sticky_notes.docs[0] + assert note["board_id"] == board_id + assert "file_id" not in note + assert "scope_id" not in note + assert note["mode"] == "surface" + + +def test_board_note_list_never_touches_code_snippets(client, monkeypatch): + """שאילתת הלוח ישירה — בלי ``_resolve_scope`` ובלי קריאה ל-code_snippets. + + נופלת אם מנתבים פתקי לוח דרך ``list_notes`` הקיים. + """ + from webapp import sticky_notes_api + + board_id = client.get("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/api/note-boards").get_json()["boards"][0]["id"] + client.post(f"/api/sticky-notes/board/{board_id}", json={"content": "א"}) + + called = [] + monkeypatch.setattr( + sticky_notes_api, "_resolve_scope", + lambda *a, **k: called.append(a) or (None, None, []), + ) + + body = client.get(f"/api/sticky-notes/board/{board_id}").get_json() + + assert body["count"] == 1 + assert called == [] + + +def test_invalid_mode_is_rejected(client): + board_id = client.get("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/api/note-boards").get_json()["boards"][0]["id"] + + res = client.post(f"/api/sticky-notes/board/{board_id}", json={"content": "x", "mode": "diagonal"}) + + assert res.status_code == 400 + assert res.get_json()["error"] == "invalid_mode" + + +def test_note_on_foreign_board_is_404(client): + """בלי הבדיקה הזו אפשר ליצור פתקים על ``board_id`` שרירותי.""" + client.db.note_boards.docs.append({"_id": 555, "user_id": 99, "name": "זר"}) + + assert client.post("/api/sticky-notes/board/555", json={"content": "x"}).status_code == 404 + assert client.get("/api/sticky-notes/board/555").status_code == 404 + + +def test_board_quota_is_enforced(client, monkeypatch): + from webapp import sticky_notes_api + + board_id = client.get("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/api/note-boards").get_json()["boards"][0]["id"] + monkeypatch.setattr(sticky_notes_api, "MAX_NOTES_PER_BOARD", 1) + + assert client.post(f"/api/sticky-notes/board/{board_id}", json={"content": "א"}).status_code == 201 + res = client.post(f"/api/sticky-notes/board/{board_id}", json={"content": "ב"}) + + assert res.status_code == 409 + assert res.get_json()["error"] == "note_quota_exceeded" + + +def test_admin_is_exempt_from_quota(client, monkeypatch): + from webapp import sticky_notes_api + + board_id = client.get("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/api/note-boards").get_json()["boards"][0]["id"] + monkeypatch.setattr(sticky_notes_api, "MAX_NOTES_PER_BOARD", 1) + monkeypatch.setattr(sticky_notes_api, "_current_user_is_admin", lambda: True) + + assert client.post(f"/api/sticky-notes/board/{board_id}", json={"content": "א"}).status_code == 201 + assert client.post(f"/api/sticky-notes/board/{board_id}", json={"content": "ב"}).status_code == 201 + + +def test_quota_rejects_when_count_failed(client): + """ספירה שנכשלה ⇒ דחייה, לא מעבר. + + ``mcp_server/backend`` עושה כאן את ההפך (``existing = 0`` בכשל). נופל + אם מעתיקים את ההתנהגות ההיא. + """ + board_id = client.get("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/api/note-boards").get_json()["boards"][0]["id"] + client.db.sticky_notes.count_fails = True + + res = client.post(f"/api/sticky-notes/board/{board_id}", json={"content": "x"}) + + assert res.status_code == 409 + assert res.get_json()["error"] == "note_quota_unknown" + + +def test_user_quota_applies_to_file_notes_too(client, monkeypatch): + """התקרה למשתמש חלה על כל הפתקים, לא רק על אלה שבלוחות. + + היא מתועדת מזה זמן ולא נאכפה בשום מקום. אכיפה רק במסלול הלוח הייתה + הופכת את התיעוד לנכון-למחצה — וזה בדיוק סוג הטענה שהלינט של + התקצירים נבנה כדי לתפוס. + """ + from webapp import sticky_notes_api + + monkeypatch.setattr(sticky_notes_api, "MAX_NOTES_PER_USER", 1) + monkeypatch.setattr(sticky_notes_api, "_resolve_scope", lambda *a, **k: (None, None, [])) + client.db.sticky_notes.docs.append({"_id": 1, "user_id": 7, "file_id": "f1"}) + + res = client.post("/api/sticky-notes/f1", json={"content": "עוד אחד"}) + + assert res.status_code == 409 + assert res.get_json()["error"] == "note_quota_exceeded" + + +def test_board_routes_ensure_indexes(client, monkeypatch): + """האינדקסים נוצרים גם כשנכנסים ישר לעמוד הלוחות. + + ``one_default_per_user`` — האינדקס הייחודי-החלקי שסוגר את המרוץ + ביצירת לוח ברירת מחדל — נבנה ב-``sticky_notes_api._ensure_indexes``, + שנקרא רק ממסלולי הפתקים. משתמש שנכנס ישר ל-``/boards`` לא היה עובר + שם, ולכן ההגנה מפני שתי בקשות מקבילות פשוט לא הייתה קיימת. + """ + from webapp import note_boards_api + + calls = [] + monkeypatch.setattr(note_boards_api, "_ensure_board_indexes", lambda: calls.append(1)) + + client.get("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/api/note-boards") + client.post("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/api/note-boards", json={"name": "x"}) + + assert len(calls) == 2 + + +def test_note_counts_come_from_one_aggregation(client, monkeypatch): + """מונה אחד לכל הלוחות, ולא שאילתה פר לוח. + + עם עשרים לוחות, ``count_documents`` פר לוח הוא עשרים סיבובים למסד + בכל טעינת עמוד. + """ + default_id = client.get("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/api/note-boards").get_json()["boards"][0]["id"] + client.db.sticky_notes.docs.append({"_id": 1, "user_id": 7, "board_id": default_id}) + + calls = [] + original = client.db.sticky_notes.count_documents + monkeypatch.setattr( + client.db.sticky_notes, "count_documents", + lambda q: calls.append(q) or original(q), + ) + + body = client.get("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/api/note-boards").get_json() + + assert body["boards"][0]["note_count"] == 1 + # ספירות שנותרו הן של סריקת היתומים בלבד, לא פר לוח + assert not any("board_id" in c and isinstance(c.get("board_id"), str) for c in calls) + + +def test_count_failure_is_distinguishable_from_empty(client): + """כשל ספירה משמיט את המונה במקום להציג אפס. + + אחרת לוח מלא היה נראה ריק בדיוק כשהמסד מתקשה. + """ + client.db.sticky_notes.count_fails = True + + board = client.get("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/api/note-boards").get_json()["boards"][0] + + assert "note_count" not in board + + +# -- ``mode``: הכפתור בלוח נשמר -- +# +# ``mode`` נכתב עד היום רק ביצירה, ולכן כפתור המצב לא יכול היה לשנות כלום +# שנשמר. מה שכן קרה בלחיצה עליו זה שהמיקום נכתב מחדש — כלומר הפתק זז ולא +# שינה מצב. + + +def _board_note(client, **extra): + doc = {"_id": 1, "user_id": 7, "board_id": "b1", "content": "", "mode": "surface"} + doc.update(extra) + client.db.sticky_notes.docs.append(doc) + return doc + + +def test_mode_update_is_persisted_for_board_note(client): + doc = _board_note(client) + + res = client.put("/api/sticky-notes/note/1", json={"mode": "screen"}) + + assert res.status_code == 200, res.get_json() + assert doc["mode"] == "screen" + + +def test_mode_alone_is_enough_to_update(client): + """``mode`` בלבד אינו "אין שדות לעדכון".""" + _board_note(client) + + res = client.put("/api/sticky-notes/note/1", json={"mode": "screen"}) + + assert res.status_code == 200 + assert res.get_json()["ok"] is True + + +def test_invalid_mode_is_rejected(client): + doc = _board_note(client) + + res = client.put("/api/sticky-notes/note/1", json={"mode": "anchored"}) + + assert res.status_code == 400 + assert res.get_json()["error"] == "invalid_mode" + assert doc["mode"] == "surface", "הפתק לא השתנה" + + +def test_mode_is_refused_on_a_file_note(client): + """פתק קובץ אינו מקבל ``mode``. + + ``_resolveMode`` בלקוח קורא את ``mode`` **לפני** הסנטינלים, ולכן שדה + כזה על פתק קובץ היה משתלט על מסלול העיגון לשורות המקור. + """ + doc = {"_id": 2, "user_id": 7, "file_id": "f1", "content": "", "scope_id": "s1"} + client.db.sticky_notes.docs.append(doc) + + res = client.put("/api/sticky-notes/note/2", json={"mode": "screen"}) + + assert res.status_code == 400 + assert res.get_json()["error"] == "mode_not_supported" + assert "mode" not in doc + + +def test_mode_update_works_through_the_batch_route(client): + """זה המסלול שהלקוח באמת משתמש בו — ``_performSaveBatch``.""" + doc = _board_note(client) + + res = client.post( + "/api/sticky-notes/batch", + json={"updates": [{"id": "1", "mode": "screen", "position": {"x": 5, "y": 6}}]}, + ) + + assert res.status_code == 200 + assert res.get_json()["results"][0]["ok"] is True + assert doc["mode"] == "screen" + assert (doc["position_x"], doc["position_y"]) == (5, 6) + + +def test_batch_rejects_invalid_mode_without_touching_the_note(client): + doc = _board_note(client) + + res = client.post( + "/api/sticky-notes/batch", + json={"updates": [{"id": "1", "mode": "לא-קיים", "position": {"x": 5, "y": 6}}]}, + ) + + result = res.get_json()["results"][0] + assert result["ok"] is False + assert result["status"] == 400 + assert result["error"] == "invalid_mode" + # הפריט כולו נדחה, ולא "חצי נשמר" + assert doc["mode"] == "surface" + assert "position_x" not in doc diff --git a/tests/test_note_boards_ui.py b/tests/test_note_boards_ui.py new file mode 100644 index 000000000..1d7096a22 --- /dev/null +++ b/tests/test_note_boards_ui.py @@ -0,0 +1,113 @@ +"""בדיקות לעמודי לוחות הפתקים. + +הבדיקות כאן מרנדרות את התבניות **באפליקציה האמיתית** ולא ב-Flask מינימלי. +הסיבה: רישום שני ה-blueprints ב-``webapp/app`` עטוף ב-``try/except`` שבולע +כשלים, ולכן ייבוא שבור או תבנית חסרה היו מדלגים בשקט — והבדיקות עם stub +היו ממשיכות לעבור. +""" + +import pytest + +pytest.importorskip("flask") + + +@pytest.fixture +def app_client(): + from webapp.app import app + + app.config['TESTING'] = True + return app.test_client() + + +@pytest.fixture +def logged_in(app_client): + with app_client.session_transaction() as sess: + sess['user_id'] = 7 + return app_client + + +# -- רישום -- + +def test_board_pages_are_registered(): + """שני הראוטים קיימים במפת ה-URL של האפליקציה האמיתית.""" + from webapp.app import app + + rules = {str(r) for r in app.url_map.iter_rules()} + assert '/boards' in rules + assert '/boards/' in rules + + +def test_board_api_routes_are_registered(): + from webapp.app import app + + rules = {str(r) for r in app.url_map.iter_rules()} + assert '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/api/note-boards' in rules + assert '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/api/note-boards/' in rules + assert '/api/sticky-notes/board/' in rules + + +# -- הרשאות -- + +def test_boards_page_requires_login(app_client): + """אורח מנותב להתחברות, עם ``next`` שמחזיר אותו לאותו עמוד.""" + res = app_client.get('/boards') + + assert res.status_code == 302 + assert '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/login' in res.headers['Location'] + assert 'next=/boards' in res.headers['Location'] + + +def test_single_board_page_keeps_the_id_in_next(app_client): + res = app_client.get('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/boards/abc123') + + assert res.status_code == 302 + assert 'next=/boards/abc123' in res.headers['Location'] + + +# -- רינדור -- + +def test_boards_list_renders(logged_in): + """התבנית מתרנדרת בפועל. תבנית חסרה או Jinja שבור ייפלו כאן.""" + res = logged_in.get('/boards') + html = res.get_data(as_text=True) + + assert res.status_code == 200 + assert 'לוחות פתקים' in html + assert 'css/note-boards.css' in html + + +def test_board_surface_renders_with_its_id(logged_in): + """מזהה הלוח מגיע ל-JS דרך ``tojson``, ולא כהדבקת מחרוזת.""" + res = logged_in.get('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/boards/507f1f77bcf86cd799439011') + html = res.get_data(as_text=True) + + assert res.status_code == 200 + assert '"507f1f77bcf86cd799439011"' in html + assert 'js/sticky-notes.js' in html + assert 'boardSurface' in html + + +def test_board_id_is_escaped_not_interpolated(logged_in): + """מזהה עוין אינו נשבר החוצה מה-JS. + + ``{{ board_id | tojson }}`` הוא מה שמונע את זה. בלעדיו מזהה שמכיל + מרכאות או ```` היה מייצר XSS מאוחסן-בנתיב. + """ + res = logged_in.get('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/boards/x";alert(1)') + html = res.get_data(as_text=True) + + assert res.status_code == 200 + # המרכאה מומלטת, ולכן ההשמה נשארת מחרוזת אחת ולא נשברת לקוד + assert 'const BOARD_ID = "x\\";alert(1)"' in html + assert 'const BOARD_ID = "x";alert(1)' not in html + + +# -- כניסה מהנאבבר -- + +def test_navbar_links_to_boards(logged_in): + """בלי הפריט הזה הפיצ'ר קיים ב-URL ואף אחד לא מוצא אותו.""" + res = logged_in.get('/boards') + html = res.get_data(as_text=True) + + assert 'href="/boards"' in html + assert 'לוחות פתקים' in html diff --git a/tests/test_note_permalink.py b/tests/test_note_permalink.py new file mode 100644 index 000000000..8578b2aee --- /dev/null +++ b/tests/test_note_permalink.py @@ -0,0 +1,167 @@ +"""בדיקות לפרמלינק ``/note/`` ולזרימת התזכורות של פתקי לוח. + +הבעיה שהשלב הזה פותר: כל צרכן הרכיב את יעד ההתראה בעצמו מהמזהים שהיו לו +ביד. ``sw.js`` בנה ``/md/``, והפעמון ב-``base.html`` עשה אותו דבר +מאחורי ``if (fileId)``. לפתק לוח אין ``file_id``, ולכן ההתראה נחתה בשורש +האתר ולחיצה על הפעמון לא עשתה כלום. + +התיקון הוא בונה אחד — הראוט הזה. הצרכנים פותחים ``/note/`` ולא צריכים +לדעת דבר על סוג הפתק. +""" + +from pathlib import Path + +import pytest + +pytest.importorskip("flask") + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _repo_file(rel: str) -> str: + """קורא קובץ יחסית לשורש הריפו ולא ל-cwd. + + pytest אינו מבטיח את ספריית העבודה, ובדיקה שקוראת נתיב יחסי הייתה + נכשלת — או גרוע מזה, עוברת על קובץ אחר — כשמריצים אותה מתיקייה אחרת. + """ + return (REPO_ROOT / rel).read_text(encoding="utf-8") + + +class _Coll: + def __init__(self, docs=None): + self.docs = list(docs or []) + + def find_one(self, query, projection=None): + for doc in self.docs: + if all(doc.get(k) == v for k, v in query.items()): + return doc + return None + + +@pytest.fixture +def client(monkeypatch): + from webapp import boards_ui + from webapp.app import app + + notes = _Coll([ + {"_id": 1, "user_id": 7, "file_id": "file-abc"}, + {"_id": 2, "user_id": 7, "board_id": "board-xyz"}, + {"_id": 3, "user_id": 7}, # פתק בלי יעד — לא אמור לקרות + {"_id": 4, "user_id": 99, "file_id": "other"}, # של משתמש אחר + ]) + db = type("DB", (), {"sticky_notes": notes})() + + monkeypatch.setattr(boards_ui, "ObjectId", lambda x: int(x)) + # ``get_db`` מיובא בתוך הפונקציה, ולכן מחליפים אותו במקור + import webapp.app as webapp_app + monkeypatch.setattr(webapp_app, "get_db", lambda: db) + + app.config['TESTING'] = True + c = app.test_client() + with c.session_transaction() as sess: + sess['user_id'] = 7 + return c + + +def test_file_note_redirects_to_markdown_view(client): + res = client.get('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/note/1') + + assert res.status_code == 302 + assert res.headers['Location'] == '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/md/file-abc?note=1' + + +def test_board_note_redirects_to_its_board(client): + """זה המקרה שהיה שבור: פתק לוח נחת בשורש האתר.""" + res = client.get('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/note/2') + + assert res.status_code == 302 + assert res.headers['Location'] == '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/boards/board-xyz?note=2' + + +def test_note_without_target_falls_back_to_boards(client): + res = client.get('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/note/3') + + assert res.status_code == 302 + assert res.headers['Location'] == '/boards' + + +def test_foreign_note_is_not_disclosed(client): + """פתק של משתמש אחר מקבל את אותה תשובה כמו פתק שנמחק. + + אחרת הראוט היה מדליף אם מזהה מסוים קיים במערכת. + """ + res = client.get('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/note/4') + + assert res.status_code == 302 + assert res.headers['Location'] == '/boards' + + +def test_deleted_note_falls_back_to_boards(client): + res = client.get('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/note/999') + + assert res.status_code == 302 + assert res.headers['Location'] == '/boards' + + +def test_guest_keeps_the_note_in_next(): + """סשן שפג מחזיר את המשתמש לפתק אחרי ההתחברות, ולא לשורש.""" + from webapp.app import app + + app.config['TESTING'] = True + res = app.test_client().get('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/note/abc') + + assert res.status_code == 302 + assert 'next=/note/abc' in res.headers['Location'] + + +# -- הצרכנים -- + +def test_service_worker_opens_the_permalink(): + """``sw.js`` מפסיק לבנות URL ממזהים. + + נופל אם מישהו יחזיר את בניית ``/md/`` כמסלול הראשי. + """ + sw = _repo_file('webapp/static/sw.js') + + assert '`/note/${encodeURIComponent(noteId)}`' in sw + # הפולבק להתראות ישנות שכבר בתור נשאר, אבל רק כענף שני. + # החיפוש מתחיל אחרי המופע הראשון — offset שלילי היה נספר מסוף + # המחרוזת ומחפש במקום הלא נכון. + idx_note = sw.index('/note/${encodeURIComponent(noteId)}') + idx_md = sw.index('/md/${encodeURIComponent(fileId)}', idx_note) + assert idx_note < idx_md + + +def test_service_worker_version_was_bumped(): + """בלי bump אי אפשר לאשש בפרודקשן שהגרסה החדשה נטענה.""" + import re + + sw = _repo_file('webapp/static/sw.js') + + m = re.search(r"const SW_VERSION = '([\d.]+)';", sw) + assert m, "SW_VERSION לא נמצא" + version = tuple(int(x) for x in m.group(1).split('.')) + # השוואה לגרסה שקדמה לשינוי, ולא לערך מדויק — כך שה-bump הבא לא + # יפיל את הבדיקה, אבל ויתור על bump כן. + assert version > (2, 0, 4), f"SW_VERSION={m.group(1)} לא הועלה" + + +def test_bell_uses_the_permalink(): + base = _repo_file('webapp/templates/base.html') + + assert "window.location.href = '/note/' + encodeURIComponent(noteId)" in base + + +def test_push_projection_includes_board_id(): + """ה-projection של שולח ה-push שולף ``board_id``. + + באג אמיתי שהיה כאן: השדה נוסף ל-payload אבל לא ל-projection, ולכן + ``r.get("board_id")`` החזיר תמיד מחרוזת ריקה — כלומר ההתראה על פתק + לוח נשלחה בלי היעד שלה. שדה שנוסף לפלט ולא למקור הוא בדיוק סוג + הכשל שנראה עובד עד שבודקים. + """ + text = _repo_file("webapp/push_api.py") + + projection = text[text.index(" projection = {"):] + projection = projection[:projection.index("}")] + assert '"board_id": 1' in projection diff --git a/tests/test_personal_backup.py b/tests/test_personal_backup.py index c83ad8206..0610c852a 100644 --- a/tests/test_personal_backup.py +++ b/tests/test_personal_backup.py @@ -514,6 +514,36 @@ def test_restore_large_files_overwrite_updates_metadata_when_content_matches(sel assert result["restored"]["large_files"] == 1 assert mock_db.save_large_file.call_count == 1 + def test_restore_board_note_lands_on_the_named_board(self, backup_service, mock_db): + """פתק לוח משוחזר לפי **שם** הלוח. + + עד היום הוא נפל על ה-``continue`` שדורש ``file_name``, כלומר פתקי + לוח לא שרדו גיבוי-שחזור — בשקט, בלי שורת שגיאה. הבדיקה הזו נופלת + על הקוד שהיה כאן לפני התיקון. + """ + mock_db.db.note_boards.find_one.return_value = {"_id": "board-77", "name": "לוח עבודה"} + # ברירת המחדל של MagicMock היא אובייקט אמיתי, ובדיקת הכפילות הייתה + # קוראת אותה כ"הפתק כבר קיים" + mock_db.db.sticky_notes.find_one.return_value = None + + zip_bytes = self._make_zip( + { + "backup_info.json": {"version": 1}, + "metadata/files.json": {"regular_files": [], "large_files": []}, + "metadata/sticky_notes.json": [ + {"board_id": "old-id", "board_name": "לוח עבודה", "content": "משימה"} + ], + } + ) + + result = backup_service.restore_user_data(12345, zip_bytes, overwrite=False) + + assert result["ok"] is True + assert result["restored"]["sticky_notes"] == 1 + doc = mock_db.db.sticky_notes.insert_one.call_args[0][0] + assert doc["board_id"] == "board-77" + assert "file_id" not in doc # אילוץ "בדיוק אחד" + def test_restore_skips_sticky_note_when_file_cannot_be_resolved(self, backup_service, mock_db): """פתקית עם file_name שלא נפתר ל-file_id לא תישמר (כדי לא ליצור יתומות).""" mock_db.get_file.return_value = None diff --git a/tests/test_repo_browser_multi.py b/tests/test_repo_browser_multi.py index 8c11b7bab..c4dde385c 100644 --- a/tests/test_repo_browser_multi.py +++ b/tests/test_repo_browser_multi.py @@ -172,11 +172,21 @@ def test_impersonation_session_key_matches_production(self): assert values[0] == IMPERSONATION_SESSION_KEY def test_production_helpers_still_exist(self): - """שתי הפונקציות שה-guard נשען עליהן עדיין קיימות בשמן.""" + """שתי הפונקציות שה-guard נשען עליהן עדיין זמינות בשמן. + + נספרות גם הגדרות וגם ייבואים: ``is_admin`` ו-``is_premium`` הועברו + למודול ``user_roles`` (הלוגיקה הייתה משוכפלת מילה במילה גם + ב-``code_tools_api``), ו-``webapp/app`` מייצא אותן משם. מה + שחשוב לצרכנים — ``themes_api``, ``routes/repo_browser``, + ``routes/auth_routes`` — הוא שהשם זמין, לא היכן הוא מוגדר. + """ tree = ast.parse(self._app_source()) names = { node.name for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) } + for node in ast.walk(tree): + if isinstance(node, (ast.Import, ast.ImportFrom)): + names.update(alias.asname or alias.name for alias in node.names) assert {'is_admin', 'is_impersonating_safe'} <= names def test_production_impersonation_honors_force_admin(self): diff --git a/tests/test_sticky_notes_api.py b/tests/test_sticky_notes_api.py index 9279f0177..05e00f1d1 100644 --- a/tests/test_sticky_notes_api.py +++ b/tests/test_sticky_notes_api.py @@ -120,6 +120,15 @@ def create_index(self, *args, **kwargs): def create_indexes(self, *args, **kwargs): return None + def count_documents(self, query, *args, **kwargs): + # נוסף כשהתקרה למשתמש התחילה להיאכף. בלי זה ``_count_or_none`` + # מחזיר ``None``, וההתנהגות ה-fail-closed חוסמת כל יצירה — + # כלומר ה-stub, ולא הקוד, היה מקור הכשל. + return sum( + 1 for d in self._docs + if all(d.get(k) == v for k, v in query.items()) + ) + def insert_one(self, doc): self._docs.append(dict(doc)) class R: diff --git a/tests/test_sticky_notes_target.py b/tests/test_sticky_notes_target.py new file mode 100644 index 000000000..923a65618 --- /dev/null +++ b/tests/test_sticky_notes_target.py @@ -0,0 +1,186 @@ +"""בדיקות ל-``sticky_notes_target`` — האילוץ "בדיוק אחד" ותקרת הפתקים. + +המודול טהור, ולכן הבדיקות כאן לא נוגעות ב-Flask, ב-pymongo ובשום stub. זה +בדיוק מה שהמודול נועד לאפשר: את הכלל שקובע לאיזה משטח פתק שייך אפשר לבדוק +בלי להרים אפליקציה. +""" + +import pytest + +from sticky_notes_target import ( + DEFAULT_BOARD_MODE, + NOTE_MODES, + NoteQuotaError, + NoteTargetError, + board_notes_filter, + build_note_target, + check_note_quota, + file_notes_filter, + is_valid_mode, + normalize_mode, + validate_note_target, +) + + +# -- validate_note_target -- + +def test_both_targets_is_rejected(): + """פתק ששייך גם לקובץ וגם ללוח אינו חוקי — אין לו מקום אחד.""" + with pytest.raises(NoteTargetError): + validate_note_target({"file_id": "abc", "board_id": "xyz"}) + + +def test_no_target_is_rejected(): + """פתק בלי משטח אינו נראה בשום מקום בממשק. עדיף שייכשל בכתיבה.""" + with pytest.raises(NoteTargetError): + validate_note_target({}) + + +def test_blank_string_counts_as_empty(): + """``''`` ורווחים אינם יעד. + + זה לא תיאורטי: ``_as_note_response`` מחזיר ``str(doc.get('file_id', ''))``, + כלומר מחרוזת ריקה היא ערך שבאמת מסתובב במערכת, ו-``set_note_reminder`` + כותב אותה למסמך התזכורת. + """ + with pytest.raises(NoteTargetError): + validate_note_target({"file_id": " ", "board_id": ""}) + + +def test_exactly_one_target_passes(): + validate_note_target({"file_id": "abc"}) + validate_note_target({"board_id": "xyz"}) + + +# -- build_note_target -- + +def test_build_runs_validation_before_returning(): + """הבנאי אינו יכול להחזיר מסמך לא חוקי — גם לא כשלא הועבר לו כלום.""" + with pytest.raises(NoteTargetError): + build_note_target() + with pytest.raises(NoteTargetError): + build_note_target(file_id="abc", board_id="xyz") + + +def test_build_file_target_carries_scope_fields(): + target = build_note_target(file_id="abc", scope_id="user:1:file:deadbeef", file_name="a.md") + assert target == { + "file_id": "abc", + "scope_id": "user:1:file:deadbeef", + "file_name": "a.md", + } + + +def test_build_file_target_omits_empty_scope_fields(): + """בדיוק כמו הראוט היום: ``scope_id``/``file_name`` נכתבים רק אם נפתרו.""" + assert build_note_target(file_id="abc") == {"file_id": "abc"} + + +def test_build_board_target_is_minimal(): + assert build_note_target(board_id="b1") == {"board_id": "b1"} + + +def test_board_target_rejects_file_metadata(): + """``scope_id``/``file_name`` הם מושגים של קובץ. + + השלמה שקטה שלהם על פתק לוח הייתה מכניסה אותו לשאילתת הקובץ — כלומר פתק + שמופיע בשני מקומות. עדיף להיכשל אצל הקורא. + """ + with pytest.raises(NoteTargetError): + build_note_target(board_id="b1", scope_id="user:1:file:deadbeef") + with pytest.raises(NoteTargetError): + build_note_target(board_id="b1", file_name="a.md") + + +# -- filters -- + +def test_file_filter_matches_the_existing_route_shape(): + """צורת השאילתה זהה למה שהראוט בנה בידיים, כולל סדר הענפים.""" + assert file_notes_filter(7, "user:7:file:abc", ["id1", "id2"]) == { + "user_id": 7, + "$or": [{"scope_id": "user:7:file:abc"}, {"file_id": {"$in": ["id1", "id2"]}}], + } + + +def test_file_filter_falls_back_to_file_id_without_scope(): + assert file_notes_filter(7, None, [], file_id="f1") == {"user_id": 7, "file_id": "f1"} + + +def test_board_filter_is_direct(): + """בלי ``$or`` ובלי ``code_snippets`` — שאילתה אחת על אינדקס אחד.""" + assert board_notes_filter(7, "b1") == {"user_id": 7, "board_id": "b1"} + + +def test_filters_cannot_catch_each_others_notes(): + """אין דליפה בין הכיוונים — וזו הסיבה שלא נדרש שומר נוסף בראוטים. + + פתק לוח לא נושא ``scope_id`` ולא ``file_id``, ושלושת הענפים של שאילתת + הקובץ דורשים אחד מהם. פתק קובץ לא נושא ``board_id``. + """ + file_q = file_notes_filter(7, "user:7:file:abc", ["id1"]) + board_q = board_notes_filter(7, "b1") + + board_note = {"user_id": 7, "board_id": "b1"} + file_note = {"user_id": 7, "file_id": "id1", "scope_id": "user:7:file:abc"} + + # שאילתת הקובץ אינה נוגעת בפתק לוח: אף ענף ב-$or אינו מזכיר שדה שקיים בו + assert all(key not in board_note for clause in file_q["$or"] for key in clause) + # ושאילתת הלוח אינה נוגעת בפתק קובץ: היא דורשת board_id שאין לו + assert "board_id" in board_q + assert "board_id" not in file_note + + +# -- modes -- + +def test_default_mode_is_surface(): + """ברירת המחדל בלוח היא הצמדה למשטח — הלוח *הוא* המשטח.""" + assert DEFAULT_BOARD_MODE == "surface" + assert normalize_mode(None) == "surface" + assert normalize_mode("") == "surface" + + +def test_unknown_mode_falls_back_but_is_not_valid(): + """``normalize_mode`` סלחני לקלט משתמש; ``is_valid_mode`` הוא זה שדוחה 400.""" + assert normalize_mode("nonsense") == "surface" + assert is_valid_mode("nonsense") is False + assert is_valid_mode("screen") is True + + +def test_anchored_is_reserved_but_recognized(): + """``anchored`` שמור לפתקי קבצים כשיעברו לשדה אמיתי. + + הוא ברשימה מראש כדי שהמעבר לא ידרוש שינוי שם של ערך קיים. + """ + assert "anchored" in NOTE_MODES + assert is_valid_mode("anchored") is True + + +# -- check_note_quota -- + +def test_quota_blocks_at_cap(): + with pytest.raises(NoteQuotaError): + check_note_quota(200, 200) + + +def test_quota_allows_below_cap(): + check_note_quota(199, 200) + + +def test_admin_is_exempt(): + check_note_quota(10_000, 200, is_admin=True) + + +def test_failed_count_is_rejected_not_waved_through(): + """כשל ספירה ⇒ דחייה. + + ``mcp_server/backend`` עושה כאן את ההפך: ``existing = 0`` בכשל, כלומר + התקרה נפתחת לרווחה בדיוק כשהמסד מתקשה. הבדיקה הזו נופלת אם מעתיקים את + ההתנהגות ההיא לכאן. + """ + with pytest.raises(NoteQuotaError): + check_note_quota(None, 200) + + +def test_admin_exempt_even_when_count_failed(): + """הפטור קודם לכל בדיקה אחרת — אדמין לא נחסם בגלל מסד שמתקשה.""" + check_note_quota(None, 200, is_admin=True) diff --git a/tests/test_sticky_notes_tasks.py b/tests/test_sticky_notes_tasks.py new file mode 100644 index 000000000..09118f0f2 --- /dev/null +++ b/tests/test_sticky_notes_tasks.py @@ -0,0 +1,265 @@ +"""בדיקות לצ'קבוקסים בפתקים — הפונקציות הטהורות והראוט. + +האפיון מסמן את הפיצ'ר הזה כמקור הבאג הצפוי, ובניסוח מדויק: *"לחיצה על +צ'קבוקס היא כתיבה למסד, לא שינוי תצוגה"*. הכשל הצפוי הוא שהתצוגה מתהפכת +מיד, השמירה נכשלת בשקט, וברענון הסימון חוזר אחורה — בלי ששום דבר דיווח. + +לכן רוב הבדיקות כאן אינן על "האם התו התחלף", אלא על **מה קורה כשהכתיבה +לא נקלטה**. +""" + +import pytest + +from sticky_notes_tasks import count_tasks, task_state_at_index, toggle_task_at_index + + +# ---------------------------------------------------------------- טהור + +def test_toggles_the_nth_occurrence_not_the_first_match(): + """שלוש שורות זהות — ומסמנים בדיוק את השנייה. + + ``str.replace`` היה מסמן את הראשונה בכל המקרים, ולחיצה על השנייה + הייתה "קופצת" לראשונה. זו הסיבה שהזיהוי הוא סידורי ולא טקסטואלי. + """ + content = "- [ ] לבדוק\n- [ ] לבדוק\n- [ ] לבדוק" + + out, changed = toggle_task_at_index(content, 1, True) + + assert changed is True + assert out == "- [ ] לבדוק\n- [x] לבדוק\n- [ ] לבדוק" + + +def test_preserves_indent_bullet_and_trailing_text(): + content = " * [ ] משימה מוזחת עם *הדגשה*" + + out, changed = toggle_task_at_index(content, 0, True) + + assert changed is True + assert out == " * [x] משימה מוזחת עם *הדגשה*" + + +def test_uncheck_returns_a_space(): + out, changed = toggle_task_at_index("- [x] בוצע", 0, False) + + assert changed is True + assert out == "- [ ] בוצע" + + +def test_uppercase_x_counts_as_checked(): + """``- [X]`` הוא מסומן. השוואת תווים ישירה הייתה מייצרת כתיבה מיותרת.""" + assert task_state_at_index("- [X] בוצע", 0) is True + + out, changed = toggle_task_at_index("- [X] בוצע", 0, True) + assert changed is False + assert out == "- [X] בוצע" + + +def test_already_in_desired_state_is_a_noop(): + """אידמפוטנטי — ואין כתיבה. הראוט נשען על זה.""" + out, changed = toggle_task_at_index("- [x] בוצע", 0, True) + + assert changed is False + assert out == "- [x] בוצע" + + +def test_missing_index_changes_nothing(): + out, changed = toggle_task_at_index("- [ ] אחת", 5, True) + + assert changed is False + assert out == "- [ ] אחת" + assert task_state_at_index("- [ ] אחת", 5) is None + + +def test_non_task_lines_are_not_counted(): + content = "כותרת\n- [ ] אחת\nסתם שורה\n- פריט רגיל\n- [x] שתיים" + + assert count_tasks(content) == 2 + assert task_state_at_index(content, 0) is False + assert task_state_at_index(content, 1) is True + + +def test_crlf_content_round_trips(): + """``\\r\\n`` שורד את הפיצול והחיבור מחדש, בלי ענף מיוחד. + + זו התנהגות שנבדקה ולא הונחה: הפיצול על ``\\n`` משאיר ``\\r`` בסוף + השורה, ה-regex עדיין תואם, והחיבור מרכיב את ``\\r\\n`` בחזרה. + """ + content = "- [ ] אחת\r\n- [ ] שתיים" + + out, changed = toggle_task_at_index(content, 1, True) + + assert changed is True + assert out == "- [ ] אחת\r\n- [x] שתיים" + + +def test_cr_only_content_is_split_correctly(): + """``\\r`` לבדו כן דורש טיפול — בלעדיו שתי משימות נספרות כאחת. + + זו הבדיקה שמגנה על הענף היחיד שנשאר ב-``_split_lines``. אומת + במוטציה: הסרתו מורידה את הספירה ל-1 ומפילה כאן. + """ + content = "- [ ] אחת\r- [ ] שתיים" + + assert count_tasks(content) == 2 + + out, changed = toggle_task_at_index(content, 1, True) + assert changed is True + assert out == "- [ ] אחת\r- [x] שתיים" + + +def test_lines_inside_a_fence_are_counted_too(): + """החלטה מתועדת: לא מפרשים בלוקי קוד. + + האלטרנטיבה היא פרסר מארקדאון מלא, שהוא מחוץ להיקף — וכבר ידוע בריפו + הזה מה עולה פרסור מארקדאון שנכתב ביד. הבדיקה קיימת כדי שההחלטה תהיה + מתועדת ולא הפתעה. + """ + content = "- [ ] אמיתית\n```\n- [ ] בתוך גדר\n```" + + assert count_tasks(content) == 2 + + +def test_toggle_preserves_length(): + """``- [ ]`` ו-``- [x]`` זהים באורכם, ולכן תקרת 5,000 לא יכולה להישבר.""" + content = "- [ ] " + "א" * 100 + + out, _ = toggle_task_at_index(content, 0, True) + + assert len(out) == len(content) + + +# ---------------------------------------------------------------- הראוט + +flask = pytest.importorskip("flask") + + +class _Res: + def __init__(self, modified_count=1): + self.modified_count = modified_count + + +class _NotesColl: + def __init__(self, docs): + self.docs = docs + self.writes = 0 + self.write_is_noop = False + + def find_one(self, query, projection=None): + for doc in self.docs: + if all(doc.get(k) == v for k, v in query.items()): + return doc + return None + + def update_one(self, query, update): + self.writes += 1 + if self.write_is_noop: + return _Res() # מדווח הצלחה, לא כותב + for doc in self.docs: + if all(doc.get(k) == v for k, v in query.items()): + doc.update(update.get("$set", {})) + return _Res() + return _Res(modified_count=0) + + +@pytest.fixture +def client(monkeypatch): + from webapp import sticky_notes_api + + note = {"_id": 1, "user_id": 7, "content": "- [ ] אחת\n- [ ] שתיים"} + coll = _NotesColl([note]) + db = type("DB", (), {"sticky_notes": coll})() + + monkeypatch.setattr(sticky_notes_api, "get_db", lambda: db) + monkeypatch.setattr(sticky_notes_api, "ObjectId", lambda x: int(x)) + + app = flask.Flask(__name__) + app.secret_key = "test" + app.register_blueprint(sticky_notes_api.sticky_notes_bp) + c = app.test_client() + with c.session_transaction() as sess: + sess["user_id"] = 7 + c.coll = coll + c.note = note + return c + + +def _toggle(c, index, checked=True, **extra): + payload = {"index": index, "checked": checked} + payload.update(extra) + return c.post("/api/sticky-notes/note/1/task", json=payload) + + +def test_route_toggles_and_returns_authoritative_content(client): + res = _toggle(client, 1, True) + body = res.get_json() + + assert res.status_code == 200 + assert body["changed"] is True + assert body["content"] == "- [ ] אחת\n- [x] שתיים" + assert client.note["content"] == "- [ ] אחת\n- [x] שתיים" + + +def test_route_returns_409_when_the_write_did_not_apply(client): + """הכתיבה מדווחת הצלחה, והקריאה החוזרת מגלה שהתוכן לא זז. + + **זו הבדיקה המרכזית של הפיצ'ר.** בלי הקריאה החוזרת הראוט היה מחזיר + 200, הלקוח היה משאיר את התיבה מסומנת, וברענון הסימון היה נעלם — בלי + ששום דבר דיווח על כשל. בדיוק מה שהאפיון אוסר. + """ + client.coll.write_is_noop = True + + res = _toggle(client, 0, True) + body = res.get_json() + + assert res.status_code == 409 + assert body["error"] == "task_toggle_not_applied" + # התשובה נושאת את התוכן האמיתי, כדי שהלקוח יוכל להחזיר את התצוגה + assert body["content"] == "- [ ] אחת\n- [ ] שתיים" + + +def test_route_rejects_a_stale_index(client): + """סידורי שאינו קיים ⇒ 409 ולא 200. התצוגה של הלקוח מתארת פתק אחר.""" + res = _toggle(client, 9, True) + + assert res.status_code == 409 + assert res.get_json()["error"] == "task_index_not_found" + assert client.coll.writes == 0 + + +def test_route_is_idempotent_without_writing(client): + _toggle(client, 0, True) + writes_after_first = client.coll.writes + + res = _toggle(client, 0, True) + + assert res.status_code == 200 + assert res.get_json()["changed"] is False + assert client.coll.writes == writes_after_first # לא נכתב שוב + + +def test_route_uses_compare_and_swap(client): + """הפילטר כולל את התוכן הקודם, כדי שכותב מקביל לא יידרס.""" + captured = {} + original_update = client.coll.update_one + + def _spy(query, update): + captured.update(query) + return original_update(query, update) + + client.coll.update_one = _spy + _toggle(client, 0, True) + + assert captured.get("content") == "- [ ] אחת\n- [ ] שתיים" + + +def test_route_404_for_foreign_note(client): + client.coll.docs.append({"_id": 2, "user_id": 99, "content": "- [ ] של אחר"}) + + res = client.post("/api/sticky-notes/note/2/task", json={"index": 0, "checked": True}) + + assert res.status_code == 404 + + +def test_route_rejects_bad_index(client): + assert _toggle(client, "לא-מספר").status_code == 400 + assert _toggle(client, -1).status_code == 400 diff --git a/tests/test_user_roles.py b/tests/test_user_roles.py new file mode 100644 index 000000000..8b9828b0c --- /dev/null +++ b/tests/test_user_roles.py @@ -0,0 +1,89 @@ +"""בדיקות ל-``user_roles`` — מקור האמת היחיד להרשאות אדמין/פרימיום. + +הפונקציות האלה היו משוכפלות מילה במילה ב-``webapp/app`` וב- +``webapp/code_tools_api``. הבדיקות כאן מכסות את ההתנהגות המשותפת, ובנוסף +מאמתות ששני הצרכנים באמת מצביעים לאותה פונקציה ולא לעותק. +""" + +import pytest + +import user_roles + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + monkeypatch.delenv("ADMIN_USER_IDS", raising=False) + monkeypatch.delenv("PREMIUM_USER_IDS", raising=False) + + +# -- is_admin -- + +def test_no_env_means_nobody_is_admin(): + """היעדר הגדרה אינו מעניק הרשאה — ברירת המחדל סגורה.""" + assert user_roles.is_admin(1) is False + + +def test_listed_id_is_admin(monkeypatch): + monkeypatch.setenv("ADMIN_USER_IDS", "5, 7 ,9") + assert user_roles.is_admin(7) is True + assert user_roles.is_admin(8) is False + + +def test_non_numeric_entry_is_skipped_not_fatal(monkeypatch): + """רשומה פגומה לא מעניקה הרשאה וגם לא מפילה את הבקשה. + + זו בדיוק ההתנהגות של שני העותקים שהוסרו, והיא נשמרת. + """ + monkeypatch.setenv("ADMIN_USER_IDS", "abc,,5,-3") + assert user_roles.is_admin(5) is True + assert user_roles.is_admin(-3) is False # ``isdigit`` דוחה מינוס — כמו קודם + + +def test_string_user_id_is_coerced(monkeypatch): + """``session['user_id']`` מגיע לפעמים כמחרוזת.""" + monkeypatch.setenv("ADMIN_USER_IDS", "5") + assert user_roles.is_admin("5") is True + + +def test_garbage_user_id_is_false_not_crash(monkeypatch): + monkeypatch.setenv("ADMIN_USER_IDS", "5") + assert user_roles.is_admin(None) is False + assert user_roles.is_admin("not-a-number") is False + + +# -- is_premium -- + +def test_premium_reads_its_own_env(monkeypatch): + monkeypatch.setenv("ADMIN_USER_IDS", "5") + monkeypatch.setenv("PREMIUM_USER_IDS", "9") + assert user_roles.is_premium(9) is True + assert user_roles.is_premium(5) is False + assert user_roles.is_admin(9) is False + + +# -- אין עותקים -- + +def test_both_consumers_point_at_the_same_function(): + """``app`` ו-``code_tools_api`` מייצאים את הפונקציה עצמה, לא עותק. + + זו הבדיקה שנופלת אם מישהו יחזיר מימוש מקומי לאחד מהם. ``is`` ולא + ``==`` בכוונה: שני מימושים זהים היו עוברים השוואת התנהגות ונכשלים כאן. + """ + pytest.importorskip("flask") + from webapp import code_tools_api + + assert code_tools_api._is_admin is user_roles.is_admin + assert code_tools_api._is_premium is user_roles.is_premium + + +def test_app_still_exports_the_names(): + """``from webapp.app import is_admin`` חייב להמשיך לעבוד. + + שלושה מודולים עושים את זה היום: ``themes_api``, ``routes/repo_browser`` + ו-``routes/auth_routes``. + """ + pytest.importorskip("flask") + from webapp import app as webapp_app + + assert webapp_app.is_admin is user_roles.is_admin + assert webapp_app.is_premium is user_roles.is_premium diff --git a/user_roles.py b/user_roles.py new file mode 100644 index 000000000..608bcbeee --- /dev/null +++ b/user_roles.py @@ -0,0 +1,47 @@ +""" +User Roles — מקור אמת יחיד ל"מי אדמין" ו"מי פרימיום". + +שתי הפונקציות האלה קובעות הרשאות, והן היו משוכפלות מילה במילה בשני מקומות: +``webapp/app.is_admin``/``is_premium`` ו-``webapp/code_tools_api._is_admin``/ +``_is_premium``. שני עותקים של לוגיקת הרשאות הם שני מקומות שיכולים לסטות, +ורק אחד מהם ייבדק. + +המודול טהור בכוונה — קורא ``os.environ`` ותו לא, בלי Flask ובלי מסד. זה מה +שמאפשר ל-``webapp/sticky_notes_api`` לייבא אותו: ``webapp/app`` **מייבא** את +``sticky_notes_api``, ולכן ייבוא הפוך היה יוצר מעגל. אותה תבנית שכבר עובדת +עם ``sticky_notes_scope``, שמיובא משלושה מודולים. +""" +from __future__ import annotations + +import os +from typing import List + + +def _ids_from_env(var_name: str) -> List[int]: + """רשימת מזהי משתמש ממשתנה סביבה מופרד בפסיקים. + + ערך שאינו ספרתי מדולג בשקט — כך התנהגו שני העותקים הקודמים, וזו גם + ההתנהגות הבטוחה: רשומה פגומה ברשימת אדמינים לא אמורה להעניק הרשאה, + וגם לא להפיל את הבקשה. + """ + try: + raw = os.getenv(var_name, "") or "" + return [int(part.strip()) for part in raw.split(",") if part.strip().isdigit()] + except Exception: + return [] + + +def is_admin(user_id: int) -> bool: + """בודק אם משתמש הוא אדמין לפי ``ADMIN_USER_IDS``.""" + try: + return int(user_id) in _ids_from_env("ADMIN_USER_IDS") + except Exception: + return False + + +def is_premium(user_id: int) -> bool: + """בודק אם משתמש הוא פרימיום לפי ``PREMIUM_USER_IDS``.""" + try: + return int(user_id) in _ids_from_env("PREMIUM_USER_IDS") + except Exception: + return False diff --git a/webapp/app.py b/webapp/app.py index d6a2e432a..e95e47eb6 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -1235,6 +1235,30 @@ def _job(): # אל תפיל את היישום אם ה-Blueprint אינו זמין (למשל בסביבת דוקס/CI) pass +# Note Boards API (לוחות פתקים — פתקים שאינם צמודים לקובץ). +# ראוטי הפתקים של הלוח יושבים ב-sticky_notes_bp שלמעלה; כאן רק הלוחות עצמם. +try: + from webapp.note_boards_api import note_boards_bp # noqa: E402 + app.register_blueprint(note_boards_bp) +except Exception as _e: + # לא מפילים את היישום (סביבות דוקס/CI ללא תלויות), אבל גם לא בולעים + # בשקט: רישום שנכשל פירושו שהפיצ'ר פשוט לא קיים, וזה חייב להיראות + # בלוג ברמת error ולא להתגלות כ-404 מסתורי. + try: + logger.error("note_boards_api blueprint not registered: %s", _e, exc_info=True) + except Exception: + pass + +# Note Boards UI (עמודי הלוחות עצמם) +try: + from webapp.boards_ui import boards_ui # noqa: E402 + app.register_blueprint(boards_ui) +except Exception as _e: + try: + logger.error("boards_ui blueprint not registered: %s", _e, exc_info=True) + except Exception: + pass + # Web Push API (public key + subscribe/unsubscribe) try: from webapp.push_api import push_bp, start_sender_if_enabled # noqa: E402 @@ -4101,22 +4125,13 @@ def decorated_function(*args, **kwargs): return f(*args, **kwargs) return decorated_function -def is_admin(user_id: int) -> bool: - """בודק אם משתמש הוא אדמין""" - admin_ids_env = os.getenv('ADMIN_USER_IDS', '') - admin_ids_list = admin_ids_env.split(',') if admin_ids_env else [] - admin_ids = [int(x.strip()) for x in admin_ids_list if x.strip().isdigit()] - return user_id in admin_ids - -def is_premium(user_id: int) -> bool: - """בודק אם משתמש הוא פרימיום לפי ENV PREMIUM_USER_IDS""" - try: - premium_ids_env = os.getenv('PREMIUM_USER_IDS', '') - premium_ids_list = premium_ids_env.split(',') if premium_ids_env else [] - premium_ids = [int(x.strip()) for x in premium_ids_list if x.strip().isdigit()] - return user_id in premium_ids - except Exception: - return False +# ``is_admin``/``is_premium`` חיים ב-``user_roles`` — מודול טהור בשורש. הם +# מיוצאים כאן בשמם כדי שכל מי שכבר עושה ``from webapp.app import is_admin`` +# (themes_api, routes/repo_browser, routes/auth_routes) ימשיך לעבוד. +# הסיבה להוצאה: הלוגיקה הייתה משוכפלת מילה במילה גם ב-``code_tools_api``, +# ובנוסף ``sticky_notes_api`` זקוק לה — ו-``app`` מייבא אותו, כך שייבוא הפוך +# היה יוצר מעגל. +from user_roles import is_admin, is_premium # noqa: E402,F401 # --- Admin Impersonation Functions --- diff --git a/webapp/boards_ui.py b/webapp/boards_ui.py new file mode 100644 index 000000000..5ed146d70 --- /dev/null +++ b/webapp/boards_ui.py @@ -0,0 +1,86 @@ +""" +Note Boards UI routes (server-rendered pages). + +בתבנית ``webapp/collections_ui.py``: הראוטים כאן מגישים HTML בלבד, וכל +הנתונים נשלפים מה-API בצד הלקוח. כך העמוד חוזר מהר, והלוגיקה חיה במקום +אחד — ``webapp/note_boards_api.py``. +""" +from __future__ import annotations + +from flask import Blueprint, redirect, render_template, session + +try: # type: ignore + from bson import ObjectId # type: ignore +except Exception: # pragma: no cover + def ObjectId(x): # type: ignore + s = str(x or "") + if len(s) != 24: + raise ValueError("malformed ObjectId") + return s + +boards_ui = Blueprint('boards_ui', __name__) + + +@boards_ui.route('/boards') +def boards_page(): + """רשימת הלוחות.""" + if 'user_id' not in session: + return redirect('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/login?next=/boards') + return render_template('note_boards.html') + + +@boards_ui.route('/boards/') +def board_page(board_id: str): + """משטח לוח יחיד. + + ``board_id`` מועבר לתבנית ולא נבדק כאן: הבעלות נאכפת ב-API, שמחזיר + 404 ללוח שאינו של המשתמש. בדיקה כפולה כאן הייתה מחייבת גישה למסד + בראוט שכל תפקידו להגיש HTML. + """ + if 'user_id' not in session: + return redirect(f'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/login?next=/boards/{board_id}') + return render_template('note_board.html', board_id=board_id) + + +@boards_ui.route('/note/') +def note_permalink(note_id: str): + """קישור קבוע לפתק — ומשם הפניה למקום שבו הוא באמת יושב. + + **זה תיקון השורש לבעיית ה-deep link.** עד היום כל צרכן הרכיב את + ה-URL בעצמו מהמזהים שהיו לו ביד: ``sw.js`` בנה ``/md/``, + והפעמון ב-``base.html`` עשה אותו דבר. לפתק לוח אין ``file_id``, ולכן + ההתראה נחתה בשורש האתר ולחיצה על הפעמון פשוט לא עשתה כלום. + + עכשיו יש בונה אחד, והוא כאן. הצרכנים פותחים ``/note/`` ולא + צריכים לדעת דבר על סוג הפתק — כולל סוג שלישי שיתווסף בעתיד. + + ``login_required`` אינו בשימוש כאן במכוון: הבדיקה הידנית מאפשרת + לשמור ``next`` בדיוק כמו בשאר עמודי הלוחות, כך שסשן שפג מחזיר את + המשתמש לפתק אחרי ההתחברות במקום לזרוק אותו לשורש. + """ + if 'user_id' not in session: + return redirect(f'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/login?next=/note/{note_id}') + + try: + from webapp.app import get_db + db = get_db() + note = db.sticky_notes.find_one( + {'_id': ObjectId(str(note_id)), 'user_id': int(session['user_id'])}, + {'file_id': 1, 'board_id': 1}, + ) + except Exception: + note = None + + if not note: + # פתק שנמחק, או מזהה פגום. הלוחות הם היעד הבטוח. + return redirect('/boards') + + board_id = str(note.get('board_id') or '') + if board_id: + return redirect(f'/boards/{board_id}?note={note_id}') + + file_id = str(note.get('file_id') or '') + if file_id: + return redirect(f'/md/{file_id}?note={note_id}') + + return redirect('/boards') diff --git a/webapp/code_tools_api.py b/webapp/code_tools_api.py index 299928c65..61ef0550e 100644 --- a/webapp/code_tools_api.py +++ b/webapp/code_tools_api.py @@ -12,22 +12,10 @@ code_tools_bp = Blueprint("code_tools", __name__, url_prefix="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/api/code") -def _is_admin(user_id: int) -> bool: - admin_ids_env = os.getenv("ADMIN_USER_IDS", "") - admin_ids_list = admin_ids_env.split(",") if admin_ids_env else [] - admin_ids = [int(x.strip()) for x in admin_ids_list if x.strip().isdigit()] - return user_id in admin_ids - - -def _is_premium(user_id: int) -> bool: - """בודק אם משתמש הוא פרימיום לפי ENV PREMIUM_USER_IDS""" - try: - premium_ids_env = os.getenv("PREMIUM_USER_IDS", "") - premium_ids_list = premium_ids_env.split(",") if premium_ids_env else [] - premium_ids = [int(x.strip()) for x in premium_ids_list if x.strip().isdigit()] - return user_id in premium_ids - except Exception: - return False +# היו כאן שני עותקים מדויקים של הלוגיקה שב-``webapp/app``. שני עותקים של +# בדיקת הרשאות הם שני מקומות שיכולים לסטות, ורק אחד מהם ייבדק. +from user_roles import is_admin as _is_admin # noqa: E402 +from user_roles import is_premium as _is_premium # noqa: E402 def _is_code_execution_enabled() -> bool: diff --git a/webapp/note_boards_api.py b/webapp/note_boards_api.py new file mode 100644 index 000000000..462529480 --- /dev/null +++ b/webapp/note_boards_api.py @@ -0,0 +1,335 @@ +""" +Note Boards API — יצירה, שינוי שם, ומחיקה של לוחות פתקים. + +ראוטי **הפתקים** של הלוח חיים ב-``webapp/sticky_notes_api`` ולא כאן, כי שם +כבר יושבים ``require_auth``, ``notes_rate_limit``, ``_sanitize_text``, +``_decode_content_b64``, ``_coerce_int`` ו-``_as_note_response``. שכפול שלהם +לקובץ שני הוא בדיוק סוג הסטייה ש-``sticky_notes_scope`` נוצר כדי למנוע. + +מה שכן כאן: הלוחות עצמם — ובעיקר **המחיקה**, שהיא הפעולה היחידה בפיצ'ר +שיכולה לאבד נתונים. +""" +from __future__ import annotations + +from datetime import datetime, timezone +from functools import wraps +from typing import Any, Dict, Optional + +from flask import Blueprint, jsonify, request, session + +try: # type: ignore + from bson import ObjectId # type: ignore +except Exception: # pragma: no cover + def ObjectId(x): # type: ignore + s = str(x or "") + if len(s) != 24: + raise ValueError("malformed ObjectId") + return s + +try: # type: ignore + from observability import emit_event # type: ignore +except Exception: # pragma: no cover + def emit_event(event: str, severity: str = "info", **fields): # type: ignore + return None + +from note_boards import ( + DEFAULT_BOARD_NAME, + ensure_default_board, + list_boards, + normalize_board_name, + reattach_orphan_notes, +) + +note_boards_bp = Blueprint("note_boards", __name__, url_prefix="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/api/note-boards") + +#: תקרת לוחות למשתמש. האפיון אומר "ללא הגבלת מספר", והכוונה היא שהמשתמש לא +#: ייתקל בתקרה בשימוש רגיל — לא שאין תקרה בכלל. בלי שום גבול, באג בלקוח +#: שיוצר לוח בלולאה ימלא את האוסף בשקט. +MAX_BOARDS_PER_USER = 500 + + +def get_db(): + from webapp.app import get_db as _get_db # local import to avoid circulars + return _get_db() + + +def _ensure_board_indexes() -> None: + """מוודא שהאינדקסים קיימים לפני כל פעולה על לוחות. + + בלי זה ``one_default_per_user`` — האינדקס הייחודי-החלקי שסוגר את + המרוץ ביצירת לוח ברירת מחדל — עלול פשוט לא להיווצר: הוא נבנה + ב-``sticky_notes_api._ensure_indexes``, וזה נקרא רק ממסלולי הפתקים. + משתמש שנכנס ישר לעמוד הלוחות לא היה עובר שם. + """ + try: + from webapp.sticky_notes_api import _ensure_indexes + _ensure_indexes() + except Exception: + pass + + +def require_auth(f): + @wraps(f) + def _inner(*args, **kwargs): + if 'user_id' not in session: + return jsonify({'ok': False, 'error': 'Unauthorized'}), 401 + return f(*args, **kwargs) + return _inner + + +def _board_response(doc: Dict[str, Any], note_count: Optional[int] = None) -> Dict[str, Any]: + out = { + 'id': str(doc.get('_id')), + 'name': str(doc.get('name') or DEFAULT_BOARD_NAME), + 'is_default': bool(doc.get('is_default', False)), + 'order': int(doc.get('order') or 0), + 'created_at': (doc.get('created_at').isoformat() if doc.get('created_at') else None), + 'updated_at': (doc.get('updated_at').isoformat() if doc.get('updated_at') else None), + } + if note_count is not None: + out['note_count'] = int(note_count) + return out + + +def _owned_board(db: Any, user_id: int, board_id: str) -> Optional[Dict[str, Any]]: + try: + oid = ObjectId(str(board_id)) + except Exception: + return None + try: + doc = db.note_boards.find_one({'_id': oid, 'user_id': int(user_id)}) + except Exception: + return None + return doc if isinstance(doc, dict) else None + + +@note_boards_bp.route('', methods=['GET']) +@note_boards_bp.route('/', methods=['GET']) +@require_auth +def list_user_boards(): + """רשימת הלוחות, עם מונה פתקים לכל אחד. + + בדרך: יצירת לוח ברירת המחדל אם חסר, וסריקת פתקים יתומים. שתי הפעולות + אידמפוטנטיות, וזו הנקודה הטבעית להריץ אותן — היא נטענת בכל כניסה לעמוד. + """ + try: + _ensure_board_indexes() + user_id = int(session['user_id']) + db = get_db() + + default_id = ensure_default_board(db, user_id) + boards = list_boards(db, user_id) + known_ids = [str(b.get('_id')) for b in boards] + + reattached = 0 + if default_id: + reattached = reattach_orphan_notes(db, user_id, known_ids, default_id) + if reattached: + emit_event( + "note_board_orphans_reattached", + severity="anomaly", + user_id=int(user_id), + count=int(reattached), + ) + + # מונה אחד לכל הלוחות בשאילתה אחת, ולא ``count_documents`` פר לוח. + # עם עשרים לוחות זה עשרים סיבובים למסד בכל טעינת עמוד. + counts = None + try: + counts = { + str(row.get('_id') or ''): int(row.get('n') or 0) + for row in db.sticky_notes.aggregate([ + {'$match': {'user_id': user_id, 'board_id': {'$nin': [None, '']}}}, + {'$group': {'_id': '$board_id', 'n': {'$sum': 1}}}, + ]) + } + except Exception: + # ``None`` ולא אפס: המונה מושמט מהתשובה, וה-UI מבחין בין + # "לוח ריק" לבין "לא הצלחנו לספור". + counts = None + + items = [ + _board_response(board, counts.get(str(board.get('_id')), 0) if counts is not None else None) + for board in boards + ] + + resp = jsonify({'ok': True, 'boards': items, 'count': len(items), 'reattached': reattached}) + try: + resp.headers['Cache-Control'] = 'no-store' + except Exception: + pass + return resp + except Exception as e: + try: + emit_event("note_boards_list_error", severity="anomaly", error=str(e)) + except Exception: + pass + return jsonify({'ok': False, 'error': 'Failed to list boards'}), 500 + + +@note_boards_bp.route('', methods=['POST']) +@note_boards_bp.route('/', methods=['POST']) +@require_auth +def create_board(): + """לוח חדש.""" + try: + _ensure_board_indexes() + user_id = int(session['user_id']) + db = get_db() + data = request.get_json(silent=True) or {} + name = normalize_board_name(data.get('name')) + + try: + existing = int(db.note_boards.count_documents({'user_id': user_id})) + except Exception: + # ספירה שנכשלה אינה "אין לוחות". אותה הכרעה כמו בתקרת הפתקים. + return jsonify({'ok': False, 'error': 'board_quota_unknown'}), 409 + if existing >= MAX_BOARDS_PER_USER: + return jsonify({'ok': False, 'error': 'board_quota_exceeded'}), 409 + + # ``order`` נגזר מהמקסימום הקיים ולא מהספירה: אחרי מחיקת לוח + # מאמצע הרשימה, ספירה הייתה מייצרת ערך שכבר תפוס ושני לוחות היו + # מתחרים על אותו מקום במיון. + try: + top = max( + (int(b.get('order') or 0) for b in db.note_boards.find({'user_id': user_id}, {'order': 1})), + default=-1, + ) + except Exception: + top = existing - 1 + now = datetime.now(timezone.utc) + doc = { + 'user_id': user_id, + 'name': name, + 'is_default': False, + 'order': top + 1, + 'created_at': now, + 'updated_at': now, + } + res = db.note_boards.insert_one(doc) + new_id = getattr(res, 'inserted_id', None) + + # אימות בקריאה חוזרת. ``inserted_id`` שחזר אינו מוכיח שהמסמך קיים, + # ולוח שאינו קיים הוא לוח שכל פתק שינחת עליו ייעלם מהממשק. + created = db.note_boards.find_one({'_id': new_id, 'user_id': user_id}) if new_id is not None else None + if not created: + emit_event("note_board_create_not_applied", severity="error", user_id=int(user_id)) + return jsonify({'ok': False, 'error': 'board_create_not_applied'}), 500 + + return jsonify({'ok': True, 'board': _board_response(created, 0)}), 201 + except Exception as e: + try: + emit_event("note_boards_create_error", severity="anomaly", error=str(e)) + except Exception: + pass + return jsonify({'ok': False, 'error': 'Failed to create board'}), 500 + + +@note_boards_bp.route('/', methods=['PATCH']) +@require_auth +def rename_board(board_id: str): + """שינוי שם. מותר גם ללוח ברירת המחדל — הזיהוי שלו הוא ``is_default``.""" + try: + _ensure_board_indexes() + user_id = int(session['user_id']) + db = get_db() + board = _owned_board(db, user_id, board_id) + if not board: + return jsonify({'ok': False, 'error': 'board_not_found'}), 404 + + data = request.get_json(silent=True) or {} + if 'name' not in data: + return jsonify({'ok': False, 'error': 'no_fields_to_update'}), 400 + name = normalize_board_name(data.get('name')) + + db.note_boards.update_one( + {'_id': board['_id'], 'user_id': user_id}, + {'$set': {'name': name, 'updated_at': datetime.now(timezone.utc)}}, + ) + fresh = db.note_boards.find_one({'_id': board['_id'], 'user_id': user_id}) + if not fresh or str(fresh.get('name') or '') != name: + return jsonify({'ok': False, 'error': 'rename_not_applied'}), 409 + return jsonify({'ok': True, 'board': _board_response(fresh)}) + except Exception as e: + try: + emit_event("note_boards_rename_error", severity="anomaly", error=str(e)) + except Exception: + pass + return jsonify({'ok': False, 'error': 'Failed to rename board'}), 500 + + +@note_boards_bp.route('/', methods=['DELETE']) +@require_auth +def delete_board(board_id: str): + """מחיקת לוח, אחרי העברת הפתקים שעליו ללוח ברירת המחדל. + + זו הפעולה היחידה בפיצ'ר שיכולה לאבד נתונים, ולכן הסדר כאן אינו מקרי: + + 1. לוח ברירת מחדל — חסום. באוספים אין חסימה כזו בכלל, וזו סטייה מכוונת: + מחיקת אוסף מאבדת סידור, מחיקת לוח מאבדת את המקום היחיד של הפתק. + 2. ``ensure_default_board`` — ולא הנחה שהיעד קיים. + 3. ספירה **לפני** ההעברה, כי ``modified_count`` מדווח בחסר כשמסמך כבר + היה בערך היעד, וממילא אסור להסתמך עליו. + 4. אחרי ההעברה — **ספירה חוזרת**. אם נשארו פתקים, עוצרים ולא מוחקים. + הלוח שורד, אפשר לנסות שוב, ואף פתק לא מתייתם. + """ + try: + _ensure_board_indexes() + user_id = int(session['user_id']) + db = get_db() + board = _owned_board(db, user_id, board_id) + if not board: + return jsonify({'ok': False, 'error': 'board_not_found'}), 404 + if bool(board.get('is_default')): + return jsonify({'ok': False, 'error': 'cannot_delete_default'}), 409 + + default_id = ensure_default_board(db, user_id) + if not default_id: + return jsonify({'ok': False, 'error': 'default_board_unavailable'}), 500 + + src = str(board.get('_id')) + src_query = {'user_id': user_id, 'board_id': src} + try: + moved_expected = int(db.sticky_notes.count_documents(src_query)) + except Exception: + return jsonify({'ok': False, 'error': 'notes_count_failed'}), 500 + + if moved_expected: + db.sticky_notes.update_many( + src_query, + {'$set': {'board_id': str(default_id), 'updated_at': datetime.now(timezone.utc)}}, + ) + try: + remaining = int(db.sticky_notes.count_documents(src_query)) + except Exception: + return jsonify({'ok': False, 'error': 'notes_count_failed'}), 500 + if remaining: + emit_event( + "note_board_delete_aborted", + severity="error", + user_id=int(user_id), + board_id=src, + remaining=int(remaining), + ) + return jsonify({ + 'ok': False, + 'error': 'notes_move_incomplete', + 'remaining': remaining, + }), 500 + + db.note_boards.delete_one({'_id': board['_id'], 'user_id': user_id}) + still_there = db.note_boards.find_one({'_id': board['_id'], 'user_id': user_id}) + if still_there: + return jsonify({'ok': False, 'error': 'board_delete_not_applied'}), 500 + + return jsonify({ + 'ok': True, + 'moved': moved_expected, + 'target_board_id': str(default_id), + }) + except Exception as e: + try: + emit_event("note_boards_delete_error", severity="anomaly", error=str(e)) + except Exception: + pass + return jsonify({'ok': False, 'error': 'Failed to delete board'}), 500 diff --git a/webapp/push_api.py b/webapp/push_api.py index 6ce1494b9..d9e62b8b8 100644 --- a/webapp/push_api.py +++ b/webapp/push_api.py @@ -600,6 +600,7 @@ def _send_due_once(max_users: int = 100, max_per_user: int = 10) -> None: "user_id": 1, "note_id": 1, "file_id": 1, + "board_id": 1, "remind_at": 1, "last_push_success_at": 1, } @@ -808,6 +809,7 @@ def _send_for_user(user_id: int | str, reminders: list[dict]) -> None: body_text = _coerce_preview(db, r) note_id_str = str(r.get("note_id") or "") file_id_str = str(r.get("file_id") or "") + board_id_str = str(r.get("board_id") or "") # Payload format: notification object at top level (FCM standard) # data object for custom handling in SW @@ -831,6 +833,7 @@ def _send_for_user(user_id: int | str, reminders: list[dict]) -> None: "type": "reminder", "note_id": note_id_str, "file_id": file_id_str, + "board_id": board_id_str, "title": title_text, "body": body_text, }, diff --git a/webapp/static/css/note-boards.css b/webapp/static/css/note-boards.css new file mode 100644 index 000000000..8b66f8571 --- /dev/null +++ b/webapp/static/css/note-boards.css @@ -0,0 +1,195 @@ +/* לוחות פתקים — רשימה ומשטח. + * + * ``hidden`` מול ``display`` של המחבר: הדפדפן מגדיר ``[hidden]{display:none}`` + * במקור ה-user-agent, וכל הכרזת ``display`` של המחבר מנצחת אותו — גם בלי + * ספציפיות גבוהה. ``.board-empty`` הגדיר ``display:flex``, ולכן הכיתוב + * "הלוח ריק" הוצג גם כשהיו פתקים על הלוח. הכלל הבא מחזיר ל-``hidden`` את + * המשמעות שלו בכל הקובץ, ולא רק במקום היחיד שנתפס. + * + * רשת הנקודות היא CSS טהור בכוונה: אפס נכסים, אפס ווטרמארק, ואפס בייטים + * להוריד. היא גם מקבלת את צבע ערכת הנושא הפעילה אוטומטית, מה שאיור סטטי + * לא היה עושה. + */ + +[hidden] { display: none !important; } + +/* ---------- רשימת הלוחות ---------- */ + +.boards-page { + max-width: 1100px; + margin: 0 auto; + padding: 1.5rem 1rem 4rem; +} + +.boards-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1.5rem; +} + +.boards-header h1 { + margin: 0; + font-size: 1.6rem; +} + +.boards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 1rem; +} + +.board-card { + padding: 1.1rem 1.2rem; + border-radius: 14px; + background: var(--card-bg, rgba(255, 255, 255, 0.9)); + border: 1px solid var(--glass-border, rgba(0, 0, 0, 0.08)); + color: var(--text-primary, #1f2933); + text-decoration: none; + transition: transform .15s ease, box-shadow .2s ease; +} + +.board-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(0, 0, 0, .12); +} + +.board-card-link { + display: block; + color: inherit; + text-decoration: none; +} + +.board-card-title { + display: flex; + align-items: center; + gap: .5rem; + font-weight: 600; + margin-bottom: .35rem; +} + +.board-card-count { + font-size: .85rem; + color: var(--text-muted, #667); +} + +.board-default-badge { + font-size: .7rem; + padding: .1rem .45rem; + border-radius: 999px; + background: var(--primary, #667eea); + color: #fff; + font-weight: 500; +} + +.board-card-actions { + display: flex; + gap: .4rem; + margin-top: .75rem; +} + +.board-action { + border: 1px solid var(--glass-border, rgba(0, 0, 0, .12)); + background: var(--bg-secondary, #f7fafc); + color: var(--text-primary, inherit); + border-radius: 8px; + padding: .25rem .6rem; + font-size: .8rem; + cursor: pointer; +} + +.board-action:hover { background: var(--glass-hover, rgba(103, 126, 234, .1)); } +.board-action[disabled] { opacity: .45; cursor: not-allowed; } + +/* ---------- משטח הלוח ---------- */ + +/* עמוד המשטח מנצל את כל הרוחב ורוב הגובה. ברשימת הלוחות עמודה צרה + היא הנכונה, אבל משטח עבודה שמוגבל ל-1100px משאיר שוליים מתים משני + הצדדים ועוד 4rem ריקים מתחתיו. */ +.boards-page.is-board-surface { + max-width: none; + padding: .75rem .75rem 1rem; +} + +.board-surface-wrap { + position: relative; +} + +.board-surface { + position: relative; /* פתק במצב surface הוא absolute יחסית לזה */ + min-height: calc(100vh - 130px); + border-radius: 16px; + overflow: auto; + border: 1px solid var(--glass-border, rgba(0, 0, 0, .08)); + + /* רשת הנקודות. השקיפות נמוכה במכוון — בשיעור כזה זה כבר לא תמונה אלא + מרקם, כמו נייר משבצות מתחת לפתק אמיתי. */ + background-color: var(--bg-primary, #fff); + background-image: radial-gradient( + circle, + var(--board-dot, rgba(103, 126, 234, .22)) 1px, + transparent 1px + ); + background-size: 24px 24px; + /* מוצמד למשטח, כך שהנקודות זזות איתו בגלילה ולא "מחליקות" מתחתיו */ + background-attachment: local; +} + +.board-empty { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: .75rem; + color: var(--text-muted, #667); + pointer-events: none; +} + +.board-empty button { pointer-events: auto; } + +.board-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: .75rem; +} + +.board-title-input { + font-size: 1.3rem; + font-weight: 600; + border: 1px solid transparent; + border-radius: 8px; + padding: .25rem .5rem; + background: transparent; + color: var(--text-primary, inherit); + max-width: 100%; +} + +.board-title-input:hover { border-color: var(--glass-border, rgba(0, 0, 0, .12)); } +.board-title-input:focus { border-color: var(--primary, #667eea); outline: none; } + +.board-msg { + padding: .5rem .75rem; + border-radius: 8px; + font-size: .9rem; + margin-bottom: .75rem; +} + +.board-msg.is-error { background: rgba(220, 53, 69, .12); color: #b02a37; } +.board-msg.is-info { background: rgba(103, 126, 234, .12); } + +@media (prefers-color-scheme: dark) { + .board-surface { + background-image: radial-gradient( + circle, + var(--board-dot, rgba(255, 255, 255, .13)) 1px, + transparent 1px + ); + } +} diff --git a/webapp/static/css/sticky-notes.css b/webapp/static/css/sticky-notes.css index 9d1879c8a..c47620bca 100644 --- a/webapp/static/css/sticky-notes.css +++ b/webapp/static/css/sticky-notes.css @@ -24,8 +24,11 @@ background: rgba(0,0,0,.06); } .sticky-note-drag{ width: 100%; height: 16px; cursor: move; touch-action: none; } -.sticky-note-actions{ display:flex; gap:6px; } -.sticky-note-btn{ border:none; background: transparent; cursor: pointer; font-size: 16px; transition: background-color .18s ease, color .18s ease; } +/* מרווח בין הכפתורים: הם קטנים, וצמודים זה לזה קשה לפגוע בהם באצבע. + ההגדלה היא במרווח ובריפוד אופקי בלבד — גובה הכותרת לא משתנה, ולכן + ``calc(100% - 42px)`` של גוף הפתק נשאר נכון. */ +.sticky-note-actions{ display:flex; gap:10px; } +.sticky-note-btn{ border:none; background: transparent; cursor: pointer; font-size: 16px; padding: 0 3px; min-width: 22px; transition: background-color .18s ease, color .18s ease; } .sticky-note-pin{ opacity:.8; } .sticky-note-pin:hover{ opacity:1; } .sticky-note-pin.is-active{ @@ -72,3 +75,67 @@ @media print{ .sticky-note, .sticky-note-fab{ display:none !important; } } + +/* ---------- צ'קבוקסים בתוך פתק ---------- */ + +/* ``flex: 1`` לא עשה כלום — ``.sticky-note`` אינו קונטיינר flex — ולכן + לתצוגה לא היה גובה משלה כלל. עכשיו היא בדיוק אותו ארגז כמו ה-textarea + שהיא מחליפה, כולל גלילה פנימית: פתק שגדל לבד מזיז את מה שסביבו. */ +.sticky-note-tasks { + width: 100%; + height: calc(100% - 42px); + overflow-y: auto; + padding: 8px; + font-size: 14px; + line-height: 1.35; + color: #222; + cursor: text; /* הרמז שאפשר ללחוץ ולחזור לעריכה */ + white-space: pre-wrap; + word-break: break-word; +} + +.sticky-note-tasks[hidden] { display: none; } +.sticky-note.is-minimized .sticky-note-tasks{ display:none; } + +/* שורה שאינה משימה — הטקסט הרגיל של הפתק. היא נשארת גלויה, וזה כל + ההבדל בין "יש כאן צ'קבוקס" לבין "כל שאר הפתק נעלם". */ +.sticky-task-line { min-height: 1.35em; } + +.sticky-task { + display: flex; + align-items: flex-start; + gap: .4rem; + padding: .1rem 0; +} + +.sticky-task-box { margin-top: .25rem; flex: 0 0 auto; cursor: pointer; } + +.sticky-task-box:checked + .sticky-task-text { + text-decoration: line-through; + opacity: .6; +} + +.sticky-task-box[disabled] { cursor: progress; } + +.sticky-note.is-task-pending { opacity: .85; } + +/* חיווי כשל — כתיבה שלא נקלטה חייבת להיראות, לא להיעלם בשקט */ +.sticky-note.has-task-error { + outline: 2px solid #dc3545; + outline-offset: 1px; +} + +/* חיווי אורך — התקרה נאכפת בשרת בחיתוך שקט, ולכן היא חייבת להיראות כאן */ +.sticky-note-warn { + position: absolute; left: 0; right: 0; bottom: 0; + padding: 4px 8px; + font-size: 12px; line-height: 1.3; + background: #dc3545; color: #fff; +} + +/* כשל שמירה — עד היום הוא היה ב-console בלבד */ +.sticky-note.has-save-error, +.sticky-note.has-length-error { + outline: 2px solid #dc3545; + outline-offset: 1px; +} diff --git a/webapp/static/js/sticky-notes.js b/webapp/static/js/sticky-notes.js index 81db1dd5f..87e207d6e 100644 --- a/webapp/static/js/sticky-notes.js +++ b/webapp/static/js/sticky-notes.js @@ -135,12 +135,55 @@ // לבין פתק ישן שעדיין לא עבר מיגרציה ל־[data-source-line]. const FLOATING_SENTINEL = '__floating__'; const AUTO_SAVE_DEBOUNCE_MS = 500; + //: תקרת התוכן שהשרת מקבל (``_sanitize_text``/``_decode_content_b64`` + //: ב-``webapp/sticky_notes_api.py``, ומתועדת ב-``docs/user/sticky_notes.rst``). + //: מעבר לזה ה-PUT נדחה ב-400, והלקוח רק החזיר את השינוי ל-pending — כלומר + //: הדבקה של מסמך ארוך פשוט לא נשמרה, בלי שום סימן למשתמש. + const MAX_NOTE_CHARS = 5000; + //: שורת משימה: ``- [ ] טקסט`` או ``* [x] טקסט``, עם הזחה כלשהי. + //: אותה תבנית בדיוק כמו ``_TASK_RE`` ב-``sticky_notes_tasks.py`` — שם + //: מתבצעת הכתיבה, וכאן רק התצוגה. סטייה ביניהן הייתה מזיזה סידורים. + const TASK_LINE_RE = /^([ \t]*[-*][ \t]\[)([ xX])(\].*)$/; const AUTO_SAVE_FORCE_INTERVAL_MS = 3500; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24h class StickyNotesManager { - constructor(fileId){ - this.fileId = fileId; + // מקבל או מזהה קובץ (הצורה ההיסטורית, שנשארת עובדת ביט-זהה), או אובייקט + // תצורה עבור לוח: + // new StickyNotesManager('') + // new StickyNotesManager({ board: '', container: el }) + // + // התאימות לאחור אינה נוחות — היא מה שמגן על מסלול הקובץ מרגרסיה. + // md_preview.html קורא בצורה הראשונה ולא משתנה כלל. + constructor(fileIdOrOptions){ + const opts = (typeof fileIdOrOptions === 'string' || fileIdOrOptions == null) + ? { file: fileIdOrOptions } + : (fileIdOrOptions || {}); + + this.boardId = opts.board ? String(opts.board) : null; + this.fileId = this.boardId ? null : (opts.file != null ? opts.file : null); + if (!this.boardId && this.fileId == null) { + throw new Error('StickyNotesManager: חסר יעד — file או board'); + } + + // הפתקים נכנסים לקונטיינר הזה. בקובץ זה ה-body, כפי שהיה; בלוח זה + // משטח הלוח, כי פתק "מעוגן ללוח" הוא absolute יחסית אליו ולא למסמך. + this.container = opts.container || document.body; + // המקור שממנו נגזרות שורות המקור לעיגון. בלוח אין כזה, וכל מסלול + // ה-anchored מנוטרל — במקום ליפול על null בשקט בשמונה מקומות. + // בקובץ ברירת המחדל היא ``#md-content``, כפי שהיה לפני + // הפרמטריזציה. בלוח אין שורות מקור כלל, ולכן ברירת המחדל היא ריקה + // — גם אם במקרה קיים ``#md-content`` בעמוד. + // + // (השורה הזו התרוקנה פעם בהחלפה גורפת של getElementById, ופתקי + // קובץ איבדו את העיגון כולו, בשקט. יש עליה בדיקה.) + this._anchorHost = ('anchorHost' in opts) + ? opts.anchorHost + : (this.boardId ? null : document.getElementById('md-content')); + this._scopeUrl = this.boardId + ? `/api/sticky-notes/board/${encodeURIComponent(this.boardId)}` + : `/api/sticky-notes/${encodeURIComponent(this.fileId)}`; + this.notes = new Map(); this._saveDebounced = debounce(this._performSaveBatch.bind(this), AUTO_SAVE_DEBOUNCE_MS); this._pending = new Map(); @@ -148,12 +191,19 @@ this._autoFlushTimer = null; this._autoFlushBusy = false; this._lineIndex = new Map(); // lineNumber -> pageY - this._cacheKey = `sticky-notes:${String(fileId)}`; + // מפתח נפרד ללוח: בלעדיו לוח וקובץ עם אותה מחרוזת מזהה היו חולקים + // את אותו קאש ב-localStorage ומרנדרים זה את הפתקים של זה. + this._cacheKey = this.boardId + ? `sticky-notes:board:${this.boardId}` + : `sticky-notes:${String(this.fileId)}`; this._renderedFromCache = false; this._pendingSeq = new Map(); // noteId -> monotonic version of pending edits this._init(); } + // האם מסלול העיגון לשורת מקור רלוונטי בכלל. בלוח — לא. + get _hasAnchorHost(){ return !!this._anchorHost; } + async _init(){ try { this._rebuildLineIndex(); @@ -177,7 +227,7 @@ async loadNotes(){ try { - const url = `/api/sticky-notes/${encodeURIComponent(this.fileId)}?_=${Date.now()}`; + const url = `${this._scopeUrl}?_=${Date.now()}`; const resp = await fetch(url, { cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }); const data = await resp.json(); if (!data || data.ok === false) return; @@ -193,7 +243,7 @@ const btn = createEl('button', 'sticky-note-fab', { title: 'הוסף פתק' }); btn.textContent = '+'; btn.addEventListener('click', () => this.createNote()); - document.body.appendChild(btn); + this.container.appendChild(btn); } _setupLifecycleGuards(){ @@ -239,7 +289,7 @@ _nearestAnchor(){ try { - const container = document.getElementById('md-content') || document.body; + const container = (this._anchorHost || document.body); const headers = Array.from(container.querySelectorAll('h1, h2, h3, h4, h5, h6')); if (!headers.length) return null; const scroll = getScrollOffsets(); @@ -267,16 +317,26 @@ // עיגון אוטומטי לאלמנט הקרוב עם [data-source-line] (רלוונטי ל־MD preview). // הפתק יהפוך ל־anchored (position:absolute, doc-Y) ולכן יזוז יחד עם התוכן // כאשר ::: details נפתח/נסגר, תמונות נטענות, וכו'. + // בלוח אין שורות מקור, ולכן החיפוש כולו מדולג — ולא "רץ ומחזיר null". let autoAnchorLine = null; - try { - const nearest = this._findNearestSourceLineElement(noteY); - if (nearest) { - const raw = parseInt(nearest.getAttribute('data-source-line'), 10); - if (Number.isFinite(raw) && raw >= 0) autoAnchorLine = raw + 1; - } - } catch(_) {} + if (!this.boardId) { + try { + const nearest = this._findNearestSourceLineElement(noteY); + if (nearest) { + const raw = parseInt(nearest.getAttribute('data-source-line'), 10); + if (Number.isFinite(raw) && raw >= 0) autoAnchorLine = raw + 1; + } + } catch(_) {} + } - const payload = { + const payload = this.boardId ? { + content: '', + position: { x: noteX, y: noteY }, + size: { width: isMobile ? 200 : 260, height: isMobile ? 160 : 200 }, + color: '#FFFFCC', + // ברירת המחדל בלוח: מוצמד למשטח. הלוח *הוא* המשטח. + mode: 'surface' + } : { content: '', // הנחתה קלה למובייל כדי למנוע קפיצה עם הופעת מקלדת position: { x: noteX, y: noteY }, @@ -287,12 +347,13 @@ anchor_id: '', anchor_text: undefined }; - const resp = await fetch(`/api/sticky-notes/${encodeURIComponent(this.fileId)}`, { + const resp = await fetch(this._scopeUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(withContentB64(payload)) }); const data = await resp.json(); if (!data || data.ok === false) return; - const note = Object.assign({ id: data.id, file_id: this.fileId }, payload, { is_minimized: false, created_at: null, updated_at: null }); + const target = this.boardId ? { board_id: this.boardId } : { file_id: this.fileId }; + const note = Object.assign({ id: data.id }, target, payload, { is_minimized: false, created_at: null, updated_at: null }); this._renderNote(note, true); } catch(e){ console.error('createNote error', e); } } @@ -345,13 +406,34 @@ const textarea = createEl('textarea', 'sticky-note-content'); textarea.value = note.content || ''; + textarea.addEventListener('focus', () => this._syncTaskView(el, { editing: true })); + textarea.addEventListener('blur', () => this._syncTaskView(el)); const resizer = createEl('div', 'sticky-note-resize'); + // תצוגת המשימות. מוצגת רק כשיש צ'קבוקסים בתוכן ורק כשה-textarea + // אינו בפוקוס — כך שהעריכה נשארת בדיוק כפי שהייתה. + const taskView = createEl('div', 'sticky-note-tasks'); + taskView.addEventListener('change', (ev) => { + const box = ev.target; + if (box && box.type === 'checkbox') this._onTaskToggle(el, box); + }); + // לחיצה בכל מקום בתצוגה שאינו התיבה עצמה מחזירה לעריכה, בשורה + // שנלחצה. זו הדרך היחידה החוצה מהתצוגה — בלעדיה פתק עם צ'קבוקס + // אחד הופך לקריאה-בלבד לתמיד. + taskView.addEventListener('click', (ev) => { + const t = ev.target; + if (t && t.classList && t.classList.contains('sticky-task-box')) return; + const row = (t && t.closest) ? t.closest('.sticky-task-line') : null; + const offset = row ? parseInt(row.dataset.charOffset, 10) : NaN; + this._enterEditAt(el, Number.isFinite(offset) ? offset : null); + }); + el.appendChild(header); el.appendChild(textarea); + el.appendChild(taskView); el.appendChild(resizer); - document.body.appendChild(el); + this.container.appendChild(el); if (note.is_minimized) el.classList.add('is-minimized'); if (isPinnedInitial) el.classList.add('is-pinned'); @@ -368,6 +450,7 @@ }); textarea.addEventListener('input', () => { + this._checkContentLength(el, textarea.value); this._queueSave(el, { content: textarea.value }); }); textarea.addEventListener('keydown', (ev) => { @@ -413,6 +496,9 @@ if (focus) try { textarea.focus(); } catch(_) {} if (note.updated_at) { try { el.dataset.updatedAt = String(note.updated_at); } catch(_) {} } this.notes.set(note.id, { el, data: note }); + // תצוגת המשימות נבנית מהתוכן שנטען. אם אין צ'קבוקסים היא נשארת מוסתרת + // וה-textarea מוצג כרגיל — כלומר פתק רגיל אינו משתנה כלל. + this._syncTaskView(el); this._applyPositionMode(el, note, { initial: true }); this._reflowWithinViewport(el); this._updateAnchoredNotePosition(el, note); @@ -432,7 +518,7 @@ || (Number.isInteger(note.line_start) && note.line_start > 0) || (note.anchor_id && note.anchor_id !== ''); if (!hasAnchor) { - const mdEl = document.getElementById('md-content'); + const mdEl = this._anchorHost; if (mdEl && mdEl.querySelector('[data-source-line]')) { const targetY = (typeof note.position?.y === 'number') ? note.position.y : null; if (Number.isFinite(targetY)) { @@ -542,11 +628,67 @@ return false; } - _notePayloadFromEl(el){ - const rect = el.getBoundingClientRect(); + // הנקודה שממנה נמדדים ``position.x/y`` של פתק, בקואורדינטות של אזור + // התצוגה. + // + // **זה שורש הבאג "הפתק זז קצת למטה בכל רענון".** השמירה חישבה תמיד + // קואורדינטות של המסמך (``rect + scroll``), אבל פתק במצב ``surface`` + // ממוקם ``absolute`` בתוך הקונטיינר — כלומר הדפדפן קורא בדיוק את + // אותם מספרים ביחס למשטח הלוח. בקובץ שני המרחבים חופפים, כי + // הקונטיינר הוא ה-body והוא לא ממוקם; בלוח המשטח מתחיל כמה מאות + // פיקסלים מתחת לראש המסמך, והפתק ירד בדיוק בהפרש הזה בכל שמירה. + // + // מסלול הקובץ יוצא ביט-זהה: כל ענף שאינו קונטיינר ממוקם מחזיר + // ``-scroll``, ואז ``rect.left - (-scroll.x)`` הוא בדיוק החישוב הישן. + _positionOrigin(el, mode){ const scroll = getScrollOffsets(); - const x = Math.round(rect.left + scroll.x); - const y = Math.round(rect.top + scroll.y); + const docOrigin = { x: -scroll.x, y: -scroll.y }; + let resolved = mode; + if (!resolved) { + const entry = this._getEntry(el); + resolved = this._resolveMode(entry ? entry.data : null); + } + // ``screen`` הוא ``position: fixed`` ונשמר במרחב המסמך, כפי שהיה. + if (resolved === 'screen') return docOrigin; + const parent = this.container; + if (!parent || parent === document.body || parent === document.documentElement) return docOrigin; + try { + const r = parent.getBoundingClientRect(); + // קופסת ה-padding של האב הממוקם, לפני הגלילה הפנימית שלו — + // בדיוק המרחב ש-``left``/``top`` של ילד ``absolute`` נמדדים בו. + return { + x: r.left + (parent.clientLeft || 0) - (parent.scrollLeft || 0), + y: r.top + (parent.clientTop || 0) - (parent.scrollTop || 0) + }; + } catch(_) { return docOrigin; } + } + + // פתק לא יוצא מגבולות המשטח. + // + // בלי זה החלק העליון של הפתק — שהוא גם ידית הגרירה — יכול לעלות מעל + // הקצה, ואז אין שום דרך להחזיר אותו: הגרירה היא לחיצה על הכותרת, + // והכותרת מחוץ למסך. + _clampToSurface(el, x, y, note){ + try { + const parent = this.container; + if (!parent || typeof parent.clientWidth !== 'number' || parent.clientWidth <= 0) { + return { x, y }; + } + let w = (note && note.size && typeof note.size.width === 'number') ? note.size.width : 0; + if (!w) { try { w = Math.round(el.getBoundingClientRect().width) || 0; } catch(_) { w = 0; } } + if (!w) w = 260; + const maxX = Math.max(0, parent.clientWidth - w); + // ציר ה-Y נחסם מלמטה בלבד: המשטח גליל, וגרירה כלפי מטה מגדילה + // אותו — כלומר פתק "רחוק" עדיין נגיש, בניגוד לפתק מעל הקצה. + return { x: clamp(Math.round(x), 0, maxX), y: Math.max(0, Math.round(y)) }; + } catch(_) { return { x, y }; } + } + + _notePayloadFromEl(el, mode){ + const rect = el.getBoundingClientRect(); + const origin = this._positionOrigin(el, mode); + const x = Math.round(rect.left - origin.x); + const y = Math.round(rect.top - origin.y); const w = Math.round(rect.width); const h = Math.round(rect.height); const payload = { position: { x, y }, size: { width: w, height: h } }; @@ -560,15 +702,238 @@ return this.notes.get(id) || null; } - _updatePinButtonState(el, isPinned){ + _updatePinButtonState(el, isActive){ const pinBtn = el ? el.querySelector('.sticky-note-pin') : null; if (!pinBtn) return; - const active = !!isPinned; + const active = !!isActive; pinBtn.classList.toggle('is-active', active); pinBtn.setAttribute('aria-pressed', active ? 'true' : 'false'); - pinBtn.title = active ? 'בטל נעיצה' : 'נעץ פתק למסמך'; + if (this.boardId) { + // בלוח הכפתור מחליף בין "יושב על הלוח" (ברירת המחדל, כבוי) לבין + // "צף מול המסך" (דלוק). "נעיצה למסמך" היא מושג של קובץ ואין לה + // מובן כאן — ולכן גם אין סיבה שהכפתור יידלק כבר בפתיחה. + pinBtn.title = active ? 'החזר את הפתק ללוח' : 'הצמד את הפתק למסך'; + } else { + pinBtn.title = active ? 'בטל נעיצה' : 'נעץ פתק למסמך'; + } } + // ----- צ'קבוקסים ----- + // + // שורה בצורת ``- [ ] טקסט`` היא משימה. לחיצה על התיבה היא **כתיבה + // למסד**, לא שינוי תצוגה — ולכן כל הזרימה כאן בנויה סביב זה + // שהתצוגה לא תשקר: אם הכתיבה לא נקלטה, הסימון חוזר אחורה ומוצגת + // שגיאה. כשל שקט הוא בדיוק מה שהאפיון אוסר. + + // בונה את התצוגה מ**כל** השורות, לא רק משורות המשימה. + // + // הגרסה הראשונה רינדרה רק את שורות הצ'קבוקס והסתירה את ה-textarea. + // התוצאה: פתק עם 400 שורות ושורת משימה אחת נראה כאילו נמחק — כל השאר + // פשוט לא הוצג, ולא הייתה שום דרך לחזור לעריכה. התוכן היה בטקסטאריה + // כל הזמן (נמדד בדפדפן: 5,509 תווים לפני ואחרי, אפס בקשות כתיבה), + // אבל מבחינת המשתמש זה אובדן מידע — ומבחינת התוצאה זה נכון. + // + // **החוק שמעל הפונקציה הזו: רינדור הוא חד-כיווני.** ``content → + // HTML``, לעולם לא חזרה. היחידים שרשאים לכתוב ל-``content`` הם + // ה-textarea עצמו וראוט ``/task`` (סידורי + מצב). על הכלל הזה יש + // בדיקה — ראו ``tests/sticky-notes-target.test.js``. + _syncTaskView(el, opts){ + try { + const view = el.querySelector('.sticky-note-tasks'); + const textarea = el.querySelector('.sticky-note-content'); + if (!view || !textarea) return; + const editing = !!(opts && opts.editing); + const lines = String(textarea.value == null ? '' : textarea.value).split('\n'); + const hasTask = lines.some((line) => TASK_LINE_RE.test(line)); + if (editing || !hasTask) { + view.hidden = true; + textarea.hidden = false; + return; + } + view.textContent = ''; + let taskIndex = 0; + let charOffset = 0; + lines.forEach((line) => { + const m = TASK_LINE_RE.exec(line); + const row = createEl('div', 'sticky-task-line'); + // מיקום התו הראשון של השורה בתוך התוכן — כדי שלחיצה תחזיר + // לעריכה בדיוק בשורה שנלחצה, ולא בתחילת הפתק. + row.dataset.charOffset = String(charOffset); + if (m) { + row.classList.add('sticky-task'); + const box = createEl('input', 'sticky-task-box'); + box.type = 'checkbox'; + box.checked = m[2].toLowerCase() === 'x'; + box.dataset.taskIndex = String(taskIndex); + taskIndex += 1; + const span = createEl('span', 'sticky-task-text'); + span.textContent = m[3].slice(1).trim(); + // התיבה והטקסט הם אחים ולא ``