diff --git a/docs/user/sticky_notes.rst b/docs/user/sticky_notes.rst index b85cc4a91..80437090b 100644 --- a/docs/user/sticky_notes.rst +++ b/docs/user/sticky_notes.rst @@ -70,6 +70,35 @@ בלוח אין עיגון לשורת מקור, כי אין טקסט להיצמד אליו — ולכן גם **בעיית הפתק המיותם אינה קיימת שם**. שום דבר לא זז מתחת לפתק על לוח. +שם, ביטול והעתקה +~~~~~~~~~~~~~~~~~ +בשורת הכפתורים של כל פתק — בקובץ ובלוח כאחד — יש שדה **שם** ושני כפתורים נוספים. + +השם מוצג בצד השני של שורת הכפתורים, ו**נשאר גלוי גם כשהפתק ממוזער** — כך שהשורה הריקה של הכותרת מקבלת משמעות. שם ריק פירושו "אין שם", והשדה נמחק מהמסמך. + +.. important:: + **שם מזהה פתק אחד בתוך לוח**, בדיוק כמו ששם קובץ מזהה קובץ אחד. שני פתקים באותו שם באותו לוח נדחים ב-``duplicate_title``. אותו שם בלוח אחר, או אצל משתמש אחר, מותר. **במצב התקין** האכיפה היא של המסד — אינדקס ייחודי-חלקי — ולא של הקוד, וכך היא גם חסינה למרוץ בין שתי בקשות מקבילות. יש מצב אחד ויחיד שבו קיים גיבוי בקוד, והוא מתואר בסוף הקטע. + + **הייחודיות היא בתוך לוח בלבד.** לפתקים שיושבים על קבצים אין שם לוח, והם אינם כפופים לאילוץ — שני קבצים יכולים להחזיק פתק באותו שם, וגם אותו קובץ יכול. זה מכוון: בעולם הקבצים אותו שם פירושו גרסה נוספת, לא התנגשות. + + האכיפה מוגדרת במקום אחד, ``ONE_TITLE_PER_BOARD_INDEX`` ב-``sticky_notes_target``, ונבנית בשני השירותים (הוובאפ וה-MCP). שם האינדקס נושא מספר גרסה (``one_title_per_board_v2``), וזה מה שמאפשר לשדרג אותו **בלי רגע אחד בלי ייחודיות**: מונגו אינו יודע לשנות שם של אינדקס, ולכן עדכון "במקום" מחייב להפיל ואז לבנות — ובין השניים שתי בקשות מקבילות יכולות להכניס שני שמות זהים, ואז הבנייה החדשה נכשלת והאכיפה נשארת מושבתת. עם מספור הסדר מתהפך: בונים את החדש, ורק אז מפילים את הישן. + + **המצב היחיד שבו הקוד אוכף:** אם מסיבה כלשהי האינדקס לא אומת במסד, השירות אינו מסמן את האתחול כהצלחה — הבנייה תנוסה שוב, עם השהיה — ובינתיים מסלולי הכתיבה מריצים בדיקת קוד. הבדיקה הזו היא ``find_one`` לפני הכתיבה, ולכן **אינה חסינה למרוץ**: שתי בקשות מקבילות יכולות שתיהן לראות שם פנוי. היא אינה תחליף לאינדקס ואינה מתיימרת להיות; היא ההפרש בין אכיפה חלקית לבין הבטחה שאין מאחוריה כלום. במצב התקין היא אינה עולה אף שאילתה. + +**ביטול פעולה** (``↶``) מחזיר את תוכן הפתק צעד אחורה בכל לחיצה. הקלדה רצופה נספרת כצעד אחד, ולכן הדבקה בטעות מבוטלת בלחיצה אחת. הכפתור מושבת כשאין מה לבטל. + +.. warning:: + הביטול חי ב**סשן הנוכחי בלבד** ואינו שורד רענון דף. הוא נועד למקרה שמתגלה מיד — בחירת כל הטקסט והדבקה במקום העתקה — ולא כתחליף להיסטוריית גרסאות, שאין לפתקים. + +**העתקה** (``⧉``) מעתיקה את כל תוכן הפתק לקליפבורד. בדפדפן שחוסם את ההרשאה מוצג חיווי כשל, ולא לחיצה שלא קורה בה כלום. + +גלילה אינסופית בלוח +~~~~~~~~~~~~~~~~~~~~ +בהגדרות הלוח (גלגל השיניים) אפשר להדליק **גלילה אינסופית** — הלוח ממשיך להתרחב כלפי מטה, כך שתמיד יש לאן לגרור פתקים. + +.. note:: + כיבוי המצב **אינו מזיז אף פתק**. אורך הלוח נמדד תמיד עד הפתק האחרון ומתקצר רק עד שם. ההעדפה נשמרת לכל לוח בנפרד בדפדפן, ואינה חלק מנתוני הלוח. + צ'קבוקסים בפתק ~~~~~~~~~~~~~~~ שורה בצורת ``- [ ] טקסט`` מוצגת כתיבת סימון שאפשר ללחוץ עליה. הלחיצה נשמרת מיד לתוכן הפתק. diff --git a/mcp_server/backend.py b/mcp_server/backend.py index dd69e93f0..7a3b9e795 100644 --- a/mcp_server/backend.py +++ b/mcp_server/backend.py @@ -17,9 +17,21 @@ import datetime as _dt import html +import time as _time import logging from typing import Any +# ``DuplicateKeyError`` נדרש כדי להבחין בין "שם תפוס" לבין תקלה אמיתית. +# אותה תבנית ייבוא עמיד שבה משתמש ``webapp/sticky_notes_api``: בסביבות +# בדיקה בלי pymongo, מחלקה מקומית שלא תיזרק לעולם עדיפה על ייבוא שמפיל +# את המודול כולו. +try: # type: ignore + from pymongo.errors import DuplicateKeyError as _DuplicateKeyError # type: ignore +except Exception: # pragma: no cover + class _DuplicateKeyError(Exception): # type: ignore + pass + + logger = logging.getLogger(__name__) _HEAVY_FIELDS = ("code", "content", "raw_data", "raw_content") @@ -29,7 +41,7 @@ #: יושב. בפתק קובץ שניהם ריקים, ולכן התוספת אינה משנה את מסלול הקובץ. _NOTE_FIELDS = ( "content", "color", "line_start", "anchor_text", "is_minimized", - "board_id", "mode", + "board_id", "mode", "title", ) @@ -356,8 +368,39 @@ def _notes_coll(self) -> Any: coll.create_index([("user_id", 1), ("scope_id", 1)], name="user_scope_idx") except Exception: logger.warning("sticky notes index creation failed (non-fatal)", exc_info=True) + # ``create_board_note`` מחזיר ``duplicate_title`` על סמך דחייה של + # המסד. אם האינדקס אינו שם — והוא נוצר עד היום רק בוובאפ — ההבטחה + # ריקה. פריסה של ה-MCP בלי הוובאפ היא בדיוק המקרה הזה. + # + # **הבנייה אינה חד-פעמית כמו השכנה שמעל.** דגל "ניסינו" שנדלק לפני + # הניסיון הופך כשל חולף אחד — נפילת רשת בעליית התהליך — לתהליך שלם + # שרץ בלי אכיפה עד שיופעל מחדש. כאן מנסים שוב, עם השהיה, עד שהאינדקס + # מאומת בקריאה חוזרת. + self._ensure_title_index(coll) return coll + #: כמה להמתין בין ניסיונות בנייה כושלים, בשניות + _TITLE_INDEX_RETRY_SECONDS = 60.0 + + def _ensure_title_index(self, coll: Any) -> bool: + """בונה ומאמת את אינדקס השם. מחזיר האם האילוץ **חי** כרגע.""" + if getattr(self, "_title_index_ok", False): + return True + now = _time.monotonic() + if now < getattr(self, "_title_index_retry_at", 0.0): + return False + self._title_index_retry_at = now + self._TITLE_INDEX_RETRY_SECONDS + try: + from sticky_notes_target import ensure_title_index + + self._title_index_ok = bool(ensure_title_index(coll)) + except Exception: + self._title_index_ok = False + logger.error("one_title_per_board index creation failed", exc_info=True) + if not self._title_index_ok: + logger.error("one_title_per_board index not confirmed — falling back to a code check") + return self._title_index_ok + def _related_file_ids(self, user_id: int, file_name: str) -> list[str]: """כל מזהי הגרסאות של השם הזה — לפריטת שאילתת הוובאפ (פתקי legacy בלי scope_id).""" try: @@ -556,6 +599,7 @@ def create_board_note( content: str, color: str, mode: str, + title: str = "", ) -> dict[str, Any]: """פתק חדש על לוח. @@ -610,6 +654,8 @@ def create_board_note( # שהוא מחזיר, ולכן אי אפשר להרכיב כאן מסמך לא חוקי. **build_note_target(board_id=canonical), "content": content, + # שם ריק ← השדה כלל אינו נכתב, כדי שלא ייכנס לאינדקס הייחודי + **({"title": title} if title else {}), "position_x": 120, "position_y": 120, "width": 260, @@ -620,7 +666,24 @@ def create_board_note( "created_at": now, "updated_at": now, } - res = coll.insert_one(note) + # גיבוי לאכיפה כשהאינדקס לא אומת. במצב התקין ``_ensure_title_index`` + # מחזירה True מיד, ואין כאן שום שאילתה נוספת. + if title and not self._ensure_title_index(coll): + from sticky_notes_target import title_is_taken + + if title_is_taken(coll, user_id=int(user_id), board_id=canonical, title=title): + return {"ok": False, "error": "duplicate_title"} + + try: + res = coll.insert_one(note) + except _DuplicateKeyError: + # שם תפוס בלוח — התנגשות ולא תקלה. נתפס לפי **הטיפוס** ולא לפי + # שם המחלקה: השוואת מחרוזת הייתה נשענת על שם שהדרייבר חופשי + # לשנות, והייתה תופסת גם כל חריגה זרה שבמקרה נקראת כך. תפיסה + # לפי טיפוס מכסה גם תת-מחלקות. (חריגה **עוטפת** — כזו שמחזיקה + # DuplicateKeyError בתוכה — אינה נתפסת כאן בשום שיטה, וגם + # ההשוואה לשם לא הייתה תופסת אותה.) + return {"ok": False, "error": "duplicate_title"} note["_id"] = getattr(res, "inserted_id", None) return {"ok": True, "note": _as_note(note)} diff --git a/mcp_server/handlers.py b/mcp_server/handlers.py index da5b5aa34..b258b6757 100644 --- a/mcp_server/handlers.py +++ b/mcp_server/handlers.py @@ -402,6 +402,7 @@ def create_board_note( content: str, color: str | None = None, mode: str | None = None, + title: str | None = None, ) -> dict[str, Any]: """Attach a sticky note to a board (a surface that belongs to no file). @@ -409,7 +410,9 @@ def create_board_note( ``screen`` (צף מול המסך). ``anchored`` אינו חוקי כאן — הוא דורש שורות מקור, ובלוח אין כאלה; פתק כזה היה מחשב מיקום מול עוגן שאינו קיים. """ - from sticky_notes_target import DEFAULT_BOARD_MODE, is_valid_board_mode, normalize_mode + from sticky_notes_target import ( + DEFAULT_BOARD_MODE, is_valid_board_mode, normalize_mode, normalize_note_title, + ) bid = (board_id or "").strip() if not _BOARD_ID_RE.match(bid): @@ -435,6 +438,7 @@ def create_board_note( content=clean, color=color_s, mode=normalize_mode(mode, DEFAULT_BOARD_MODE), + title=normalize_note_title(title), ) diff --git a/mcp_server/server.py b/mcp_server/server.py index e30478df1..cc354744e 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -374,8 +374,8 @@ def list_board_notes(ctx: Context, board_id: str) -> dict: description=( "Add a sticky note to a board (board_id from codekeeper_list_boards). Unlike " "codekeeper_create_note this needs no file. mode is 'surface' (sits on the " - "board, default) or 'screen' (floats against the viewport). Requires write " - "permission." + "board, default) or 'screen' (floats against the viewport). An optional title " + "labels the note and must be unique on that board. Requires write permission." ), annotations=_WRITE_TOOL, ) @@ -385,6 +385,7 @@ def create_board_note( content: str, color: str | None = None, mode: str | None = None, + title: str | None = None, ) -> dict: require_write(ctx) # דחיית טוקן קריאה-בלבד לפני כל נגיעה בנתונים return handlers.create_board_note( @@ -394,6 +395,7 @@ def create_board_note( content=content, color=color, mode=mode, + title=title, ) @mcp.tool( diff --git a/sticky_notes_target.py b/sticky_notes_target.py index 5c875e900..5a50c6e89 100644 --- a/sticky_notes_target.py +++ b/sticky_notes_target.py @@ -20,7 +20,11 @@ """ from __future__ import annotations -from typing import Any, Dict, List, Mapping, Optional +from typing import Any, Dict, List, Mapping, Optional, Tuple + + +#: ``IndexNotFound`` — מונגו מחזיר אותו כשמפילים אינדקס שכבר אינו שם. +_INDEX_NOT_FOUND = 27 class NoteTargetError(ValueError): @@ -86,6 +90,223 @@ class NoteQuotaExceeded(NoteQuotaError): #: בתקווה הם מספר אחד שגוי שמחכה. MAX_NOTES_PER_USER = 1000 +#: אורך מרבי לשם פתק. שם הוא תווית קצרה בשורת הכפתורים, לא כותרת. +MAX_NOTE_TITLE = 80 + + +def normalize_note_title(value: Any) -> str: + """שם פתק מנורמל, או מחרוזת ריקה. + + **מחרוזת ריקה משמעותה "אין שם", והשדה נמחק מהמסמך** ולא נשמר כ-``""``. + זה לא קוסמטי: האינדקס הייחודי משתמש ב-``partialFilterExpression`` עם + ``$exists``, ולכן שני פתקים עם ``title: ""`` היו מתנגשים זה בזה. פתק + בלי השדה כלל פשוט אינו באינדקס. + + (``$ne`` אינו נתמך ב-``partialFilterExpression`` — נבדק מול מונגו + 7.0 ונדחה ב-``Error in specification``. ``$exists`` נתמך.) + + **רק מחרוזת היא שם.** ``str(value)`` על גוף JSON שרירותי הופך + ``["a", "b"]`` ל-``"['a', 'b']"`` ו-``{"x": 1}`` ל-``"{'x': 1}"`` — + כלומר ייצוג פייתון פנימי נשמר במסד ומוצג למשתמש כשם הפתק. כל טיפוס + שאינו ``str`` נחשב "אין שם", בדיוק כמו מחרוזת ריקה. + """ + if not isinstance(value, str): + return "" + text = value.strip() + if not text: + return "" + # שורה אחת: שם שנשפך לשתי שורות שובר את שורת הכפתורים + text = " ".join(text.split()) + return text[:MAX_NOTE_TITLE] + + +#: מפרט האינדקס שאוכף "שם אחד לכל לוח" — **מקור האמת היחיד**. +#: +#: הוא כאן ולא ב-``webapp`` מאותה סיבה שהביאה הנה את התקרות: גם ה-webapp +#: וגם ``mcp_server`` כותבים פתקים עם שם, ושניהם מחזירים ``duplicate_title`` +#: כשהמסד דוחה. אכיפה שקיימת רק בצד אחד היא הבטחה שהצד השני מפר בשקט. +#: +#: **למה גם ``board_id`` בפילטר:** בלעדיו פתקי **קבצים** נכנסים לאינדקס עם +#: ``board_id`` חסר, כלומר כולם חולקים את אותו ערך מפתח — ושני פתקים על +#: שני קבצים שונים עם אותו שם נדחים ב-E11000. נמדד מול מונגו 7.0.14: +#: עם הפילטר הישן ההוספה השנייה נדחתה, עם החדש היא עוברת, והייחודיות בתוך +#: לוח נשמרת. זו גם ההתנהגות שביקשנו — בקבצים אותו שם הוא גרסה נוספת, לא +#: התנגשות. +#: **למה השם נושא גרסה:** מונגו אינו יודע לשנות שם של אינדקס, ואינדקס +#: קיים עם אפשרויות שונות נדחה ב-``code 86``. כלומר עדכון "במקום" מחייב +#: להפיל ואז לבנות — וברגעים שבין השניים אין ייחודיות כלל, ושתי בקשות +#: מקבילות יכולות להכניס שני שמות זהים לאותו לוח. אז הבנייה החדשה נכשלת, +#: והאכיפה נשארת מושבתת. +#: +#: עם שם ממוספר הסדר מתהפך: **בונים את החדש, ורק אז מפילים את הישן.** +#: בכל רגע נתון יש לפחות אינדקס ייחודי אחד. נמדד מול מונגו 7.0.14 — שני +#: אינדקסים על אותם מפתחות עם ``partialFilterExpression`` שונה **מותרים** +#: לחיות זה לצד זה, ובכל אחד מארבעת הצעדים הייחודיות נאכפה. +#: +#: שינוי עתידי במפרט ← להעלות ל-``_v3`` ולהוסיף את ``_v2`` לרשימה שמתחת. +ONE_TITLE_PER_BOARD_INDEX: Dict[str, Any] = { + "keys": [("user_id", 1), ("board_id", 1), ("title", 1)], + "name": "one_title_per_board_v2", + "unique": True, + "partialFilterExpression": {"title": {"$exists": True}, "board_id": {"$exists": True}}, +} + +#: שמות של גרסאות קודמות. מופלים **רק אחרי** שהאינדקס הנוכחי אומת במסד. +SUPERSEDED_TITLE_INDEX_NAMES: Tuple[str, ...] = ("one_title_per_board",) + + +def ensure_title_index(coll: Any) -> bool: + """יוצר את :data:`ONE_TITLE_PER_BOARD_INDEX`, ומיישב גרסה ישנה שלו. + + ``coll`` הוא אוסף pymongo, אבל הטיפוס אינו מיובא כאן — המודול הזה נשאר + טהור כדי ש-``mcp_server`` יוכל לייבא ממנו. הקריאות היחידות הן + ``index_information`` / ``drop_index`` / ``create_index``. + + **הסדר הוא כל העניין: בונים את החדש, ורק אז מפילים את הישן.** מונגו + אינו יודע לשנות שם של אינדקס, ולכן עדכון "במקום" מחייב את הסדר ההפוך + — ובין ההפלה לבנייה אין ייחודיות כלל, ושתי בקשות מקבילות יכולות + להכניס שני שמות זהים לאותו לוח. אז הבנייה החדשה נכשלת, והאכיפה + נשארת מושבתת לחלוטין. השם הממוספר הוא מה שמאפשר את הסדר הנכון: שני + אינדקסים על אותם מפתחות עם ``partialFilterExpression`` שונה **מותרים** + לחיות זה לצד זה (נמדד מול מונגו 7.0.14). + + שני קודי השגיאה שמעצבים את הפונקציה, שניהם נמדדו: + ``code 85`` — אינדקס עם אותו מפרט בדיוק כבר קיים בשם אחר; + ``code 86`` — אינדקס באותו שם כבר קיים עם מפרט אחר. + + ומכאן גם הכלל השני: **אינדקס שנושא את המפרט הנכון תחת שם ישן נשאר + כמו שהוא.** ראו :func:`_enforcing_index_name`. + + :returns: ``True`` רק אחרי **קריאה חוזרת** שמאשרת שהאינדקס במסד תואם + למפרט. ערך החזרה של כתיבה אינו אימות. + """ + want = ONE_TITLE_PER_BOARD_INDEX + name = want["name"] + try: + info = coll.index_information() + except Exception: + info = {} + if not isinstance(info, Mapping): + info = {} + + live = _enforcing_index_name(info, want) + if live is None: + # השם הנוכחי תפוס במפרט אחר. זה קורה **רק** אם מישהו שינה את + # המפרט בלי להעלות את מספר הגרסה בשם — כלומר בדיוק מה שהמספור בא + # למנוע. כאן אין מוצא: מונגו דוחה ב-``code 86``, ובלי ההפלה + # האינדקס לעולם לא יתכנס. זה המקום היחיד שנשאר בו חלון בלי + # ייחודיות, והדרך להימנע ממנו היא להעלות ל-``_v3`` ולא לערוך את + # ``_v2`` במקום. + if name in info: + _drop_index_if_present(coll, name) + + coll.create_index( + want["keys"], + name=name, + unique=want["unique"], + partialFilterExpression=want["partialFilterExpression"], + ) + try: + info = coll.index_information() + except Exception: + return False + if not isinstance(info, Mapping): + return False + live = _enforcing_index_name(info, want) + if live is None: + return False + + # **רק עכשיו** — יש אינדקס חי ומאומת, ולכן הפלת השאר אינה פותחת חלון + # בלי ייחודיות. ``live`` עצמו לעולם אינו מופל. + for stale in SUPERSEDED_TITLE_INDEX_NAMES: + if stale != live and stale in info: + _drop_index_if_present(coll, stale) + return True + + +def _enforcing_index_name(info: Mapping[str, Any], want: Mapping[str, Any]) -> Optional[str]: + """שם האינדקס שאוכף כרגע **בדיוק** את המפרט המבוקש, אם קיים כזה. + + **למה השם אינו חשוב והמפרט כן:** אינדקס שנושא את המפרט הנכון תחת שם + ישן כבר אוכף את האילוץ במלואו. הפלתו כדי "לסדר" את השם הייתה מפילה + את האינדקס **היחיד** שאוכף, ורק אז בונה — כלומר פותחת בדיוק את החלון + שכל המספור בא לסגור, ובלי שום תמורה. לכן הוא נחשב תקין כמו שהוא. + + השם הקנוני נבדק ראשון, כדי שאחרי מיגרציה מלאה לא נשאר תלויים בישן. + """ + if _index_matches(info.get(want["name"]), want): + return str(want["name"]) + for stale in SUPERSEDED_TITLE_INDEX_NAMES: + if _index_matches(info.get(stale), want): + return stale + return None + + +def title_is_taken( + coll: Any, + *, + user_id: Any, + board_id: Any, + title: str, + exclude_id: Any = None, +) -> bool: + """האם השם כבר תפוס בלוח — בדיקת **קוד**, לא של המסד. + + **זו אינה החלפה לאינדקס והיא לא נועדה לרוץ בדרך כלל.** האינדקס הוא מה + שסוגר את המרוץ; שאילתה כאן יכולה תמיד להפסיד לכותב מקביל שנכנס בין + הבדיקה לכתיבה. היא קיימת בשביל מצב אחד בלבד: כש-:func:`ensure_title_index` + לא הצליחה לאמת שהאינדקס במסד, ואז הברירה היא בין אכיפה חלקית לבין אפס + אכיפה — בעוד שהקריאה לשירות **מבטיחה** ``duplicate_title``. הבטחה שאין + מאחוריה כלום היא הדבר היחיד שגרוע יותר מהתנגשות. + + לכן היא נקראת מאחורי דגל, ובמצב התקין אינה עולה אף שאילתה. + """ + if not title: + return False + query: Dict[str, Any] = {"user_id": user_id, "board_id": board_id, "title": title} + if exclude_id is not None: + # ההחרגה שייכת ל**שאילתה**, לא לסינון התוצאה שחזרה. ``find_one`` + # מחזיר מסמך אחד שרירותי, ולכן סינון בדיעבד היה יכול לקבל דווקא + # את הפתק שאנחנו מעדכנים ולהחזיר "פנוי" — בעוד שפתק אחר עם אותו + # שם יושב שם ומחכה. + query["_id"] = {"$ne": exclude_id} + try: + return coll.find_one(query, {"_id": 1}) is not None + except Exception: + # שאילתה שנכשלה אינה ראיה לכך שהשם פנוי. ראו ``check_note_quota``: + # באותו ריפו כבר נקבע שכשל בדיקה נסגר ולא נפתח. + return True + + +def _drop_index_if_present(coll: Any, name: str) -> None: + """מפיל אינדקס, ובולע **רק** את המקרה שבו הוא כבר לא שם. + + תהליך מקביל שהספיק להפיל אותו לפנינו אינו תקלה — והחריגה שלו לא + אמורה לעצור את שאר הרצף. + """ + try: + coll.drop_index(name) + except Exception as exc: # pragma: no cover - תלוי דרייבר + if getattr(exc, "code", None) != _INDEX_NOT_FOUND: + raise + + +def _index_matches(spec: Any, want: Mapping[str, Any]) -> bool: + """האם מפרט שנקרא מהמסד תואם למה שביקשנו. + + ``partialFilterExpression`` חוזר כ-``SON``, שהוא תת-מחלקה של ``dict`` + ולכן ההשוואה למילון רגיל תקפה ואינה תלוית סדר (נבדק). + """ + if not isinstance(spec, Mapping): + return False + if spec.get("unique") is not True: + return False + if spec.get("partialFilterExpression") != want["partialFilterExpression"]: + return False + # ``index_information`` מחזיר את המפתחות כרשימת זוגות + keys = [(str(f), v) for f, v in (spec.get("key") or [])] + return keys == [(f, v) for f, v in want["keys"]] + def _clean(value: Any) -> str: """מזהה כמחרוזת מנורמלת. ``None``, ``''`` ומחרוזת רווחים — כולם ריקים.""" diff --git a/tests/sticky-notes-target.test.js b/tests/sticky-notes-target.test.js index 8fbfaea3b..b00eb9e70 100644 --- a/tests/sticky-notes-target.test.js +++ b/tests/sticky-notes-target.test.js @@ -64,9 +64,21 @@ const StickyNotesManager = sandbox.window.StickyNotesManager; let passed = 0; let failed = 0; +// בדיקה אסינכרונית שנכשלת מחזירה promise דחוי, וגרסה סינכרונית של +// ``check`` הייתה סופרת אותה כ"עברה" ומדפיסה אזהרת unhandled rejection. +// לכן התור מצטבר ומוצה בסוף הקובץ. +const pending = []; + function check(name, fn) { try { - fn(); + const out = fn(); + if (out && typeof out.then === 'function') { + pending.push(out.then( + () => { passed += 1; }, + (err) => { failed += 1; console.error(`✗ ${name}\n ${err && err.message}`); }, + )); + return; + } passed += 1; } catch (err) { failed += 1; @@ -144,6 +156,17 @@ check('יעד חסר נכשל מיד ולא בשקט', () => { const fileMgr = new StickyNotesManager('f1'); const boardMgr = new StickyNotesManager({ board: 'b1' }); +// **מוקש בהארנס, ולא בקוד הנבדק.** +// +// הבנאי יורה ``_init() → loadNotes()`` ברקע, וכשהיא נפתרת היא קוראת +// ``_clearAllNotes()`` — כלומר **כל פתק שנרשם ידנית ב-``notes`` נמחק +// ב-microtask הראשון**. כל הבדיקות עד היום היו סינכרוניות ורצו לפני +// הרגע הזה, ולכן לא הרגישו. הראשונה שהוסיפה ``await`` נפלה על מפה ריקה, +// ובלי הבדיקה הזו כל בדיקה אסינכרונית עתידית הייתה משקרת באותו אופן. +// +// ממתינים כאן, פעם אחת, עד שהטעינה ברקע מסיימת. +await new Promise((resolve) => setTimeout(resolve, 0)); + check('סנטינל הנעיצה ממופה ל-surface', () => { eq(fileMgr._resolveMode({ anchor_id: '__pinned__' }), 'surface'); }); @@ -426,5 +449,219 @@ check('שמירה: 400 אינו חוזר לתור, 500 כן', () => { eq(fileMgr._isPermanentFailure(undefined), false, 'סטטוס לא ידוע — לא לזרוק מידע'); }); +// ---------- ביטול פעולה ---------- +// +// כל הבדיקות כאן רושמות את הפתק ב-``notes``, כי ``_undoState`` תלוי ב- +// ``entry.data.content`` בתור נקודת המוצא. + +/** רושם פתק במנהל ומחזיר את החלקים שלו. */ +function registerNote(mgr, id, content) { + const parts = makeNote(content); + parts.el.dataset.noteId = id; + const undoBtn = new FakeEl('button'); + undoBtn.className = 'sticky-note-btn sticky-note-undo'; + undoBtn.disabled = true; + parts.el.appendChild(undoBtn); + const titleInput = new FakeEl('input'); + titleInput.className = 'sticky-note-title'; + parts.el.appendChild(titleInput); + mgr.notes.set(id, { el: parts.el, data: { id, content } }); + return Object.assign(parts, { undoBtn, titleInput, entry: mgr.notes.get(id) }); +} + +check('ביטול: המשך רשימה אוטומטי נרשם למחסנית', () => { + // ``_handleListContinuation`` כותב ל-``textarea.value`` ישירות, וכתיבה + // תכנותית **אינה** משדרת ``input``. בלי רישום מפורש הצעד הזה היה יוצא + // מחוץ להיסטוריה, והביטול היה מדלג עליו כאילו לא קרה. + const n = registerNote(fileMgr, 'undo-list', '- [ ] ראשון'); + n.ta.value = '- [ ] ראשון\n- [ ] '; + fileMgr._noteProgrammaticEdit(n.el, n.ta, '- [ ] ראשון'); + + eq(fileMgr._undoState(n.el).stack.length, 1, 'צעד אחד במחסנית'); + eq(fileMgr._undoState(n.el).stack[0], '- [ ] ראשון', 'התוכן שלפני'); + eq(n.undoBtn.disabled, false, 'הכפתור פעיל'); +}); + +check('ביטול: כתיבה תכנותית שלא שינתה דבר אינה נרשמת', () => { + const n = registerNote(fileMgr, 'undo-noop', 'טקסט'); + fileMgr._noteProgrammaticEdit(n.el, n.ta, 'טקסט'); + eq(fileMgr._undoState(n.el).stack.length, 0, 'מחסנית ריקה'); +}); + +check('ביטול: כפתור פעיל מיד אחרי הדבקה, לפני שהטיימר נסגר', () => { + // התרחיש המרכזי של הפיצ'ר: בחירת-הכל ואז הדבקה בטעות. המחסנית עדיין + // ריקה — הצילום ממתין ל-600ms של שקט — ובגרסה הקודמת הכפתור היה + // מושבת בדיוק ברגע שבו הוא הכי נחוץ. + const original = 'א'.repeat(750); + const n = registerNote(fileMgr, 'undo-paste', original); + n.ta.value = 'הדבקה קצרה'; + fileMgr._recordUndoBurst(n.el, n.ta); + + eq(fileMgr._undoState(n.el).stack.length, 0, 'המחסנית עדיין ריקה'); + eq(n.undoBtn.disabled, false, 'ובכל זאת הכפתור פעיל'); + + fileMgr._undoLastChange(n.el); + eq(n.ta.value, original, 'התוכן חזר במלואו'); + eq(n.ta.value.length, 750, 'ובאורך המקורי'); +}); + +check('ביטול: תוכן סמכותי מהשרת מאפס את נקודת ההשוואה', () => { + // אחרי סימון צ'קבוקס השרת מחזיר את התוכן הנכון. אם ``last`` נשאר על + // התוכן שלפני הסימון, העריכה הבאה דוחפת אותו למחסנית — וביטול אחד היה + // מוחק כתיבה שהשרת כבר אישר. + const n = registerNote(fileMgr, 'undo-server', '- [ ] משימה'); + const entry = n.entry; + + // **חובה לייצר את מצב הביטול לפני התוכן מהשרת.** ``_undoState`` בונה + // את עצמו מ-``entry.data.content``, ואם הוא נוצר רק אחרי — הוא נזרע + // ממילא מהערך החדש והבדיקה עוברת גם בלי התיקון. נתפס במוטציה: ביטול + // ``_resetUndoBaseline`` לא הפיל את הגרסה הראשונה של הבדיקה הזו. + n.ta.value = '- [ ] משימה!'; + fileMgr._recordUndoBurst(n.el, n.ta); + eq(fileMgr._undoState(n.el).last, '- [ ] משימה!', 'נקודת המוצא לפני השרת'); + + fileMgr._applyServerContent(n.el, entry, '- [x] משימה', '2026-01-01T00:00:00Z'); + eq(fileMgr._undoState(n.el).last, '- [x] משימה', 'נקודת ההשוואה התעדכנה'); + eq(fileMgr._undoState(n.el).burstFrom, null, 'וההתפרצות הפתוחה נסגרה'); + + // עכשיו המשתמש מקליד. מה שנשמר חייב להיות התוכן **שאחרי** הסימון. + n.ta.value = '- [x] משימה ועוד'; + fileMgr._recordUndoBurst(n.el, n.ta); + eq(fileMgr._undoState(n.el).burstFrom, '- [x] משימה', 'הצילום הוא מהתוכן המאושר'); +}); + +check('ביטול: מחיקת פתק מנקה את הטיימר התלוי', () => { + const n = registerNote(fileMgr, 'undo-timer', 'טקסט'); + n.ta.value = 'טקסט ועוד'; + fileMgr._recordUndoBurst(n.el, n.ta); + eq(fileMgr._undoState(n.el).timer !== null, true, 'יש טיימר פתוח'); + + fileMgr._clearUndoTimer(n.el); + eq(fileMgr._undoState(n.el).timer, null, 'נוקה'); +}); + +// ---------- שם תפוס ---------- + +check('שם: הסימון תלוי בקוד השגיאה, לא בכשל שמירה גנרי', () => { + // הגרסה הקודמת הסיקה מ-``has-save-error``: כל נפילת רשת או חריגת מכסה + // סימנה את השם כתפוס, ואילו 409 אמיתי — שאינו מסמן את המחלקה הזו — + // לא סימן כלום. כלומר הסימון הופיע בכל מצב חוץ מהנכון. + const n = registerNote(fileMgr, 'dup-1', 'תוכן'); + + n.el.classList.add('has-save-error'); // כשל שמירה כלשהו + fileMgr._syncTitleConflict(n.el); + eq(n.titleInput.classList.contains('is-duplicate'), false, 'כשל גנרי אינו "שם תפוס"'); + + fileMgr._markTitleConflict('dup-1', true); // 409 duplicate_title + eq(n.titleInput.classList.contains('is-duplicate'), true, 'ורק הקוד הספציפי מסמן'); + + fileMgr._markTitleConflict('dup-1', false); + eq(n.titleInput.classList.contains('is-duplicate'), false, 'שם חדש מנקה את הסימון'); +}); + +check('ביטול: תוכן מהשרת שסוגר התפרצות מכבה גם את הכפתור', () => { + // הכפתור נדלק על סמך התפרצות ממתינה. ``_resetUndoBaseline`` סוגר אותה, + // ואם הוא לא מסנכרן — הכפתור נשאר דלוק על מחסנית ריקה, ולחיצה עליו + // לא עושה כלום. כפתור שנראה זמין ולא עובד הוא בדיוק סוג השקר שהפיצ'ר + // הזה נבנה כדי לא לספר. + const n = registerNote(fileMgr, 'undo-sync', 'מקור'); + n.ta.value = 'מקור ועוד'; + fileMgr._recordUndoBurst(n.el, n.ta); + eq(n.undoBtn.disabled, false, 'דלוק בזמן ההתפרצות'); + eq(fileMgr._undoState(n.el).stack.length, 0, 'והמחסנית עדיין ריקה'); + + fileMgr._resetUndoBaseline(n.el, 'תוכן מהשרת'); + + eq(fileMgr._undoState(n.el).stack.length, 0, 'המחסנית נשארה ריקה'); + eq(n.undoBtn.disabled, true, 'ולכן הכפתור כבוי'); +}); + +check('ביטול: סימון צ׳קבוקס ניתן לביטול', () => { + // התרחיש: פתק שלא נערך, המשתמש מסמן צ'קבוקס. המחסנית ריקה לגמרי, + // ולכן לפני התיקון לא הייתה שום דרך לחזור מהסימון דרך הכפתור — + // בעוד שההערה בקוד טענה שהמחסנית כבר שומרת את המצב שלפני. + const n = registerNote(fileMgr, 'undo-toggle', '- [ ] משימה'); + eq(fileMgr._undoState(n.el).stack.length, 0, 'נקודת המוצא: מחסנית ריקה'); + + fileMgr._applyServerContent(n.el, n.entry, '- [x] משימה', '2026-01-01T00:00:00Z'); + + eq(n.ta.value, '- [x] משימה', 'התוכן הסמכותי הוחל'); + eq(n.undoBtn.disabled, false, 'ויש מה לבטל'); + fileMgr._undoLastChange(n.el); + eq(n.ta.value, '- [ ] משימה', 'הסימון בוטל'); +}); + +check('ביטול: תוכן זהה מהשרת אינו נחשב צעד', () => { + const n = registerNote(fileMgr, 'undo-same', '- [x] משימה'); + fileMgr._applyServerContent(n.el, n.entry, '- [x] משימה', null); + eq(fileMgr._undoState(n.el).stack.length, 0, 'שום דבר לא השתנה'); + eq(n.undoBtn.disabled, true); +}); + +// ``_sendUpdate`` אמיתי מול תשובה מבוקרת. בלי זה אין דרך לבדוק את ענפי +// הכשל של מסלול השמירה — והם בדיוק החלק שנשבר בשקט. +async function withFetch(handler, fn) { + // ``await`` ולא ``return`` בתוך try: בלעדיו ה-``finally`` משחזר את + // ה-fetch **לפני** שה-promise נפתר, והבדיקה רצה מול הפייק ברירת המחדל + // של הסנדבוקס — שאין לו ``status`` בכלל. נתפס בהרצה: שתי בדיקות נפלו + // על ענף הכשל במקום על הענף שנבדק. + const original = sandbox.fetch; + sandbox.fetch = handler; + try { return await fn(); } finally { sandbox.fetch = original; } +} +const okResponse = async () => ({ ok: true, status: 200, json: async () => ({ ok: true }) }); + +check('שם: שמירה שאינה של השם אינה מנקה את הסימון', async () => { + // התרחיש: השם נדחה ב-409, ואז המשתמש גורר את הפתק. השמירה של המיקום + // מצליחה — אבל היא לא אומרת דבר על השם, שנשאר תפוס בשרת. ניקוי הסימון + // כאן היה מציג "נשמר" על שדה שהשרת לא קיבל. + const n = registerNote(fileMgr, 'dup-keep', 'תוכן'); + fileMgr._markTitleConflict('dup-keep', true); + eq(n.titleInput.classList.contains('is-duplicate'), true, 'מסומן אחרי 409'); + + await withFetch(okResponse, () => fileMgr._sendUpdate('dup-keep', { position: { x: 1, y: 2 } }, 0)); + eq(n.titleInput.classList.contains('is-duplicate'), true, 'שמירת מיקום לא ניקתה'); + + await withFetch(okResponse, () => fileMgr._sendUpdate('dup-keep', { title: 'שם אחר' }, 0)); + eq(n.titleInput.classList.contains('is-duplicate'), false, 'שמירת שם כן ניקתה'); +}); + +check('שם: 409 duplicate_title מסמן ואינו חוזר לתור', async () => { + // 409 גנרי חוזר לתור עם חותמת טרייה; שם תפוס לא ייפתר לעולם בניסיון + // חוזר. בלי ההבחנה זו לולאת ניסיונות אינסופית, ובלי שום חיווי. + const n = registerNote(fileMgr, 'dup-409', 'תוכן'); + const dup = async () => ({ ok: false, status: 409, json: async () => ({ ok: false, error: 'duplicate_title' }) }); + + fileMgr._pending.set('dup-409', { title: 'תפוס' }); + fileMgr._pendingSeq.set('dup-409', 0); + const result = await withFetch(dup, () => fileMgr._sendUpdate('dup-409', { title: 'תפוס' }, 0)); + + eq(result, false, 'הכשל דווח'); + eq(n.titleInput.classList.contains('is-duplicate'), true, 'והשם מסומן'); + eq(fileMgr._pending.has('dup-409'), false, 'ולא נשאר בתור לניסיון נוסף'); +}); + +await Promise.all(pending); +check('ביטול: תוכן מהשרת באמצע הקלדה שומר את תחילת ההתפרצות', () => { + // התרחיש: המשתמש מקליד, ובאמצע ההתפרצות מגיע תוכן סמכותי מהשרת + // (סימון צ'קבוקס ממכשיר אחר, למשל). ``_resetUndoBaseline`` מוחק את + // ``burstFrom`` — הצילום שנלקח **לפני** ההקלדה — ובלי לשמור אותו קודם + // הביטול היה מחזיר לסוף ההתפרצות במקום לתחילתה, וההקלדה כולה אבודה. + const n = registerNote(fileMgr, 'undo-mid-burst', 'התחלה'); + + n.ta.value = 'התחלה + הקלדה'; + fileMgr._recordUndoBurst(n.el, n.ta); + eq(fileMgr._undoState(n.el).burstFrom, 'התחלה', 'ההתפרצות פתוחה'); + + fileMgr._applyServerContent(n.el, n.entry, 'מהשרת', null); + + // שני צעדים, LIFO: קודם חוזרים למה שהוקלד, ואז למה שהיה לפני + eq(n.ta.value, 'מהשרת', 'התוכן הסמכותי הוחל'); + fileMgr._undoLastChange(n.el); + eq(n.ta.value, 'התחלה + הקלדה', 'ביטול ראשון — מה שהוקלד'); + fileMgr._undoLastChange(n.el); + eq(n.ta.value, 'התחלה', 'ביטול שני — לפני ההקלדה'); +}); + 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 dd1745fe3..26103b5ef 100644 --- a/tests/test_mcp_notes_handlers.py +++ b/tests/test_mcp_notes_handlers.py @@ -251,6 +251,8 @@ def test_as_note_serialization(): # פתק לוח חייב לומר איפה הוא יושב. בפתק קובץ שניהם ריקים. "board_id", "mode", + # שם הפתק — תווית שמזהה אותו בתוך הלוח + "title", "created_at", "updated_at", } @@ -299,8 +301,8 @@ def list_board_notes(self, user_id, *, board_id): self.calls.append(("list_board_notes", user_id, board_id)) return {"ok": True, "board_id": board_id, "count": 0, "notes": []} - def create_board_note(self, user_id, *, board_id, content, color, mode): - self.calls.append(("create_board_note", user_id, board_id, content, color, mode)) + def create_board_note(self, user_id, *, board_id, content, color, mode, title=""): + self.calls.append(("create_board_note", user_id, board_id, content, color, mode, title)) return {"ok": True, "note": {"id": "n1", "board_id": board_id, "mode": mode}} @@ -366,12 +368,174 @@ def test_create_board_note_rejects_anchored_mode(): assert b.calls == [] +def test_create_board_note_normalizes_the_title(): + """שם מנורמל לפני שהוא מגיע למסד: רווחים, שורות, ואורך. + + שם רב-שורות היה שובר את שורת הכפתורים, ושם ריק חייב להישאר ריק כדי + שהשדה כלל לא ייכתב — אחרת הוא נכנס לאינדקס הייחודי ומתנגש. + """ + from sticky_notes_target import MAX_NOTE_TITLE + + b = _BoardsBackend() + _h.create_board_note(b, 7, board_id=_VALID_BOARD, content="x", title=" שם\nעם שורות ") + assert b.calls[0][6] == "שם עם שורות" + + b2 = _BoardsBackend() + _h.create_board_note(b2, 7, board_id=_VALID_BOARD, content="x", title=" ") + assert b2.calls[0][6] == "", "שם ריק נשאר ריק, והשדה לא ייכתב" + + b3 = _BoardsBackend() + _h.create_board_note(b3, 7, board_id=_VALID_BOARD, content="x", title="א" * 200) + assert len(b3.calls[0][6]) == MAX_NOTE_TITLE + + def test_create_board_note_normalizes_color_and_mode(): b = _BoardsBackend() _h.create_board_note(b, 7, board_id=_VALID_BOARD, content="שלום", color="לא-צבע") - _, _, _, content, color, mode = b.calls[0] + _, _, _, content, color, mode, _title = b.calls[0] assert content == "שלום" assert color == _h.DEFAULT_NOTE_COLOR, "צבע לא חוקי נופל לברירת המחדל, כמו ביצירת פתק קובץ" assert mode == "surface", "ברירת המחדל בלוח" + + +# ---------- מכונת המצבים של אינדקס השם ב-MCP ---------- +# +# ``create_board_note`` מבטיח ``duplicate_title``. ההבטחה נשענת על אינדקס +# שנבנה כאן, ולכן מסלולי הכשל שלה הם חלק מהחוזה ולא פרט פנימי. + + +class _IndexSpyColl: + """אוסף מדומה שסופר קריאות ומאפשר לשלוט בתוצאת הבנייה.""" + + def __init__(self): + self.index_calls = 0 + self.find_calls = [] + self.inserted = [] + self.result = True + self.raises = False + + # -- מה ש-``ensure_title_index`` המדומה תצרוך -- + def create_index(self, *a, **k): + return None + + def index_information(self): + return {} + + # -- מה ש-``title_is_taken`` צורכת -- + def find_one(self, query, projection=None): + self.find_calls.append(query) + for doc in self.inserted: + if all(doc.get(k) == v for k, v in query.items() if not isinstance(v, dict)): + return doc + return None + + def insert_one(self, doc): + self.inserted.append(dict(doc)) + + class _R: + inserted_id = len(self.inserted) + + return _R() + + def count_documents(self, *a, **k): + return len(self.inserted) + + +def _backend_with(coll, monkeypatch, clock): + """``ProductionBackend`` בלי ה-``__init__`` הכבד, עם שעון נשלט.""" + from mcp_server.backend import ProductionBackend + import mcp_server.backend as backend_mod + + monkeypatch.setattr(backend_mod._time, "monotonic", lambda: clock["now"]) + b = ProductionBackend.__new__(ProductionBackend) + b._notes_idx_done = True + b._raw_mongo = lambda: {"sticky_notes": coll} + return b + + +def test_a_failed_index_build_is_retried_only_after_the_cooldown(monkeypatch): + """דגל "ניסינו" שנדלק לפני הניסיון משבית את האכיפה לכל חיי התהליך. + + כאן הוא מנסה שוב — אבל לא בכל קריאה, אחרת כל כלי משלם על בנייה + כושלת. נופלת אם ההשהיה מוסרת, וגם אם הניסיון החוזר מוסר. + """ + import sticky_notes_target + + clock = {"now": 500.0} + coll = _IndexSpyColl() + b = _backend_with(coll, monkeypatch, clock) + attempts = [] + monkeypatch.setattr( + sticky_notes_target, "ensure_title_index", + lambda c: (attempts.append(clock["now"]), False)[1], + ) + + assert b._ensure_title_index(coll) is False + assert b._ensure_title_index(coll) is False + assert b._ensure_title_index(coll) is False + assert len(attempts) == 1, f"נוסה {len(attempts)} פעמים בתוך חלון ההמתנה" + + clock["now"] += b._TITLE_INDEX_RETRY_SECONDS + 1 + assert b._ensure_title_index(coll) is False + assert len(attempts) == 2, "אחרי ההשהיה לא נוסה שוב" + + +def test_an_exception_during_the_build_is_not_fatal(monkeypatch): + """חריגה בבנייה מחזירה ``False``, לא מפילה את הכלי.""" + import sticky_notes_target + + clock = {"now": 500.0} + coll = _IndexSpyColl() + b = _backend_with(coll, monkeypatch, clock) + + def _boom(_c): + raise RuntimeError("mongo down") + + monkeypatch.setattr(sticky_notes_target, "ensure_title_index", _boom) + + assert b._ensure_title_index(coll) is False + + +def test_a_confirmed_index_is_never_rebuilt(monkeypatch): + """אחרי אימות, אין יותר קריאות — וגם אין שאילתת גיבוי.""" + import sticky_notes_target + + clock = {"now": 500.0} + coll = _IndexSpyColl() + b = _backend_with(coll, monkeypatch, clock) + attempts = [] + monkeypatch.setattr( + sticky_notes_target, "ensure_title_index", + lambda c: (attempts.append(1), True)[1], + ) + + assert b._ensure_title_index(coll) is True + clock["now"] += 10_000 + assert b._ensure_title_index(coll) is True + assert len(attempts) == 1 + + +def test_create_board_note_falls_back_to_a_code_check_without_the_index(monkeypatch): + """**זו ההבטחה שהכלי נותן, וזה מה שקורה כשאין מי שיאכוף אותה.** + + נופלת אם ``create_board_note`` מדלג על הגיבוי כשהאינדקס לא אומת. + """ + import sticky_notes_target + + clock = {"now": 500.0} + coll = _IndexSpyColl() + b = _backend_with(coll, monkeypatch, clock) + monkeypatch.setattr(sticky_notes_target, "ensure_title_index", lambda c: False) + monkeypatch.setattr(b, "_notes_coll", lambda: coll) + monkeypatch.setattr(b, "_canonical_board_id", lambda board: "b1", raising=False) + monkeypatch.setattr(b, "_owned_board", lambda uid, bid: {"_id": "b1"}, raising=False) + + first = b.create_board_note(7, board_id="b1", content="a", color="#FFFFCC", mode="surface", title="טודו") + assert first.get("ok") is True, first + + dup = b.create_board_note(7, board_id="b1", content="b", color="#FFFFCC", mode="surface", title="טודו") + + assert dup == {"ok": False, "error": "duplicate_title"} + assert coll.find_calls, "הגיבוי לא נשאל בכלל" diff --git a/tests/test_note_boards_api.py b/tests/test_note_boards_api.py index bfafedec7..5b7778943 100644 --- a/tests/test_note_boards_api.py +++ b/tests/test_note_boards_api.py @@ -11,6 +11,13 @@ flask = pytest.importorskip("flask") +try: # אותה תבנית ייבוא עמיד שבמודול הנבדק + from pymongo.errors import DuplicateKeyError as _StubDuplicateKeyError +except Exception: # pragma: no cover + class _StubDuplicateKeyError(Exception): + pass + + class _Res: def __init__(self, inserted_id=None, modified_count=1, deleted_count=1): self.inserted_id = inserted_id @@ -24,6 +31,8 @@ def __init__(self, docs=None): self._next = 1000 self.count_fails = False self.move_is_noop = False + #: מדמה את דחיית האינדקס הייחודי ``one_title_per_board`` + self.duplicate_titles = False # -- קריאה -- def find_one(self, query, projection=None): @@ -59,7 +68,13 @@ def insert_one(self, doc): def update_one(self, query, update): for doc in self._matching(query): + new_title = update.get("$set", {}).get("title") + if new_title is not None and self.duplicate_titles: + raise _StubDuplicateKeyError("E11000 duplicate key") doc.update(update.get("$set", {})) + # ``$unset`` — בלעדיו כל בדיקה על מחיקת שדה עוברת מעצמה + for field in (update.get("$unset") or {}): + doc.pop(field, None) return _Res() return _Res(modified_count=0) @@ -692,3 +707,307 @@ def _raise(*_a, _cls=exc_cls, **_k): assert res.status_code == 409 assert res.get_json()["error"] == expected assert "טקסט אחר" not in str(res.get_json()) + + +# -- שם הפתק במסלול ה-batch -- +# +# זה **המסלול הרגיל**, לא הפולבק: ``_queueSave`` בלקוח מנקז דרך debounce +# אל ``POST /batch``, וה-PUT הבודד רץ רק אם בקשת ה-batch עצמה נכשלה. + + +def test_batch_saves_the_note_title(client): + """**אבידת כתיבה שקטה, מהסוג הגרוע ביותר.** + + ``title`` לא היה ב-allowlist של ה-batch. הראוט החזיר ``ok: True, + status: 200``, הלקוח ניקה את התור בהתאם — והשם פשוט לא נשמר. שום + חיווי, שום לוג, ואין דרך למשתמש לדעת. + + נמדד מול הראוט לפני התיקון: השם במסד נשאר "ישן" אחרי בקשה ששלחה + "שם חדש", והתשובה הייתה 200. + + נופלת בלי ``title`` ב-allowlist של ה-batch. + """ + client.db.sticky_notes.docs.append( + {"_id": 1, "user_id": 7, "board_id": "b1", "content": "x", "title": "ישן"} + ) + + res = client.post("/api/sticky-notes/batch", json={"updates": [{"id": "1", "title": " שם חדש "}]}) + body = res.get_json() + + assert body["results"][0]["ok"] is True + assert client.db.sticky_notes.docs[0]["title"] == "שם חדש" + + +def test_batch_clearing_a_title_removes_the_field(client): + """שם ריק ← ``$unset``, ולא ``title: ""``. + + שני פתקים ששמם נוקה היו מתנגשים באינדקס, כי ``$exists`` מתקיים גם + למחרוזת ריקה. אותה סמנטיקה בדיוק כמו ב-PUT הבודד. + + נופלת אם ה-batch כותב מחרוזת ריקה במקום למחוק את השדה. + """ + client.db.sticky_notes.docs.append( + {"_id": 1, "user_id": 7, "board_id": "b1", "content": "x", "title": "יש שם"} + ) + + res = client.post("/api/sticky-notes/batch", json={"updates": [{"id": "1", "title": " "}]}) + + assert res.get_json()["results"][0]["ok"] is True + assert "title" not in client.db.sticky_notes.docs[0] + + +def test_batch_reports_a_duplicate_title_as_409_and_not_500(client): + """קוד השגיאה הוא **ההבדל בין תיקון ללולאה אינסופית**. + + הלקוח מנסה שוב על 409 גנרי (התנגשות גרסה) ומפסיק על ``duplicate_title``. + ``500 Failed`` — מה שהמסלול הזה החזיר עד היום, כי הכתיבה לא הייתה + עטופה — היה מסמן כשל בלי לומר למשתמש שהשם תפוס. + + נופלת בלי ``except DuplicateKeyError`` סביב הכתיבה ב-batch. + """ + client.db.sticky_notes.docs.append({"_id": 1, "user_id": 7, "board_id": "b1", "content": "x"}) + client.db.sticky_notes.duplicate_titles = True + + res = client.post("/api/sticky-notes/batch", json={"updates": [{"id": "1", "title": "טודו"}]}) + result = res.get_json()["results"][0] + + assert result["status"] == 409 + assert result["error"] == "duplicate_title" + + +def test_single_put_clearing_a_title_removes_the_field(client): + """אותה סמנטיקה גם ב-PUT — כאן דרך ה-HTTP, לא ישירות מול המסד.""" + client.db.sticky_notes.docs.append( + {"_id": 1, "user_id": 7, "board_id": "b1", "content": "x", "title": "יש שם"} + ) + + res = client.put("/api/sticky-notes/note/1", json={"title": ""}) + + assert res.status_code == 200 + assert "title" not in client.db.sticky_notes.docs[0] + + +# -- גיבוי לאכיפת השם כשהאינדקס לא אומת -- + + +def test_duplicate_title_is_caught_by_code_when_the_index_is_missing(client, monkeypatch): + """**הבטחה שאין מאחוריה כלום היא הדבר היחיד שגרוע יותר מהתנגשות.** + + הראוט מחזיר ``duplicate_title`` על סמך דחייה של המסד. אם האינדקס + הייחודי לא נבנה — כשל חולף בעלייה, פריסה שהוובאפ לא רץ בה — הדחייה + לעולם לא תגיע, והשמות הכפולים נכנסים בשקט בזמן שהתיעוד מבטיח 409. + + נופלת בלי ``_title_conflict`` בראוט **היצירה**. שני הראוטים האחרים + נבדקים בנפרד — הגיבוי מחווט לשלושה מסלולים, ובדיקה של אחד מהם אינה + מגינה על השניים האחרים. + """ + from webapp import sticky_notes_api + + monkeypatch.setattr(sticky_notes_api, "_TITLE_INDEX_OK", False, raising=False) + board = client.get("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/api/note-boards").get_json()["boards"][0]["id"] + first = client.post(f"/api/sticky-notes/board/{board}", json={"content": "a", "title": "טודו"}) + assert first.status_code == 201 + + res = client.post(f"/api/sticky-notes/board/{board}", json={"content": "b", "title": "טודו"}) + + assert res.status_code == 409 + assert res.get_json()["error"] == "duplicate_title" + assert client.db.sticky_notes.count_documents({"title": "טודו"}) == 1 + + +def test_the_fallback_covers_the_single_put_route(client, monkeypatch): + """נופלת אם הגיבוי מוסר מ-``update_note`` בלבד.""" + from webapp import sticky_notes_api + + monkeypatch.setattr(sticky_notes_api, "_TITLE_INDEX_OK", False, raising=False) + board = client.get("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/api/note-boards").get_json()["boards"][0]["id"] + taken = client.post(f"/api/sticky-notes/board/{board}", json={"content": "a", "title": "טודו"}) + other = client.post(f"/api/sticky-notes/board/{board}", json={"content": "b"}) + assert taken.status_code == 201 and other.status_code == 201 + + res = client.put(f"/api/sticky-notes/note/{other.get_json()['id']}", json={"title": "טודו"}) + + assert res.status_code == 409 + assert res.get_json()["error"] == "duplicate_title" + + +def test_the_fallback_covers_the_batch_route(client, monkeypatch): + """נופלת אם הגיבוי מוסר מ-``batch`` בלבד — וזה **המסלול הרגיל** של הלקוח.""" + from webapp import sticky_notes_api + + monkeypatch.setattr(sticky_notes_api, "_TITLE_INDEX_OK", False, raising=False) + board = client.get("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/api/note-boards").get_json()["boards"][0]["id"] + client.post(f"/api/sticky-notes/board/{board}", json={"content": "a", "title": "טודו"}) + other = client.post(f"/api/sticky-notes/board/{board}", json={"content": "b"}) + + res = client.post( + "/api/sticky-notes/batch", + json={"updates": [{"id": other.get_json()["id"], "title": "טודו"}]}, + ) + result = res.get_json()["results"][0] + + assert result["status"] == 409 + assert result["error"] == "duplicate_title" + + +def test_the_fallback_renaming_a_note_to_its_own_title_is_allowed(client, monkeypatch): + """פתק ששומר על שמו אינו מתנגש בעצמו. + + ``exclude_id`` שייך ל**שאילתה**: סינון התוצאה בדיעבד היה נכשל כאן ברגע + שיש עוד פתק תואם, כי ``find_one`` מחזיר מסמך אחד שרירותי. + """ + from webapp import sticky_notes_api + + monkeypatch.setattr(sticky_notes_api, "_TITLE_INDEX_OK", False, raising=False) + board = client.get("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/api/note-boards").get_json()["boards"][0]["id"] + mine = client.post(f"/api/sticky-notes/board/{board}", json={"content": "a", "title": "טודו"}) + + res = client.put(f"/api/sticky-notes/note/{mine.get_json()['id']}", json={"title": "טודו"}) + + assert res.status_code == 200 + + +def test_the_fallback_is_scoped_to_the_board(client, monkeypatch): + """**הממד שהתיקון כולו עומד עליו: אותו שם בלוח אחר הוא חוקי.** + + בלי ``board_id`` בשאילתת הגיבוי, הבדיקה הזו הופכת את הגיבוי למחמיר + יותר מהאינדקס עצמו — ופתק בלוח שני נדחה בלי סיבה. בדיקה שנוגעת רק + בלוח אחד לא יכולה לתפוס את זה. + """ + from webapp import sticky_notes_api + + monkeypatch.setattr(sticky_notes_api, "_TITLE_INDEX_OK", False, raising=False) + first = client.get("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/api/note-boards").get_json()["boards"][0]["id"] + second = client.post("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/api/note-boards", json={"name": "לוח שני"}).get_json()["board"]["id"] + + a = client.post(f"/api/sticky-notes/board/{first}", json={"content": "a", "title": "טודו"}) + b = client.post(f"/api/sticky-notes/board/{second}", json={"content": "b", "title": "טודו"}) + + assert a.status_code == 201 + assert b.status_code == 201, f"אותו שם בלוח אחר נדחה: {b.get_json()}" + assert client.db.sticky_notes.count_documents({"title": "טודו"}) == 2 + + +def test_the_fallback_costs_nothing_when_the_index_is_confirmed(client, monkeypatch): + """במצב התקין האכיפה היא של המסד, והגיבוי לא עולה שום שאילתה. + + נופלת אם ``_title_conflict`` מריצה ``find_one`` בלי תנאי — כלומר אם + מישהו יסיר את הדגל ויהפוך בדיקה חירומית לעלות קבועה בכל כתיבה. + """ + from webapp import sticky_notes_api + + monkeypatch.setattr(sticky_notes_api, "_TITLE_INDEX_OK", True, raising=False) + board = client.get("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/api/note-boards").get_json()["boards"][0]["id"] + + calls = [] + real = client.db.sticky_notes.find_one + monkeypatch.setattr( + client.db.sticky_notes, "find_one", + lambda *a, **k: (calls.append(a), real(*a, **k))[1], + ) + + res = client.post(f"/api/sticky-notes/board/{board}", json={"content": "a", "title": "טודו"}) + + assert res.status_code == 201 + assert calls == [], f"נורו שאילתות מיותרות: {calls}" + + +def test_a_missing_index_does_not_mark_the_warmup_as_done(monkeypatch): + """``_mark_indexes_ready`` כותב דגל **משותף ברדיס ל-24 שעות**. + + סימון הצלחה כשהאינדקס לא אומת היה נועל כשל חולף אחד ליממה שלמה של + אכיפה שאינה קיימת, בכל התהליכים. בלי הסימון הבקשה הבאה בונה שוב. + + נופלת אם ``_mark_indexes_ready`` נקראת ללא תנאי. + """ + from webapp import sticky_notes_api + + marked = [] + monkeypatch.setattr(sticky_notes_api, "get_db", lambda: _StubDB()) + monkeypatch.setattr(sticky_notes_api, "_INDEX_READY", False, raising=False) + monkeypatch.setattr(sticky_notes_api, "_cache_flag_ready", lambda: False, raising=False) + monkeypatch.setattr(sticky_notes_api, "ensure_title_index", lambda coll: False) + monkeypatch.setattr(sticky_notes_api, "_mark_indexes_ready", lambda **kw: marked.append(kw)) + # ``_ensure_indexes`` כותב ל-``_TITLE_INDEX_OK`` ול-``_INDEX_RETRY_AFTER`` + # דרך ``global``. בלי monkeypatch עליהם הכתיבה שורדת את הבדיקה ודולפת + # לבאות אחריה — שם היא נראית ככשל אקראי שתלוי בסדר ההרצה. + monkeypatch.setattr(sticky_notes_api, "_TITLE_INDEX_OK", sticky_notes_api._TITLE_INDEX_OK, raising=False) + monkeypatch.setattr(sticky_notes_api, "_INDEX_RETRY_AFTER", 0.0, raising=False) + + sticky_notes_api._ensure_indexes() + + assert marked == [], "האתחול סומן כהצלחה בעוד שהאינדקס הקריטי אינו קיים" + assert sticky_notes_api._TITLE_INDEX_OK is False + + +def test_a_failing_build_is_not_retried_on_every_request(monkeypatch): + """**בלי חסם, כשל מתמשך מסריאל את השירות סביב מנעול אחד.** + + ``_ensure_indexes`` תופס את ``_INDEX_READY_LOCK`` ובונה שש קבוצות + אינדקסים. כל עוד האתחול אינו מסומן כהצלחה — וזה בדיוק מה שהתיקון + הקודם קבע — כל בקשה נכנסת שוב לאותו מסלול. + + נופלת בלי ``_INDEX_RETRY_AFTER``. + """ + from webapp import sticky_notes_api + + calls = [] + clock = {"now": 1000.0} + monkeypatch.setattr(sticky_notes_api, "get_db", lambda: _StubDB()) + monkeypatch.setattr(sticky_notes_api, "_INDEX_READY", False, raising=False) + monkeypatch.setattr(sticky_notes_api, "_cache_flag_ready", lambda: False, raising=False) + monkeypatch.setattr(sticky_notes_api, "_TITLE_INDEX_OK", False, raising=False) + monkeypatch.setattr(sticky_notes_api, "_INDEX_RETRY_AFTER", 0.0, raising=False) + monkeypatch.setattr(sticky_notes_api.time, "monotonic", lambda: clock["now"]) + monkeypatch.setattr( + sticky_notes_api, "ensure_title_index", + lambda coll: (calls.append(clock["now"]), False)[1], + ) + + sticky_notes_api._ensure_indexes() + sticky_notes_api._ensure_indexes() + sticky_notes_api._ensure_indexes() + assert len(calls) == 1, f"הבנייה רצה {len(calls)} פעמים בתוך חלון ההמתנה" + + # ...ואחרי שההשהיה חלפה — ניסיון חדש, כי כשל חולף חייב להיפתר לבד + clock["now"] += sticky_notes_api._INDEX_RETRY_SECONDS + 1 + sticky_notes_api._ensure_indexes() + assert len(calls) == 2, "אחרי ההשהיה לא נוסה שוב" + + +def test_the_shared_cache_flag_also_confirms_the_title_index(monkeypatch): + """**worker חדש שיורש את הדגל אינו אמור להריץ שאילתת גיבוי לנצח.** + + ``_mark_indexes_ready`` — הכותב היחיד של הדגל — רץ רק כשאינדקס השם + אומת, ולכן קיום הדגל הוא עדות לכך שהאילוץ חי. בלי הגרירה הזו התהליך + היה מדלג על הבנייה ונשאר עם ``_TITLE_INDEX_OK`` כבוי לתמיד. + + נופלת אם ``_cache_flag_ready`` מדליק רק את ``_INDEX_READY``. + """ + from webapp import sticky_notes_api + + class _Cache: + is_enabled = True + + def get(self, key): + return {"ready": True, "ts": 1} + + monkeypatch.setattr(sticky_notes_api, "cache", _Cache(), raising=False) + monkeypatch.setattr(sticky_notes_api, "_INDEX_READY", False, raising=False) + monkeypatch.setattr(sticky_notes_api, "_INDEX_CACHE_LAST_CHECK", 0.0, raising=False) + monkeypatch.setattr(sticky_notes_api, "_TITLE_INDEX_OK", False, raising=False) + + assert sticky_notes_api._cache_flag_ready() is True + assert sticky_notes_api._TITLE_INDEX_OK is True + + +def test_the_cache_key_version_was_bumped_with_the_meaning_change(): + """מפתח ישן נושא משמעות ישנה. + + לפני השינוי הדגל נכתב **ללא קשר** לאינדקס השם. תחת אותו מפתח, דגל + שנשאר מהגרסה הקודמת היה מתפרש עכשיו כאימות שלא היה — למשך ה-TTL, + ובכל התהליכים. + """ + from webapp import sticky_notes_api + + assert sticky_notes_api._INDEX_READY_CACHE_KEY.endswith("_v2") diff --git a/tests/test_note_boards_mongo.py b/tests/test_note_boards_mongo.py index 4da1342fc..944edd3f9 100644 --- a/tests/test_note_boards_mongo.py +++ b/tests/test_note_boards_mongo.py @@ -456,3 +456,217 @@ def test_admin_is_exempt_from_the_board_quota(mcp_backend, mongo_db, monkeypatch allowed = mcp_backend.create_board_note(7, board_id=board, content="שני", color="#FFFFCC", mode="surface") assert allowed["ok"] is True, allowed assert mongo_db.sticky_notes.count_documents({"board_id": board}) == 2 + + +# ---------- שם ייחודי לפתק בתוך לוח ---------- +# +# שם מזהה פתק אחד בתוך לוח, בדיוק כמו ששם קובץ מזהה קובץ אחד. האכיפה היא +# במסד ולא בקוד, ולכן היא נבדקת כאן ולא מול stub. + + +def test_the_title_index_is_created_with_the_right_partial_filter(indexed_db): + """``$ne`` **אינו נתמך** ב-``partialFilterExpression``. + + נבדק מול מונגו 7.0 ונדחה ב-``Error in specification``; ``$exists`` נתמך. + לכן פתק בלי שם משמיט את השדה במקום לשמור ``""`` — שני פתקים עם מחרוזת + ריקה היו מתנגשים זה בזה. + + ההשוואה היא מול :data:`ONE_TITLE_PER_BOARD_INDEX` ולא מול מילון מוקלד + כאן: מפרט שכתוב פעמיים הוא מפרט שיתפצל, וזו בדיוק הדרך שבה ``board_id`` + נשמט מהפילטר מלכתחילה. + """ + from sticky_notes_target import ONE_TITLE_PER_BOARD_INDEX as WANT + + info = indexed_db.sticky_notes.index_information() + + assert WANT["name"] in info, f"האינדקס לא נוצר. קיימים: {sorted(info)}" + spec = info[WANT["name"]] + assert spec.get("unique") is True + assert spec.get("partialFilterExpression") == WANT["partialFilterExpression"] + assert [(f, v) for f, v in spec["key"]] == WANT["keys"] + + +def test_two_notes_cannot_share_a_title_on_one_board(indexed_db): + notes = indexed_db.sticky_notes + notes.insert_one({"user_id": 7, "board_id": "b1", "title": "טודו", "content": "a"}) + + with pytest.raises(DuplicateKeyError): + notes.insert_one({"user_id": 7, "board_id": "b1", "title": "טודו", "content": "b"}) + + +def test_the_same_title_is_free_on_another_board_and_for_another_user(indexed_db): + notes = indexed_db.sticky_notes + notes.insert_one({"user_id": 7, "board_id": "b1", "title": "טודו", "content": "a"}) + + notes.insert_one({"user_id": 7, "board_id": "b2", "title": "טודו", "content": "b"}) + notes.insert_one({"user_id": 99, "board_id": "b1", "title": "טודו", "content": "c"}) + + assert notes.count_documents({"title": "טודו"}) == 3 + + +def test_notes_without_a_title_are_unaffected(indexed_db): + """**זה מה שהאינדקס היה שובר אילו נבנה בלי הפילטר החלקי.** + + רוב הפתקים הם בלי שם, וללא ה-``partialFilterExpression`` המשתמש היה + יכול להחזיק פתק אחד בלבד ללא שם בכל לוח. + """ + notes = indexed_db.sticky_notes + + for i in range(5): + notes.insert_one({"user_id": 7, "board_id": "b1", "content": f"בלי שם {i}"}) + for i in range(3): + notes.insert_one({"user_id": 7, "file_id": "f1", "content": f"פתק קובץ {i}"}) + + assert notes.count_documents({"user_id": 7}) == 8 + + +def test_clearing_a_title_removes_the_field(indexed_db): + """שם ריק **מוחק** את השדה ולא שומר ``""``. + + בלי זה, שני פתקים ששמם נוקה היו מתנגשים — שניהם ``title: ""`` והאינדקס + כולל אותם, כי ``$exists`` מתקיים גם למחרוזת ריקה. + """ + notes = indexed_db.sticky_notes + notes.insert_one({"_id": 1, "user_id": 7, "board_id": "b1", "title": "ראשון"}) + notes.insert_one({"_id": 2, "user_id": 7, "board_id": "b1", "title": "שני"}) + + notes.update_one({"_id": 1}, {"$unset": {"title": ""}}) + notes.update_one({"_id": 2}, {"$unset": {"title": ""}}) + + assert notes.count_documents({"user_id": 7, "board_id": "b1"}) == 2 + assert "title" not in notes.find_one({"_id": 1}) + + +def test_mcp_refuses_a_duplicate_title(mcp_backend, mongo_db, indexed_db): + """הכלי מחזיר ``duplicate_title`` ולא קורס על החריגה.""" + board = _make_board(mongo_db, user_id=7) + ok = mcp_backend.create_board_note(7, board_id=board, content="a", color="#FFFFCC", mode="surface", title="טודו") + assert ok["ok"] is True + + dup = mcp_backend.create_board_note(7, board_id=board, content="b", color="#FFFFCC", mode="surface", title="טודו") + + assert dup == {"ok": False, "error": "duplicate_title"} + assert mongo_db.sticky_notes.count_documents({"board_id": board}) == 1 + + +def test_two_file_notes_may_share_a_title(indexed_db): + """**הבאג שהאינדקס ייצר, ולא מנע.** + + האינדקס הוא ``(user_id, board_id, title)``, ולפתק **קובץ** אין + ``board_id`` כלל. עם פילטר שמסתכל על ``title`` בלבד, כל פתקי הקבצים + נכנסים אליו עם אותו ערך מפתח חסר — ושני קבצים שונים עם אותו שם פתק + נדחים ב-E11000. נמדד מול מונגו 7.0.14 לפני התיקון: ההוספה השנייה + נכשלה. + + זו גם ההתנהגות שנקבעה במפורש: בקבצים אותו שם הוא גרסה נוספת, לא + התנגשות. הייחודיות היא **בתוך לוח**, ולכן ``board_id`` חייב להיות + בפילטר. + + נופלת בלי ``board_id`` ב-``partialFilterExpression``. + """ + notes = indexed_db.sticky_notes + + notes.insert_one({"user_id": 7, "file_id": "f1", "title": "רשימת מטלות"}) + notes.insert_one({"user_id": 7, "file_id": "f2", "title": "רשימת מטלות"}) + # וגם שניים על **אותו** קובץ — שם אינו מזהה ייחודי בעולם הקבצים + notes.insert_one({"user_id": 7, "file_id": "f1", "title": "רשימת מטלות"}) + + assert notes.count_documents({"user_id": 7, "title": "רשימת מטלות"}) == 3 + + +def test_the_old_index_name_is_superseded_without_losing_uniqueness(indexed_db): + """**זה מסלול השדרוג האמיתי בפרודקשן — והבדיקה שלו היא על הסדר.** + + מונגו אינו יודע לשנות שם של אינדקס, ואינדקס בשם קיים עם אפשרויות + שונות נדחה ב-``code 86``. עדכון "במקום" מחייב אפוא להפיל ואז לבנות, + ובין השניים אין ייחודיות כלל — ושתי בקשות מקבילות יכולות להכניס שני + שמות זהים לאותו לוח, ואז הבנייה החדשה נכשלת והאכיפה נשארת מושבתת. + + השם הממוספר הופך את הסדר: בונים את החדש, ורק אז מפילים את הישן. + הבדיקה עוקבת אחרי **כל** פעולת אינדקס ומוודאת שהייחודיות נאכפה גם + אחריה. נופלת אם ההפלה מקדימה את הבנייה. + """ + from sticky_notes_target import ONE_TITLE_PER_BOARD_INDEX as WANT + from sticky_notes_target import SUPERSEDED_TITLE_INDEX_NAMES, ensure_title_index + + notes = indexed_db.sticky_notes + old_name = SUPERSEDED_TITLE_INDEX_NAMES[0] + notes.drop_index(WANT["name"]) + notes.create_index( + [("user_id", 1), ("board_id", 1), ("title", 1)], + name=old_name, unique=True, partialFilterExpression={"title": {"$exists": True}}, + ) + + def uniqueness_is_live() -> bool: + notes.delete_many({"user_id": 4242}) + notes.insert_one({"user_id": 4242, "board_id": "b", "title": "בדיקה"}) + try: + notes.insert_one({"user_id": 4242, "board_id": "b", "title": "בדיקה"}) + live = False + except DuplicateKeyError: + live = True + notes.delete_many({"user_id": 4242}) + return live + + seen = [] + + class _Watched: + """עוטף את האוסף ובודק אכיפה אחרי כל פעולת אינדקס.""" + + def index_information(self): + return notes.index_information() + + def create_index(self, *a, **k): + out = notes.create_index(*a, **k) + seen.append((f"create {k.get('name')}", uniqueness_is_live())) + return out + + def drop_index(self, name): + out = notes.drop_index(name) + seen.append((f"drop {name}", uniqueness_is_live())) + return out + + assert ensure_title_index(_Watched()) is True + + assert seen, "לא בוצעה שום פעולת אינדקס — השדרוג לא רץ" + gaps = [step for step, live in seen if not live] + assert not gaps, f"היה רגע בלי ייחודיות אחרי: {gaps} (הרצף: {seen})" + + info = notes.index_information() + assert WANT["name"] in info + assert old_name not in info, "האינדקס הישן שרד — שני אינדקסים על אותם מפתחות" + + +def test_the_title_index_upgrades_an_older_version_of_itself(indexed_db): + """**בלי היישוב הזה התיקון לא מגיע לפרודקשן.** + + בפרודקשן כבר יושב האינדקס עם הפילטר הישן. מונגו דוחה יצירה מחדש של + אינדקס בשם קיים עם אפשרויות שונות — ``code 86``, נמדד — והדחייה + נבלעת ב-``except Exception``. כלומר בלי ההפלה-ובנייה, הקוד היה + מתעדכן וההתנהגות לא. + + נופלת אם ``ensure_title_index`` רק יוצר ולא משווה. + """ + from sticky_notes_target import ONE_TITLE_PER_BOARD_INDEX, ensure_title_index + + notes = indexed_db.sticky_notes + name = ONE_TITLE_PER_BOARD_INDEX["name"] + + # משחזרים את המצב שבשדה: אותו שם, פילטר ישן + notes.drop_index(name) + notes.create_index( + [("user_id", 1), ("board_id", 1), ("title", 1)], + name=name, unique=True, partialFilterExpression={"title": {"$exists": True}}, + ) + assert notes.index_information()[name]["partialFilterExpression"] == {"title": {"$exists": True}} + + assert ensure_title_index(notes) is True + + assert ( + notes.index_information()[name]["partialFilterExpression"] + == ONE_TITLE_PER_BOARD_INDEX["partialFilterExpression"] + ) + # ולראיה שההתנהגות באמת השתנתה, ולא רק המפרט + notes.insert_one({"user_id": 7, "file_id": "f1", "title": "אחרי שדרוג"}) + notes.insert_one({"user_id": 7, "file_id": "f2", "title": "אחרי שדרוג"}) + assert notes.count_documents({"title": "אחרי שדרוג"}) == 2 diff --git a/tests/test_sticky_notes_target.py b/tests/test_sticky_notes_target.py index 923a65618..bbd017e70 100644 --- a/tests/test_sticky_notes_target.py +++ b/tests/test_sticky_notes_target.py @@ -184,3 +184,44 @@ def test_failed_count_is_rejected_not_waved_through(): def test_admin_exempt_even_when_count_failed(): """הפטור קודם לכל בדיקה אחרת — אדמין לא נחסם בגלל מסד שמתקשה.""" check_note_quota(None, 200, is_admin=True) + + +# ---------- נרמול שם פתק ---------- + + +def test_title_is_trimmed_and_collapsed_to_one_line(): + from sticky_notes_target import normalize_note_title + + assert normalize_note_title(" רשימת מטלות \n שנייה ") == "רשימת מטלות שנייה" + + +def test_blank_title_means_no_title(): + from sticky_notes_target import normalize_note_title + + assert normalize_note_title("") == "" + assert normalize_note_title(" \t\n ") == "" + assert normalize_note_title(None) == "" + + +def test_title_is_capped(): + from sticky_notes_target import MAX_NOTE_TITLE, normalize_note_title + + assert len(normalize_note_title("א" * 500)) == MAX_NOTE_TITLE + + +@pytest.mark.parametrize( + "value", + [["a", "b"], {"x": 1}, 42, 3.5, True, object()], +) +def test_only_a_string_can_be_a_title(value): + """**ייצוג פייתון פנימי לא נכנס למסד כשם פתק.** + + ``str(value)`` על גוף JSON שרירותי הופך ``["a", "b"]`` למחרוזת + ``"['a', 'b']"`` — שם מעוות שנשמר, מוצג למשתמש, ותופס מקום באינדקס + הייחודי. כל טיפוס שאינו ``str`` נחשב "אין שם", כמו מחרוזת ריקה. + + נופלת אם הנרמול חוזר ל-``str(value or "")``. + """ + from sticky_notes_target import normalize_note_title + + assert normalize_note_title(value) == "" diff --git a/webapp/static/css/note-boards.css b/webapp/static/css/note-boards.css index 8b66f8571..72e5bb40d 100644 --- a/webapp/static/css/note-boards.css +++ b/webapp/static/css/note-boards.css @@ -108,9 +108,38 @@ /* עמוד המשטח מנצל את כל הרוחב ורוב הגובה. ברשימת הלוחות עמודה צרה היא הנכונה, אבל משטח עבודה שמוגבל ל-1100px משאיר שוליים מתים משני הצדדים ועוד 4rem ריקים מתחתיו. */ +/* עמוד המשטח מנצל כמעט את כל המסך. נמדד לפני: שוליים 12/12 בצדדים אבל + 168px מתים למעלה, והמשטח חרג מהמסך ב-40px — כי ``100vh - 130px`` היה + ניחוש קשיח שלא תאם את המיקום בפועל. */ .boards-page.is-board-surface { max-width: none; - padding: .75rem .75rem 1rem; + padding: .4rem .5rem .5rem; +} + +/* סרגל הכלים צף **מעל** המשטח במקום לשבת מעליו. + כך הלוח מתחיל מיד מתחת לנאבבר, והשורה שתפסה 40 פיקסלים מלאים + מפסיקה לגזול שטח מהלוח עצמו. + (העוגן ל-``position: absolute`` הזה הוא ``.board-surface-wrap``, שכבר + מוגדר ``position: relative`` בכלל הבסיס שלו בהמשך הקובץ.) */ +.is-board-surface .board-toolbar { + position: absolute; + inset-block-start: .5rem; + inset-inline-end: .6rem; + inset-inline-start: auto; + z-index: 5; + margin: 0; + gap: .4rem; + width: auto; + padding: .25rem .35rem; + border-radius: 10px; + background: color-mix(in srgb, var(--bg-primary, #fff) 82%, transparent); + backdrop-filter: blur(6px); + border: 1px solid var(--glass-border, rgba(0, 0, 0, .08)); +} +.is-board-surface .board-title-input { + font-size: 1rem; + padding: .1rem .4rem; + max-width: 40vw; } .board-surface-wrap { @@ -119,17 +148,29 @@ .board-surface { position: relative; /* פתק במצב surface הוא absolute יחסית לזה */ - min-height: calc(100vh - 130px); + /* ``--board-top`` נמדד ב-JS מהמיקום בפועל, במקום המספר הקשיח שפספס + ב-40px ויצר גלילה מיותרת. הערך הקשיח נשאר כנפילה חיננית בלבד. */ + /* ``vh`` קודם כנפילה: דפדפן שאינו מכיר ``dvh`` מתעלם מההצהרה כולה, + ובלי השורה הזו המשטח היה מאבד את גובה המסך ומתכווץ לתוכן. */ + min-height: calc(100vh - var(--board-top, 130px) - .5rem); + min-height: calc(100dvh - var(--board-top, 130px) - .5rem); border-radius: 16px; overflow: auto; border: 1px solid var(--glass-border, rgba(0, 0, 0, .08)); - /* רשת הנקודות. השקיפות נמוכה במכוון — בשיעור כזה זה כבר לא תמונה אלא - מרקם, כמו נייר משבצות מתחת לפתק אמיתי. */ + /* רשת הנקודות. + * + * הצבע נגזר מ-``--text-primary`` של הערכה **הפעילה**, ולא משאילתת מדיה. + * הגרסה הקודמת השתמשה ב-``prefers-color-scheme``, שהיא הגדרת מערכת + * ההפעלה — בעוד שהאפליקציה בוחרת ערכה דרך ``data-theme`` על ````. + * כלומר צבע הנקודות נקבע לפי משהו שאין לו קשר לערכה שנבחרה: בערכה + * בהירה הן נעלמו לגמרי, ובכהות הן השתנו באקראי. + * + * ``color-mix`` כבר בשימוש בריפו (``codemirror-custom.css``). */ background-color: var(--bg-primary, #fff); background-image: radial-gradient( circle, - var(--board-dot, rgba(103, 126, 234, .22)) 1px, + var(--board-dot, color-mix(in srgb, var(--text-primary, #1f2933) 26%, transparent)) 1px, transparent 1px ); background-size: 24px 24px; @@ -184,12 +225,41 @@ .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 - ); - } +/* אין כאן שאילתת מדיה בכוונה: ``--text-primary`` כבר מתהפך עם הערכה, + ולכן הנקודות מתאימות את עצמן לכל ערכה — כולל ערכות שהמשתמש יצר. */ + +/* קישור כאייקון — בלי טקסט. שטח נגיעה מלא למרות שהאייקון קטן. */ +.board-icon-link { + display: inline-flex; align-items: center; justify-content: center; + width: 34px; height: 34px; flex: 0 0 auto; + border-radius: 8px; + border: 1px solid var(--glass-border, rgba(0, 0, 0, .12)); + background: var(--bg-secondary, #f7fafc); + color: var(--text-primary, inherit); + text-decoration: none; +} +.board-icon-link:hover { background: var(--glass-hover, rgba(103, 126, 234, .12)); } +.board-icon-link:focus-visible { outline: 2px solid var(--primary, #667eea); outline-offset: 2px; } + +/* ---------- הגדרות הלוח ---------- */ + +.board-settings-modal { position: fixed; inset: 0; z-index: 1200; display: flex; align-items: center; justify-content: center; } +.board-settings-backdrop { position: absolute; inset: 0; background: rgba(0, 0, 0, .45); } +.board-settings-card { + position: relative; + max-width: 26rem; width: calc(100% - 2rem); + background: var(--bg-primary, #fff); color: var(--text-primary, #1f2933); + border: 1px solid var(--glass-border, rgba(0, 0, 0, .12)); + border-radius: 14px; padding: 1.2rem 1.3rem; + box-shadow: 0 20px 50px rgba(0, 0, 0, .3); +} +.board-settings-card h2 { margin: 0 0 1rem; font-size: 1.15rem; } +.board-setting-row { display: flex; gap: .6rem; align-items: flex-start; cursor: pointer; } +.board-setting-row input { margin-top: .3rem; flex: 0 0 auto; } +.board-setting-row small { display: block; color: var(--text-muted, #667); font-size: .85rem; margin-top: .15rem; } +.board-setting-note { + margin: .9rem 0 0; padding: .6rem .75rem; border-radius: 8px; + font-size: .85rem; color: var(--text-muted, #667); + background: color-mix(in srgb, var(--text-primary, #1f2933) 6%, transparent); } +.board-settings-actions { display: flex; justify-content: flex-start; margin-top: 1rem; } diff --git a/webapp/static/css/sticky-notes.css b/webapp/static/css/sticky-notes.css index 5e6eb72c2..e45da4e2d 100644 --- a/webapp/static/css/sticky-notes.css +++ b/webapp/static/css/sticky-notes.css @@ -15,18 +15,32 @@ .sticky-note.is-floating{ position: fixed; } +/* הפתק הוא עמודת flex: כותרת בגובה טבעי, וגוף שממלא את השאר. + * + * זה מה שמוציא מהמשוואה את המספר הקסום 42. עד היום הכותרת **חויבה** + * להישאר בגובה קבוע, כי גוף הפתק חושב כ-``calc(100% - 42px)`` והמיזעור + * נקבע כ-``height: 42px``. כלומר כל שינוי בכותרת — כפתור נוסף, שורה + * שנשברת — היה חותך את הגוף בשקט. עכשיו הגובה נגזר מהתוכן. */ +.sticky-note{ display:flex; flex-direction: column; } .sticky-note.is-minimized .sticky-note-content{ display:none; } -.sticky-note.is-minimized{ height: 42px !important; } +/* ``height: auto`` דורס את ה-``style.height`` שנשמר, ולכן פתק ממוזער + מתכווץ בדיוק לגובה הכותרת שלו — שורה אחת או שתיים, לפי מה שנכנס. */ +.sticky-note.is-minimized{ height: auto !important; } .sticky-note-header{ display:flex; align-items:center; justify-content:space-between; padding: 6px 8px; cursor: move; user-select:none; background: rgba(0,0,0,.06); + flex: 0 0 auto; + /* גלישה לשורה שנייה כשאין מקום. ראו את ההסבר אצל ``.sticky-note-title`` + — זה מה שמאפשר לשם ולשישה כפתורים לחיות יחד בפתק של 200 פיקסלים. */ + flex-wrap: wrap; } .sticky-note-drag{ width: 100%; height: 16px; cursor: move; touch-action: none; } /* מרווח בין הכפתורים: הם קטנים, וצמודים זה לזה קשה לפגוע בהם באצבע. - ההגדלה היא במרווח ובריפוד אופקי בלבד — גובה הכותרת לא משתנה, ולכן - ``calc(100% - 42px)`` של גוף הפתק נשאר נכון. */ + גובה הכותרת **כן** משתנה — היא גולשת לשתי שורות בפתק צר — וזה בסדר: + הגוף הוא ``flex: 1 1 auto`` ולוקח את מה שנשאר, ולא ``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; } @@ -40,7 +54,12 @@ .sticky-note-btn:hover{ color:#000; } .sticky-note-content{ - width: 100%; height: calc(100% - 42px); + width: 100%; + /* ``flex: 1`` במקום ``calc(100% - 42px)``: הגוף לוקח את מה שנשאר אחרי + הכותרת, יהיה גובהה אשר יהיה. ``min-height: 0`` הכרחי — בלעדיו פריט + flex לא מתכווץ מתחת לגודל התוכן שלו, וטקסט ארוך היה מותח את הפתק. */ + flex: 1 1 auto; + min-height: 0; border: 0; outline: none; resize: none; padding: 8px; font-size: 14px; line-height: 1.35; background: transparent; color: #222; @@ -78,12 +97,16 @@ /* ---------- צ'קבוקסים בתוך פתק ---------- */ -/* ``flex: 1`` לא עשה כלום — ``.sticky-note`` אינו קונטיינר flex — ולכן - לתצוגה לא היה גובה משלה כלל. עכשיו היא בדיוק אותו ארגז כמו ה-textarea - שהיא מחליפה, כולל גלילה פנימית: פתק שגדל לבד מזיז את מה שסביבו. */ +/* התצוגה היא בדיוק אותו ארגז כמו ה-textarea שהיא מחליפה, כולל גלילה + פנימית: פתק שגדל לבד מזיז את מה שסביבו. + (היסטוריה: פעם ``flex: 1`` כאן לא עשה כלום, כי ``.sticky-note`` לא היה + קונטיינר flex — ואז לתצוגה לא היה גובה משלה כלל. היום הוא כן, וזה מה + שמחזיק את הגובה.) */ .sticky-note-tasks { width: 100%; - height: calc(100% - 42px); + /* אותו ארגז בדיוק כמו ה-textarea שהיא מחליפה. ראו שם. */ + flex: 1 1 auto; + min-height: 0; overflow-y: auto; padding: 8px; font-size: 14px; @@ -142,3 +165,53 @@ outline: 2px solid #dc3545; outline-offset: 1px; } + +/* כפתור ביטול מושבת — הכפתור אומר את האמת על מה שהוא יכול לעשות */ +.sticky-note-btn[disabled] { opacity: .3; cursor: default; } + +/* חיווי העתקה. כשל חייב להיראות, לא רק הצלחה. */ +.sticky-note-copy.is-copy-done { background: #d1fae5; color: #065f46; border-radius: 6px; } +.sticky-note-copy.is-copy-fail { background: #fee2e2; color: #b91c1c; border-radius: 6px; } + +/* שם הפתק — בצד השני של שורת הכפתורים, וגלוי גם במיזעור. + זו כל הסיבה שהוא בכותרת ולא בגוף: פתק ממוזער מציג רק את השורה הזו. */ +.sticky-note-title { + flex: 1 1 auto; + min-width: 0; + border: 0; + background: transparent; + font: inherit; + font-size: 13px; + font-weight: 600; + color: inherit; + padding: 0 4px; + text-overflow: ellipsis; +} +.sticky-note-title::placeholder { font-weight: 400; opacity: .45; } +/* פוקוס נראה לעין. רקע ב-5% אינו חיווי — הוא כמעט בלתי נראה על צהוב, + וזה שדה עריכה שמגיעים אליו ב-Tab. ``outline`` אמיתי, בלי לבטל אותו. */ +.sticky-note-title:focus-visible, +.sticky-note-title:focus { + outline: 2px solid #1d4ed8; + outline-offset: 1px; + background: rgba(255, 255, 255, .55); + border-radius: 4px; +} +.sticky-note-title.is-duplicate { color: #b91c1c; text-decoration: underline wavy #dc3545; } + +/* הכותרת היא flex, וכעת יש בה שלושה חלקים: כפתורים, ידית גרירה, ושם. + * + * **הבעיה שנמדדה:** שישה כפתורים בני 26 פיקסלים ועוד חמישה רווחים של 10 + * הם 190 פיקסלים קשיחים. פתק ברירת המחדל במובייל הוא 200, ובדסקטופ 260 — + * כלומר לשם נשארו 8 ו-16 פיקסלים בהתאמה, ובמובייל הכותרת אף חרגה מהפתק + * ב-44 פיקסלים ו-``overflow: hidden`` חתך כפתורים. (החריגה קדמה לשם: + * היא נכנסה עם כפתורי הביטול וההעתקה, ורק השם הפך אותה לגלויה.) + * + * **הפתרון:** ``min-width`` אמיתי לשם + ``flex-wrap`` על הכותרת. כשאין + * מקום לשלושתם בשורה אחת, השם יורד לשורה שנייה ומקבל את כל הרוחב. אין + * כאן שאילתת מדיה, כי הקובע הוא רוחב **הפתק** ולא רוחב המסך — פתק צר + * במסך רחב סובל בדיוק מאותה בעיה. */ +.sticky-note-header { gap: 6px; } +.sticky-note-drag { width: 24px; flex: 0 0 auto; } +.sticky-note-actions { flex: 0 0 auto; } +.sticky-note-title { flex: 1 1 96px; min-width: 96px; } diff --git a/webapp/static/js/sticky-notes.js b/webapp/static/js/sticky-notes.js index 40b041758..b970c5cc2 100644 --- a/webapp/static/js/sticky-notes.js +++ b/webapp/static/js/sticky-notes.js @@ -150,6 +150,14 @@ //: שורת משימה: ``- [ ] טקסט`` או ``* [x] טקסט``, עם הזחה כלשהי. //: אותה תבנית בדיוק כמו ``_TASK_RE`` ב-``sticky_notes_tasks.py`` — שם //: מתבצעת הכתיבה, וכאן רק התצוגה. סטייה ביניהן הייתה מזיזה סידורים. + //: כמה זמן של חוסר-הקלדה סוגר "התפרצות" והופך אותה לצעד אחד לביטול. + const UNDO_BURST_MS = 600; + //: עומק מחסנית הביטול לכל פתק. בזיכרון בלבד. + const UNDO_MAX_STEPS = 50; + //: מרווח פנוי מתחת לפתק האחרון במצב אינסופי — תמיד יש לאן לגרור. + const INFINITE_PAD = 700; + //: ובכיבוי, רק נשימה קטנה מתחת לפתק האחרון. + const SURFACE_TAIL_PAD = 24; const TASK_LINE_RE = /^([ \t]*[-*][ \t]\[)([ xX])(\].*)$/; const AUTO_SAVE_FORCE_INTERVAL_MS = 3500; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24h @@ -203,6 +211,11 @@ this._cacheKey = this.boardId ? `sticky-notes:board:${this.boardId}` : `sticky-notes:${String(this.fileId)}`; + // גלילה אינסופית: כמה מקום פנוי מתחת לפתק האחרון. הלוח **תמיד** + // נמדד עד הפתק הרחוק ביותר; הכיבוי רק מפסיק להוסיף מרווח מעבר לו, + // ולעולם אינו מקצר את הלוח מתחת לתוכן שכבר עליו — כלומר אף פתק לא + // "מתכווץ פנימה" ואף מיקום לא הולך לאיבוד. + this.infiniteScroll = ('infiniteScroll' in opts) ? !!opts.infiniteScroll : false; this._renderedFromCache = false; this._pendingSeq = new Map(); // noteId -> monotonic version of pending edits this._init(); @@ -321,21 +334,6 @@ const noteX = isMobile ? 80 : 120; const noteY = scroll.y + (isMobile ? 80 : 120); - // עיגון אוטומטי לאלמנט הקרוב עם [data-source-line] (רלוונטי ל־MD preview). - // הפתק יהפוך ל־anchored (position:absolute, doc-Y) ולכן יזוז יחד עם התוכן - // כאשר ::: details נפתח/נסגר, תמונות נטענות, וכו'. - // בלוח אין שורות מקור, ולכן החיפוש כולו מדולג — ולא "רץ ומחזיר null". - let autoAnchorLine = null; - 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 = this.boardId ? { content: '', position: { x: noteX, y: noteY }, @@ -349,9 +347,20 @@ position: { x: noteX, y: noteY }, size: { width: isMobile ? 200 : 260, height: isMobile ? 160 : 200 }, color: '#FFFFCC', - line_start: autoAnchorLine, - // חזרה להתנהגות הישנה: ללא עיגון לכותרות כברירת מחדל - anchor_id: '', + // **פתק חדש צף עם המשתמש.** זו זרימת העבודה: כותבים פתק תוך עיון + // בקובץ, גוללים למטה, וממשיכים לכתוב — ורק בסוף נועצים אותו במקום + // שמתאים. + // + // עד כאן ``line_start`` נקבע אוטומטית לשורת המקור הקרובה, וזה + // הספיק כדי ש-``_resolveMode`` יחזיר ``anchored`` — כלומר + // ``position: absolute``, שנשאר במקומו במסמך ולא ממשיך עם המסך. + // נמדד: פתק חדש זז ‎-900px‎ בגלילה של 900, ורק ``pin`` ואז ``unpin`` + // הפכו אותו לצף. ההערה שהייתה כאן כבר הצהירה "ללא עיגון כברירת + // מחדל" — בוטל רק העיגון לכותרות, ועיגון השורה נשאר. + line_start: null, + // הסנטינל המפורש הכרחי: בלעדיו המיגרציה הרכה ב-``_renderNote`` + // הייתה מעגנת את הפתק מחדש בטעינה הבאה ומחזירה את הבאג. + anchor_id: FLOATING_SENTINEL, anchor_text: undefined }; const resp = await fetch(this._scopeUrl, { @@ -397,6 +406,12 @@ if (note.color) el.style.backgroundColor = note.color; const header = createEl('div', 'sticky-note-header'); + // שם הפתק, בצד השני של שורת הכפתורים. הוא נשאר גלוי גם כשהפתק + // ממוזער — וזה כל הרעיון: השורה הריקה של הכותרת מקבלת משמעות. + const titleInput = createEl('input', 'sticky-note-title', { + type: 'text', placeholder: 'שם', 'aria-label': 'שם הפתק', maxlength: '80' + }); + titleInput.value = String(note.title || ''); const drag = createEl('div', 'sticky-note-drag'); const actions = createEl('div', 'sticky-note-actions'); const isPinnedInitial = note.anchor_id === PIN_SENTINEL; @@ -404,8 +419,39 @@ const minimizeBtn = createEl('button', 'sticky-note-btn', { title: 'מזער' }); minimizeBtn.textContent = '—'; const remindBtn = createEl('button', 'sticky-note-btn', { title: 'קבע תזכורת' }); remindBtn.textContent = '🔔'; const deleteBtn = createEl('button', 'sticky-note-btn', { title: 'מחיקה' }); deleteBtn.textContent = '×'; - actions.appendChild(pinBtn); actions.appendChild(remindBtn); actions.appendChild(minimizeBtn); actions.appendChild(deleteBtn); - header.appendChild(drag); header.appendChild(actions); + // ביטול פעולה. אין לפתקים היסטוריית גרסאות, ולכן בחירת-הכל ואז הדבקה + // בטעות מוחקת הכל בלי דרך חזרה. הכפתור מושבת כשאין מה לבטל — כפתור + // שנראה זמין ולא עושה כלום הוא בדיוק סוג השקר שאנחנו נמנעים ממנו. + const undoBtn = createEl('button', 'sticky-note-btn sticky-note-undo', { title: 'אין מה לבטל' }); + undoBtn.textContent = '↶'; + undoBtn.disabled = true; + const copyBtn = createEl('button', 'sticky-note-btn sticky-note-copy', { title: 'העתק את תוכן הפתק' }); + copyBtn.textContent = '⧉'; + actions.appendChild(pinBtn); actions.appendChild(remindBtn); actions.appendChild(undoBtn); + actions.appendChild(copyBtn); actions.appendChild(minimizeBtn); actions.appendChild(deleteBtn); + header.appendChild(actions); header.appendChild(drag); header.appendChild(titleInput); + + titleInput.addEventListener('input', () => { + // שם חדש הוא ניסיון חדש. הסימון הישן שייך לערך שכבר לא בשדה, + // והשארתו הייתה מציגה שגיאה על טקסט שטרם נבדק. + this._markTitleConflict(el.dataset.noteId, false); + this._queueSave(el, { title: titleInput.value }); + }); + // 409 מהשרת פירושו שהשם תפוס בלוח הזה. חיווי גלוי — לא כתיבה + // שנראית שנשמרה ולא נשמרה. + titleInput.addEventListener('blur', async () => { + try { await this._flushFor(el); } catch(_) {} + this._syncTitleConflict(el); + }); + + undoBtn.addEventListener('click', (ev) => { + try { ev.stopPropagation(); ev.preventDefault(); } catch(_) {} + this._undoLastChange(el); + }); + copyBtn.addEventListener('click', (ev) => { + try { ev.stopPropagation(); ev.preventDefault(); } catch(_) {} + this._copyNoteContent(el); + }); pinBtn.addEventListener('click', (ev) => { try { ev.stopPropagation(); ev.preventDefault(); } catch(_) {} this._toggleAnchor(el); @@ -473,6 +519,7 @@ }); textarea.addEventListener('input', () => { + this._recordUndoBurst(el, textarea); this._checkContentLength(el, textarea.value); this._queueSave(el, { content: textarea.value }); }); @@ -480,10 +527,15 @@ try { // תמיכה במקלדות מובייל/טלגרם שמשדרות key=Unidentified במקום Enter if ((ev.key === 'Enter' || ev.keyCode === 13) && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) { + // צילום **לפני** השינוי. המשך הרשימה כותב ל-``textarea.value`` + // ישירות, וכתיבה תכנותית אינה משדרת ``input`` — כלומר הביטול + // לא ראה את השינוי הזה כלל, והצעד היה בלתי הפיך. + const beforeChange = textarea.value; const result = this._handleListContinuation(textarea); if (result === false) { // כפול אנטר → יציאה מרשימה, הטקסטריה כבר עודכנה ev.preventDefault(); + this._noteProgrammaticEdit(el, textarea, beforeChange); this._queueSave(el, { content: textarea.value }); } else if (typeof result === 'string') { ev.preventDefault(); @@ -493,6 +545,7 @@ textarea.value = before + result + after; const newPos = start + result.length; textarea.selectionStart = textarea.selectionEnd = newPos; + this._noteProgrammaticEdit(el, textarea, beforeChange); this._queueSave(el, { content: textarea.value }); } } @@ -522,9 +575,12 @@ // תצוגת המשימות נבנית מהתוכן שנטען. אם אין צ'קבוקסים היא נשארת מוסתרת // וה-textarea מוצג כרגיל — כלומר פתק רגיל אינו משתנה כלל. this._syncTaskView(el); + this._undoState(el); // מצלם את נקודת המוצא כבר עכשיו + this._syncUndoButton(el); this._applyPositionMode(el, note, { initial: true }); this._reflowWithinViewport(el); this._updateAnchoredNotePosition(el, note); + this._updateSurfaceExtent(); // ── מיגרציה רכה לפתקים ישנים ב־MD preview ── // פתקים שנוצרו לפני תמיכת ה־[data-source-line] נשמרו בלי עוגן וחיו כ־ @@ -691,6 +747,79 @@ // בלי זה החלק העליון של הפתק — שהוא גם ידית הגרירה — יכול לעלות מעל // הקצה, ואז אין שום דרך להחזיר אותו: הגרירה היא לחיצה על הכותרת, // והכותרת מחוץ למסך. + // פתק צף (``position: fixed``) נמדד מול אזור התצוגה ולא מול המשטח. + // + // בלי זה אפשר היה לגרור אותו כך שהכותרת — שהיא ידית הגרירה — יוצאת + // מחוץ למסך. הוא אמנם חזר לגבולות בלחיצה הבאה, בזכות + // ``_reflowWithinViewport`` שרץ בפוקוס, אבל "נעלם ואז חוזר" הוא לא + // התנהגות, הוא תיקון שקורה במקרה. + _clampToViewport(el, x, y){ + try { + const vp = window.visualViewport; + const vpW = Math.max(100, (vp ? vp.width : window.innerWidth) || window.innerWidth || 320); + const vpH = Math.max(100, (vp ? vp.height : window.innerHeight) || window.innerHeight || 320); + const r = el.getBoundingClientRect(); + const w = Math.round(r.width) || 260; + const h = Math.round(r.height) || 200; + return { + x: clamp(Math.round(x), 12, Math.max(12, vpW - w - 12)), + y: clamp(Math.round(y), 60, Math.max(60, vpH - h - 20)) + }; + } catch(_) { return { x, y }; } + } + + // גובה המשטח נגזר מהתוכן שעליו, ולא ממספר קבוע. + // + // ``INFINITE_PAD`` הוא המרווח שנשאר פנוי מתחת לפתק האחרון כשהמצב + // האינסופי דלוק — כך תמיד יש לאן לגרור. בכיבוי המרווח מתאפס, אבל + // הגובה עדיין נמדד עד הפתק הרחוק ביותר. + // מדידה אחת לכל פריים, ולא אחת לכל פתק. + // + // ``_renderNote`` קורא לכאן על כל פתק, והמדידה עצמה עוברת על **כל** + // הפתקים עם ``getBoundingClientRect`` — כלומר טעינה היא ריבועית, וכל + // קריאה כזו מכריחה את הדפדפן לחשב פריסה מחדש. נמדד בכרומיום לפני + // האיחוד: 10 פתקים → 65 קריאות, 30 → 495, 60 → 1,890. אחרי: 20, 60, + // 120 — עם אותו ``min-height`` בדיוק. בתקרת הלוח (200 פתקים) ההפרש + // הוא בין מאות לעשרות אלפים. + _updateSurfaceExtent(){ + if (typeof requestAnimationFrame !== 'function') { + // סביבת בדיקה בלי דפדפן — מודדים מיד, כדי שההתנהגות תישאר + // ניתנת לבדיקה סינכרונית. + this._applySurfaceExtent(); + return; + } + if (this._extentFrame) return; + this._extentFrame = requestAnimationFrame(() => { + this._extentFrame = null; + this._applySurfaceExtent(); + }); + } + + _applySurfaceExtent(){ + try { + const surface = this.container; + if (!this.boardId || !surface || !surface.style) return; + let bottom = 0; + this.notes.forEach((entry) => { + const el = entry && entry.el; + if (!el || !el.classList || !el.classList.contains('is-pinned')) return; + const top = parseInt(el.style.top || '0', 10) || 0; + const h = Math.round(el.getBoundingClientRect().height) || 0; + if (top + h > bottom) bottom = top + h; + }); + const pad = this.infiniteScroll ? INFINITE_PAD : SURFACE_TAIL_PAD; + const needed = bottom + pad; + // ``min-height`` ולא ``height``: הכלל ב-CSS ממלא את המסך, וכאן + // רק מרחיבים מעבר לו. הקטן מביניהם לא מקצר את הלוח. + surface.style.minHeight = needed > 0 ? `max(var(--board-fill, 0px), ${needed}px)` : ''; + } catch(_) {} + } + + setInfiniteScroll(on){ + this.infiniteScroll = !!on; + this._updateSurfaceExtent(); + } + _clampToSurface(el, x, y, note){ try { const parent = this.container; @@ -731,14 +860,9 @@ const active = !!isActive; pinBtn.classList.toggle('is-active', active); pinBtn.setAttribute('aria-pressed', active ? 'true' : 'false'); - if (this.boardId) { - // בלוח הכפתור מחליף בין "יושב על הלוח" (ברירת המחדל, כבוי) לבין - // "צף מול המסך" (דלוק). "נעיצה למסמך" היא מושג של קובץ ואין לה - // מובן כאן — ולכן גם אין סיבה שהכפתור יידלק כבר בפתיחה. - pinBtn.title = active ? 'החזר את הפתק ללוח' : 'הצמד את הפתק למסך'; - } else { - pinBtn.title = active ? 'בטל נעיצה' : 'נעץ פתק למסמך'; - } + // אותה משמעות, אותו צבע, בשני המשטחים + const anchor = this.boardId ? 'ללוח' : 'למסמך'; + pinBtn.title = active ? `הפתק מוצמד ${anchor} — לחצו כדי לשחרר` : `הצמידו את הפתק ${anchor}`; } // ----- צ'קבוקסים ----- @@ -828,6 +952,238 @@ } catch(_) {} } + // שם תפוס — מסומן על השדה עצמו, לפי **קוד השגיאה מהשרת** בלבד. + // + // הגרסה הראשונה הסיקה את זה מ-``has-save-error``, שהוא סימון גנרי: + // כל כשל שמירה — חריגת מכסה, נפילת רשת, 500 — היה מסמן את השם + // כתפוס. וגם ההפך: מסלול ה-409 אינו מסמן ``has-save-error`` כלל, + // ולכן התנגשות שם אמיתית לא הופיעה אף פעם. סימון שמופיע בכל מצב + // חוץ מהנכון הוא גרוע מאין סימון. + _markTitleConflict(id, on){ + try { + const entry = this.notes.get(String(id)); + if (!entry || !entry.el) return; + entry.titleConflict = !!on; + this._syncTitleConflict(entry.el); + } catch(_) {} + } + + _syncTitleConflict(el){ + try { + const input = el.querySelector('.sticky-note-title'); + if (!input) return; + const entry = this._getEntry(el); + const conflicted = !!(entry && entry.titleConflict); + input.classList.toggle('is-duplicate', conflicted); + input.title = conflicted ? 'שם כזה כבר קיים בלוח הזה' : 'שם הפתק'; + } catch(_) {} + } + + // ----- ביטול פעולה ----- + // + // לפתקים אין היסטוריית גרסאות בשרת. התרחיש שזה מגן מפניו הוא ממשי: + // בוחרים את כל הטקסט כדי להעתיק, ולוחצים "הדבק" במקום "העתק" — וכל + // הפתק נמחק. בלי מנגנון כזה אין שום דרך חזרה. + // + // **טווח מכוון: הסשן הנוכחי.** המחסנית חיה בזיכרון ולא שורדת רענון. + // התרחיש שהיא מכסה מתגלה מיד, ואחסון מקומי של עשרות גרסאות בנות + // 20,000 תווים היה אוכל את מכסת הדפדפן. + + _undoState(el){ + const entry = this._getEntry(el); + if (!entry) return null; + if (!entry.undo) { + // ``last`` מאותחל לתוכן **שלפני** השינוי, ולא ל-null. + // + // באתחול ל-null, הקריאה הראשונה הייתה קובעת ``last`` לערך שכבר + // השתנה — ואז "המצב הקודם" היה זהה לחדש ושום דבר לא נדחף + // למחסנית. כלומר בדיוק התרחיש שהפיצ'ר נועד לו — בחירת-הכל + // והדבקה — לא היה ניתן לביטול. נתפס בבדיקה: 21 תווים אחרי + // ביטול במקום המקור המלא. + const seed = (entry.data && typeof entry.data.content === 'string') ? entry.data.content : ''; + entry.undo = { stack: [], burstFrom: null, timer: null, last: seed }; + } + return entry.undo; + } + + // הקלדה רצופה היא פעולה אחת, לא מאה. צילום המצב נלקח **לפני** תחילת + // ההתפרצות, ונדחף למחסנית רק אחרי שהמשתמש עצר — כך שהדבקה בודדת היא + // בדיוק צעד אחד לבטל. + _recordUndoBurst(el, textarea){ + try { + const st = this._undoState(el); + if (!st) return; + const current = String(textarea.value == null ? '' : textarea.value); + if (st.burstFrom === null) st.burstFrom = st.last; + st.last = current; + try { clearTimeout(st.timer); } catch(_) {} + st.timer = setTimeout(() => { + const from = st.burstFrom; + st.burstFrom = null; + st.timer = null; + if (from !== null && from !== st.last) this._pushUndo(el, from); + this._syncUndoButton(el); + }, UNDO_BURST_MS); + // הכפתור מתעורר כבר עכשיו, ולא בעוד 600 מילישניות + this._syncUndoButton(el); + } catch(_) {} + } + + // כתיבה תכנותית ל-textarea (המשך רשימה, וכל מה שיבוא אחריו). + // + // ``input`` נורה רק על הקלדה של אדם. שינוי שנעשה בקוד חייב להירשם + // בעצמו, אחרת המחסנית מפספסת אותו ו-``last`` נשאר על ערך שכבר לא + // בשדה — מה שהופך את הביטול הבא לשחזור של טקסט שגוי. + _noteProgrammaticEdit(el, textarea, before){ + try { + const st = this._undoState(el); + if (!st) return; + const after = String(textarea.value == null ? '' : textarea.value); + if (after === before) return; + // סוגרים התפרצות פתוחה כדי שהצעד הזה לא ייבלע לתוכה + try { clearTimeout(st.timer); } catch(_) {} + st.timer = null; + const from = (st.burstFrom !== null) ? st.burstFrom : before; + st.burstFrom = null; + st.last = after; + if (from !== after) this._pushUndo(el, from); + this._syncUndoButton(el); + } catch(_) {} + } + + // תוכן סמכותי מהשרת מאפס את נקודת ההשוואה. + // + // בלי זה ``st.last`` נשאר על התוכן שלפני הסימון, העריכה הבאה דוחפת + // אותו למחסנית, וביטול אחד היה מוחק את הסימון שהשרת אישר — כלומר + // דורס כתיבה מאומתת בתוכן ישן. + _resetUndoBaseline(el, content){ + try { + const st = this._undoState(el); + if (!st) return; + try { clearTimeout(st.timer); } catch(_) {} + st.timer = null; + st.burstFrom = null; + st.last = String(content == null ? '' : content); + // ההתפרצות הממתינה נסגרה, ולכן העומק ירד. בלי הסנכרון הזה + // הכפתור נשאר דלוק על מחסנית ריקה — לחיצה עליו לא עושה כלום, + // וזה בדיוק סוג השקר שהכפתור הזה נבנה כדי לא לספר. + this._syncUndoButton(el); + } catch(_) {} + } + + _pushUndo(el, snapshot){ + try { + const st = this._undoState(el); + if (!st) return; + if (st.stack.length && st.stack[st.stack.length - 1] === snapshot) return; + st.stack.push(snapshot); + if (st.stack.length > UNDO_MAX_STEPS) st.stack.shift(); + this._syncUndoButton(el); + } catch(_) {} + } + + // האם יש התפרצות פתוחה שטרם נדחפה למחסנית. + // + // זה מה שהופך את התרחיש המרכזי לעביד: מיד אחרי הדבקה המחסנית עדיין + // ריקה — הצילום ממתין ל-600 מילישניות של שקט — והכפתור היה מושבת + // בדיוק ברגע שבו הוא הכי נחוץ. + _hasPendingUndoBurst(st){ + return !!(st && st.burstFrom !== null && st.burstFrom !== st.last); + } + + _syncUndoButton(el){ + try { + const btn = el.querySelector('.sticky-note-undo'); + if (!btn) return; + const st = this._undoState(el); + const depth = st ? st.stack.length : 0; + const pending = this._hasPendingUndoBurst(st) ? 1 : 0; + btn.disabled = (depth + pending) === 0; + btn.title = (depth + pending) + ? `בטל שינוי אחרון (${depth + pending} צעדים שמורים)` + : 'אין מה לבטל'; + } catch(_) {} + } + + _undoLastChange(el){ + try { + const st = this._undoState(el); + const textarea = el.querySelector('.sticky-note-content'); + if (!st || !textarea) return; + // ההתפרצות הממתינה נסגרת **לפני** בדיקת המחסנית הריקה, לא אחריה: + // מיד אחרי הדבקה היא כל מה שיש, והיציאה המוקדמת הייתה בולעת בדיוק + // את התרחיש שהפיצ'ר נבנה בשבילו. + try { clearTimeout(st.timer); } catch(_) {} + st.timer = null; + if (this._hasPendingUndoBurst(st)) { + st.stack.push(st.burstFrom); + if (st.stack.length > UNDO_MAX_STEPS) st.stack.shift(); + } + st.burstFrom = null; + if (!st.stack.length) return; + + const restored = st.stack.pop(); + textarea.value = restored; + st.last = restored; + const entry = this._getEntry(el); + if (entry && entry.data) entry.data.content = restored; + this._checkContentLength(el, restored); + this._queueSave(el, { content: restored }); + this._syncTaskView(el); + this._syncUndoButton(el); + } catch(e) { console.warn('sticky note: undo failed', e); } + } + + // ----- העתקה ----- + // + // ``navigator.clipboard`` אינו זמין בהקשר לא-מאובטח ועלול להיחסם + // בהרשאות. יש נפילה חיננית, ובכל מקרה **חיווי גלוי** — כפתור שנלחץ + // ולא קורה כלום הוא בדיוק הכשל השקט שהפיצ'ר הזה לא מרשה לעצמו. + async _copyNoteContent(el){ + const textarea = el.querySelector('.sticky-note-content'); + const text = textarea ? String(textarea.value == null ? '' : textarea.value) : ''; + let ok = false; + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + ok = true; + } + } catch(_) { ok = false; } + if (!ok) ok = this._copyViaFallback(text); + this._flashCopyResult(el, ok); + } + + _copyViaFallback(text){ + try { + const tmp = document.createElement('textarea'); + tmp.value = text; + tmp.setAttribute('readonly', ''); + tmp.style.position = 'fixed'; + tmp.style.opacity = '0'; + document.body.appendChild(tmp); + tmp.select(); + const ok = document.execCommand && document.execCommand('copy'); + document.body.removeChild(tmp); + return !!ok; + } catch(_) { return false; } + } + + _flashCopyResult(el, ok){ + try { + // מחלקה יציבה ולא ה-title, שאותו הפונקציה הזו עצמה משנה + const target = el.querySelector('.sticky-note-copy') || el; + const cls = ok ? 'is-copy-done' : 'is-copy-fail'; + target.classList.add(cls); + if (!ok) target.title = 'ההעתקה נחסמה בדפדפן — סמנו והעתיקו ידנית'; + setTimeout(() => { + try { + target.classList.remove(cls); + if (!ok) target.title = 'העתק את תוכן הפתק'; + } catch(_) {} + }, 1800); + } catch(_) {} + } + // חיווי אורך. השרת דוחה תוכן שחורג מהתקרה ב-``content_too_long``, // אבל דחייה שמגיעה רק אחרי ניסיון שמירה היא מאוחרת מדי — המשתמש // כבר הדביק והמשיך. כאן זה נראה בזמן ההקלדה. @@ -905,9 +1261,28 @@ _applyServerContent(el, entry, content, updatedAt){ if (typeof content !== 'string') return; const textarea = el.querySelector('.sticky-note-content'); + // התוכן שלפני — **לפני** שהוא נדרס. הוא הצעד שהמשתמש עשוי לרצות + // לבטל, וההערה הקודמת כאן טענה שהמחסנית כבר שומרת אותו. היא לא: + // סימון צ'קבוקס בפתק שלא נערך משאיר את המחסנית ריקה לגמרי, ואז + // אין שום דרך לחזור מהסימון דרך כפתור הביטול. + const before = textarea ? String(textarea.value == null ? '' : textarea.value) : null; + // התפרצות הקלדה שעדיין פתוחה — הצילום שלה נלקח **לפני** ההקלדה, + // ו-``_resetUndoBaseline`` עומד למחוק אותו. בלי לשמור אותו כאן, + // תוכן שמגיע מהשרת באמצע הקלדה היה מוחק את נקודת הפתיחה, וביטול + // היה מחזיר לסוף ההתפרצות במקום לתחילתה — כלומר ההקלדה כולה אבודה. + const st = this._undoState(el); + const burstFrom = this._hasPendingUndoBurst(st) ? st.burstFrom : null; if (textarea) textarea.value = content; if (entry && entry.data) entry.data.content = content; if (updatedAt) el.dataset.updatedAt = updatedAt; + // נקודת ההשוואה עוברת לתוכן הסמכותי, אחרת העריכה הבאה הייתה דוחפת + // את התוכן הישן למחסנית וביטול היה מוחק כתיבה שהשרת אישר. + this._resetUndoBaseline(el, content); + // שני צעדים נפרדים, כי באמת קרו שני דברים: המשתמש הקליד, ואז + // השרת שינה. הסדר הוא LIFO — הביטול הראשון מחזיר למה שהוקלד, + // והשני למה שהיה לפני ההקלדה. + if (burstFrom !== null && burstFrom !== before) this._pushUndo(el, burstFrom); + if (before !== null && before !== content) this._pushUndo(el, before); this._syncTaskView(el); } @@ -1041,9 +1416,10 @@ this._reflowWithinViewport(el); } } - // בלוח הכפתור מציין "צף מול המסך", ולא "מוצמד". בלי ההיפוך הזה - // הוא היה נדלק כבר בפתיחת הפתק — כי surface הוא ברירת המחדל. - this._updatePinButtonState(el, this.boardId ? (resolved === 'screen') : isPinned); + // **ורוד = מוצמד למשטח שמתחתיו**, בקובץ ובלוח כאחד. בקובץ זה + // המסמך, בלוח זה הלוח. שני סימנים שונים לאותה משמעות באותה + // מערכת הם באג בפני עצמו. + this._updatePinButtonState(el, isPinned); } catch(e) { console.warn('sticky note: applyPositionMode failed', e); } @@ -1199,7 +1575,12 @@ // חסימה כבר בזמן הגרירה, ולא רק בשמירה: אחרת אפשר לגרור את // הכותרת אל מעל קצה המשטח, ומשם אין דרך חזרה — הכותרת *היא* // ידית הגרירה. - if (this.boardId && el.classList && el.classList.contains('is-pinned')) { + // שני המצבים נחסמים — כל אחד מול מסגרת הייחוס שלו. + const floating = el.classList && el.classList.contains('is-floating'); + if (floating) { + const c = this._clampToViewport(el, nx, ny); + nx = c.x; ny = c.y; + } else if (this.boardId && el.classList && el.classList.contains('is-pinned')) { const c = this._clampToSurface(el, nx, ny, null); nx = c.x; ny = c.y; } @@ -1224,6 +1605,7 @@ this._queueSave(el, payload); } this._flushFor(el); + this._updateSurfaceExtent(); }; // מניעת מחוות ברירת מחדל במובייל try { handle.style.touchAction = 'none'; } catch(_) {} @@ -1294,6 +1676,8 @@ if (!resizing) return; resizing=false; const payload = this._notePayloadFromEl(el); this._queueSave(el, payload); this._flushFor(el); + // פתק שגדל כלפי מטה מאריך את המשטח, בדיוק כמו פתק שנגרר לשם + this._updateSurfaceExtent(); }; try { handle.style.touchAction = 'none'; } catch(_) {} handle.addEventListener('mousedown', onDown); @@ -1484,6 +1868,18 @@ const attempt = async () => { try { const { resp, json } = await send(payload); + // 409 נושא שני מובנים שונים לגמרי, ורק ``error`` מבדיל ביניהם. + // התנגשות גרסה נפתרת בניסיון חוזר עם חותמת טרייה; שם תפוס לא + // ייפתר לעולם בניסיון חוזר — הוא דורש שהמשתמש יבחר שם אחר. + // בלי ההבחנה הזו כל הקלדה של שם קיים נכנסה ללולאת ניסיונות + // אינסופית, ובלי שום חיווי: מסלול ה-409 אינו מסמן כשל. + if (resp.status === 409 && json && json.error === 'duplicate_title') { + this._markTitleConflict(id, true); + if ((this._pendingSeq.get(String(id)) || 0) === (seqAtSend || 0)) { + this._pending.delete(String(id)); + } + return false; + } if (resp.status === 409) { console.warn('sticky note update conflict, retrying once with fresh timestamp', id); let serverUpdatedAt = null; @@ -1496,6 +1892,13 @@ const retryPayload = withContentB64(retryOriginal); try { const { resp: retryResp, json: retryJson } = await send(retryPayload); + if (retryResp.status === 409 && retryJson && retryJson.error === 'duplicate_title') { + this._markTitleConflict(id, true); + if ((this._pendingSeq.get(String(id)) || 0) === (seqAtSend || 0)) { + this._pending.delete(String(id)); + } + return false; + } if (retryResp.status === 409) { console.warn('sticky note update conflict persisted after retry', id); if (retryJson && retryJson.updated_at) { @@ -1536,6 +1939,10 @@ } } else { this._clearSaveFailed(id); + // רק אם השם עצמו הוא מה שנשמר. שמירה מוצלחת של גרירה או שינוי + // גודל אינה אומרת דבר על השם — הוא נשאר תפוס בשרת — וניקוי + // הסימון כאן היה מציג "נשמר" על שדה שהשרת לא קיבל. + if ('title' in original) this._markTitleConflict(id, false); } return resp.ok; } catch(err) { @@ -1589,10 +1996,16 @@ if (result.ok) { if (result.updated_at) { this._setNoteUpdatedAt(id, String(result.updated_at)); } this._clearSaveFailed(id); + // כמו במסלול הבודד: רק שמירה שכללה שם מנקה את הסימון + if (snap && snap.payload && 'title' in snap.payload) this._markTitleConflict(id, false); // clear pending only if no newer edits arrived during request if (snap && currentSeq === snap.seq) { this._pending.delete(id); } + } else if (Number(result.status) === 409 && result.error === 'duplicate_title') { + // שם תפוס — לא התנגשות גרסה, ולכן לא חוזר לתור + this._markTitleConflict(id, true); + if (snap && currentSeq === snap.seq) this._pending.delete(id); } else if (Number(result.status) === 409) { // conflict – set prev_updated_at for next retry without overriding newer local edits const patch = {}; @@ -1623,11 +2036,13 @@ for (const [id] of entries){ const current = this._pending.get(id); if (!current) continue; + // גרסת התור נלקחת **לפני** המחיקה, ומועברת הלאה — בדיוק כמו + // ב-``_flushFor``. בלעדיה ``seqAtSend`` היה ``undefined``, כל + // ההשוואות בענפי הכשל נפלו על 0, והשומר "אל תמחק עריכה חדשה + // יותר" לא היה יכול להתאים לעולם. + const seqAtSend = this._pendingSeq.get(id) || 0; this._pending.delete(id); - const didSucceed = await this._sendUpdate(id, current); - if (!didSucceed && !this._pending.has(id)) { - // במקרה של כישלון המידע הוחזר ל-pending בתוך _sendUpdate - } + await this._sendUpdate(id, current, seqAtSend); } } if ((!this._pending || this._pending.size === 0) && (!this._inFlight || this._inFlight.size === 0)) { @@ -1739,8 +2154,24 @@ async _deleteNoteEl(el){ const id = el.dataset.noteId; try { await fetch(`/api/sticky-notes/note/${encodeURIComponent(id)}`, { method: 'DELETE' }); } catch(e) { console.warn('delete failed', id, e); } + // טיימר ההתפרצות מחזיק הפניה ל-``el`` ול-``entry``. בלי הניקוי הזה + // הוא ימשיך לרוץ אחרי שהפתק ירד מה-DOM. + this._clearUndoTimer(el); try { el.remove(); } catch(_) {} this.notes.delete(id); + // אורך המשטח נגזר מהפתקים שעליו. בלי הקריאה הזו מחיקת הפתק התחתון + // משאירה ``min-height`` שמתאר מצב שכבר לא קיים. + this._updateSurfaceExtent(); + } + + _clearUndoTimer(el){ + try { + const entry = this._getEntry(el); + const st = entry && entry.undo; + if (!st || !st.timer) return; + try { clearTimeout(st.timer); } catch(_) {} + st.timer = null; + } catch(_) {} } _reflowWithinViewport(target){ @@ -2055,10 +2486,14 @@ _clearAllNotes(){ try { for (const [id, entry] of this.notes.entries()){ + // אותו נימוק כמו במחיקת פתק בודד: טיימר ההתפרצות מחזיק הפניה + // ל-``entry``, והוא ימשיך לרוץ אחרי שהאלמנט ירד מה-DOM. + try { if (entry && entry.el) this._clearUndoTimer(entry.el); } catch(_) {} try { entry && entry.el && entry.el.remove && entry.el.remove(); } catch(_) {} } } catch(_) {} this.notes.clear(); + this._updateSurfaceExtent(); } _loadCacheAndRender(){ diff --git a/webapp/sticky_notes_api.py b/webapp/sticky_notes_api.py index 7436b2f6a..c1d393a1b 100644 --- a/webapp/sticky_notes_api.py +++ b/webapp/sticky_notes_api.py @@ -16,11 +16,27 @@ import hashlib import threading import asyncio +import logging # תקרת אורך התוכן — מקור אמת אחד, בלי fallback שקט. # תלוי בכך ש-``app.py`` כבר הכין את ``sys.path``; ראו את ההסבר המלא ב- # tests/test_webapp_import_paths.py. -from sticky_notes_target import MAX_NOTE_CHARS, MAX_NOTES_PER_BOARD, MAX_NOTES_PER_USER +from sticky_notes_target import ( + MAX_NOTE_CHARS, MAX_NOTE_TITLE, MAX_NOTES_PER_BOARD, MAX_NOTES_PER_USER, + normalize_note_title, + ensure_title_index, + title_is_taken, +) +# ``DuplicateKeyError`` נדרש לאכיפת שם ייחודי לפתק. ייבוא עמיד, באותה +# תבנית של ObjectId — בסביבות stub אין pymongo, ומחלקה מקומית שלא תיזרק +# לעולם עדיפה על ייבוא שמפיל את המודול כולו. +try: # type: ignore + from pymongo.errors import DuplicateKeyError # type: ignore +except Exception: # pragma: no cover + class DuplicateKeyError(Exception): # type: ignore + pass + + # Robust ObjectId/InvalidId import with fallbacks for stub environments try: # type: ignore from bson import ObjectId # type: ignore @@ -57,6 +73,8 @@ def get_db(): # Blueprint sticky_notes_bp = Blueprint("sticky_notes", __name__, url_prefix="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/api/sticky-notes") +logger = logging.getLogger(__name__) + try: from cache_manager import cache # type: ignore except Exception: @@ -65,11 +83,32 @@ def get_db(): # Module-level guard to ensure indexes only once per process _INDEX_READY = False _INDEX_READY_LOCK = threading.Lock() -_INDEX_READY_CACHE_KEY = "sticky_notes_indexes_ready_v1" +#: **גרסת המפתח הועלתה ל-v2 בכוונה.** הדגל הזה נכתב היום רק אחרי +#: שאינדקס השם אומת, ולכן קריאתו מותר שתדליק גם את ``_TITLE_INDEX_OK``. +#: דגל שנכתב בגרסה הקודמת של הקוד לא נשא את המשמעות הזו, ותחת אותו מפתח +#: הוא היה מתפרש כאימות שלא היה — למשך יממה, ובכל התהליכים. +_INDEX_READY_CACHE_KEY = "sticky_notes_indexes_ready_v2" _INDEX_READY_CACHE_TTL_SECONDS = 24 * 3600 _INDEX_CACHE_LAST_CHECK = 0.0 _WARMUP_TRIGGERED = threading.Event() +#: האם ``one_title_per_board_v2`` **אומת** במסד בהרצה האחרונה. +#: +#: זה לא אותו דבר כמו ``_INDEX_READY``, שאומר "ניסינו". הדגל הזה אומר +#: "האילוץ חי", וזה מה שאכיפת ``duplicate_title`` נשענת עליו. כל עוד הוא +#: כבוי, מסלולי הכתיבה עוברים לבדיקת קוד — ראו :func:`title_is_taken`. +_TITLE_INDEX_OK = False + +#: מתי מותר לנסות לבנות שוב אחרי כשל, כ-``time.monotonic``. +#: +#: בלי החסם הזה כשל מתמשך באינדקס אחד היה גורם ל**כל בקשה** להיכנס +#: ל-``_ensure_indexes``, לתפוס את הנעילה ולבנות מחדש את כל שש קבוצות +#: האינדקסים — לולאה חמה שמסריאלת את השירות כולו סביב מנעול אחד. +_INDEX_RETRY_AFTER = 0.0 + +#: כמה להמתין בין ניסיונות בנייה כושלים, בשניות. +_INDEX_RETRY_SECONDS = 60.0 + def _emit_index_event(stage: str, duration_ms: Optional[int] = None, error: Optional[str] = None) -> None: """Emit lightweight observability events without failing the request.""" @@ -87,8 +126,15 @@ def _emit_index_event(stage: str, duration_ms: Optional[int] = None, error: Opti def _cache_flag_ready() -> bool: - """Check shared cache flag (best-effort) to avoid duplicate index builds.""" - global _INDEX_READY, _INDEX_CACHE_LAST_CHECK + """Check shared cache flag (best-effort) to avoid duplicate index builds. + + **הדגל המשותף גורר גם את ``_TITLE_INDEX_OK``.** הוא נכתב רק דרך + ``_mark_indexes_ready``, שרץ רק כשאינדקס השם אומת — כלומר קיומו הוא + עדות לכך שהאילוץ חי. בלי הגרירה הזו, worker חדש שיורש את הדגל היה + מדלג על הבנייה, נשאר עם ``_TITLE_INDEX_OK`` כבוי לתמיד, ומריץ שאילתת + גיבוי מיותרת בכל כתיבה עם שם. גרסת המפתח הועלתה בדיוק בשביל זה. + """ + global _INDEX_READY, _INDEX_CACHE_LAST_CHECK, _TITLE_INDEX_OK if _INDEX_READY: return True cache_obj = cache if 'cache' in globals() else None @@ -104,6 +150,7 @@ def _cache_flag_ready() -> bool: flag = None if flag: _INDEX_READY = True + _TITLE_INDEX_OK = True return True return False @@ -161,12 +208,19 @@ def _job(): _job() def _ensure_indexes() -> None: + global _TITLE_INDEX_OK, _INDEX_RETRY_AFTER if _INDEX_READY or _cache_flag_ready(): return + # החסם נבדק **לפני** הנעילה: בקשה שנקלעה לחלון ההמתנה לא צריכה + # להמתין גם לנעילה כדי לגלות שאין לה מה לעשות. + if time.monotonic() < _INDEX_RETRY_AFTER: + return try: with _INDEX_READY_LOCK: if _INDEX_READY or _cache_flag_ready(): return + if time.monotonic() < _INDEX_RETRY_AFTER: + return started = time.perf_counter() db = get_db() coll = db.sticky_notes @@ -194,6 +248,20 @@ def _ensure_indexes() -> None: coll.create_index([("user_id", 1), ("board_id", 1)], name="user_board_idx") except Exception: pass + # האינדקס הייחודי נוצר **בנפרד משני המסלולים**, ולא בתוכם. + # + # שני טעמים. האחד: כשהוא ישב ברשימת ``create_indexes``, כשל שלו + # הפיל את כל החמישה למסלול הפולבק — שלא יצר אותו — ואז + # ``_INDEX_READY`` נדלק בעוד שאכיפת ``duplicate_title`` פשוט + # אינה קיימת. השני: הוא דורש **יישוב** של גרסה קודמת, כי אינדקס + # בשם קיים עם אפשרויות שונות נדחה ב-code 86. + try: + _TITLE_INDEX_OK = bool(ensure_title_index(coll)) + if not _TITLE_INDEX_OK: + logger.error("one_title_per_board index not confirmed after create") + except Exception: + _TITLE_INDEX_OK = False + logger.error("one_title_per_board index creation failed", exc_info=True) # אינדקסים ללוחות הפתקים (best-effort) try: nb = db.note_boards @@ -254,12 +322,37 @@ def _ensure_indexes() -> None: # Never fail request because of index creation pass duration_ms = int(max(0.0, (time.perf_counter() - started) * 1000.0)) - _mark_indexes_ready(duration_ms=duration_ms) + if _TITLE_INDEX_OK: + _INDEX_RETRY_AFTER = 0.0 + _mark_indexes_ready(duration_ms=duration_ms) + else: + _INDEX_RETRY_AFTER = time.monotonic() + _INDEX_RETRY_SECONDS + # **לא מסמנים מוכן.** ``_mark_indexes_ready`` כותב גם דגל + # ברדיס ל-24 שעות, ולכן סימון כאן היה נועל כשל חולף — נפילת + # רשת אחת בזמן פריסה — ליממה שלמה של אכיפה שאינה קיימת, + # בכל התהליכים. בלי הסימון הבקשה הבאה תבנה שוב. + _emit_index_event("failed", error="one_title_per_board not confirmed") except Exception as exc: + _INDEX_RETRY_AFTER = time.monotonic() + _INDEX_RETRY_SECONDS _emit_index_event("failed", error=str(exc)) # --- Helpers --- + +def _title_conflict(db: Any, user_id: Any, board_id: Any, title: str, exclude_id: Any = None) -> bool: + """גיבוי לאכיפת השם, ורק כשהאינדקס לא אומת. + + במצב התקין הפונקציה מחזירה ``False`` מיד ואינה עולה שאילתה — האכיפה + היא של המסד, וכך היא גם חסינה למרוץ. היא מתעוררת רק כש- + ``_TITLE_INDEX_OK`` כבוי, כלומר כשהראוט עומד להבטיח ``duplicate_title`` + בלי שיש מי שיאכוף אותו. + """ + if _TITLE_INDEX_OK or not title or not board_id: + return False + return title_is_taken( + db.sticky_notes, user_id=user_id, board_id=board_id, title=title, exclude_id=exclude_id + ) + def require_auth(f): @wraps(f) def _inner(*args, **kwargs): @@ -530,6 +623,8 @@ def _as_note_response(doc: Dict[str, Any]) -> Dict[str, Any]: 'id': str(doc.get('_id')), 'file_id': str(doc.get('file_id', '')), 'content': _coerce_content_from_doc(doc.get('content', '')), + # שם הפתק. ריק = אין שם; במסמך השדה פשוט אינו קיים. + 'title': str(doc.get('title', '') or ''), 'position': { 'x': int(doc.get('position_x', 100) or 100), 'y': int(doc.get('position_y', 100) or 100), @@ -1054,6 +1149,7 @@ def create_note(file_id: str): return jsonify({'ok': False, 'error': 'content_too_long', 'max': MAX_NOTE_CHARS}), 400 except _InvalidContentB64: return jsonify({'ok': False, 'error': 'invalid_content_b64'}), 400 + title = normalize_note_title(data.get('title')) pos = data.get('position') or {} size = data.get('size') or {} color = str(data.get('color', '#FFFFCC') or '#FFFFCC') @@ -1081,6 +1177,8 @@ def create_note(file_id: str): 'user_id': user_id, 'file_id': str(file_id), 'content': content, + # ריק ← השדה כלל אינו נכתב, כדי שלא ייכנס לאינדקס הייחודי + **({'title': title} if title else {}), 'position_x': _coerce_int(pos.get('x'), 100, 0, 100000), 'position_y': _coerce_int(pos.get('y'), 100, 0, 1000000), 'width': _coerce_int(size.get('width'), 250, 120, 1200), @@ -1098,7 +1196,11 @@ def create_note(file_id: str): doc['scope_id'] = scope_id if scope_file_name: doc['file_name'] = scope_file_name - res = db.sticky_notes.insert_one(doc) + try: + res = db.sticky_notes.insert_one(doc) + except DuplicateKeyError: + # שם תפוס בלוח הזה — התנגשות, לא תקלה + return jsonify({'ok': False, 'error': 'duplicate_title', 'max': MAX_NOTE_TITLE}), 409 nid = str(getattr(res, 'inserted_id', '')) try: emit_event("sticky_note_created", severity="info", user_id=int(user_id), file_id=str(file_id)) @@ -1166,11 +1268,19 @@ def update_note(note_id: str): if 'anchor_text' in data: atx = (data.get('anchor_text') or '').strip()[:256] updates['anchor_text'] = atx or None + # שם ריק **מוחק** את השדה ולא שומר ``""``. ראו normalize_note_title. + unset_fields: Dict[str, Any] = {} + if 'title' in data: + new_title = normalize_note_title(data.get('title')) + if new_title: + updates['title'] = new_title + else: + unset_fields['title'] = '' # ``mode`` מאומת כאן ומוחל רק אחרי טעינת הפתק — הבדיקה תלויה # ב-``board_id`` שלו, שעדיין לא ידוע בשלב הזה. wants_mode = 'mode' in data - if not updates and not wants_mode: + if not updates and not wants_mode and not unset_fields: return jsonify({'ok': False, 'error': 'No fields to update'}), 400 updates['updated_at'] = datetime.now(timezone.utc) @@ -1210,7 +1320,16 @@ def update_note(note_id: str): return jsonify({'ok': False, 'error': 'Conflict', 'updated_at': note['updated_at'].isoformat()}), 409 except Exception: pass - db.sticky_notes.update_one({'_id': oid, 'user_id': user_id}, {'$set': updates}) + ops: Dict[str, Any] = {'$set': updates} + if unset_fields: + ops['$unset'] = unset_fields + if 'title' in updates and _title_conflict(db, user_id, note.get('board_id'), updates['title'], exclude_id=oid): + return jsonify({'ok': False, 'error': 'duplicate_title', 'max': MAX_NOTE_TITLE}), 409 + try: + db.sticky_notes.update_one({'_id': oid, 'user_id': user_id}, ops) + except DuplicateKeyError: + # שם תפוס בלוח הזה. 409 ולא 500 — זו התנגשות, לא תקלה. + return jsonify({'ok': False, 'error': 'duplicate_title', 'max': MAX_NOTE_TITLE}), 409 try: emit_event("sticky_note_updated", severity="info", user_id=int(user_id), note_id=str(note_id)) except Exception: @@ -1380,6 +1499,21 @@ def batch_update_notes(): results.append({'id': note_id, 'ok': False, 'status': 400, 'error': mode_error}) continue updates['mode'] = mode_value + # ``title`` חסר כאן עד היום, וזו הייתה **אבידת כתיבה שקטה**. + # + # הלקוח שולח שם דרך ``_queueSave``, וה-debounce מנקז אותו + # לכאן — לא ל-PUT הבודד, שהוא רק פולבק. השדה נשמט מה- + # allowlist, הראוט החזיר ``ok: True, status: 200``, הלקוח + # ניקה את התור, והשם נעלם בלי שום חיווי. נמדד מול הראוט: + # ``title`` במסד נשאר "ישן" אחרי בקשה ששלחה "שם חדש". + # אותה סמנטיקה כמו ב-PUT הבודד: ריק ← מחיקת השדה. + unset_fields: Dict[str, Any] = {} + if 'title' in fragment: + new_title = normalize_note_title(fragment.get('title')) + if new_title: + updates['title'] = new_title + else: + unset_fields['title'] = '' # conflict detection similar to single update try: @@ -1404,7 +1538,24 @@ def batch_update_notes(): pass updates['updated_at'] = datetime.now(timezone.utc) - db.sticky_notes.update_one({'_id': oid, 'user_id': user_id}, {'$set': updates}) + ops: Dict[str, Any] = {'$set': updates} + if unset_fields: + ops['$unset'] = unset_fields + if 'title' in updates and _title_conflict( + db, user_id, note.get('board_id'), updates['title'], exclude_id=oid + ): + results.append({'id': note_id, 'ok': False, 'status': 409, + 'error': 'duplicate_title', 'max': MAX_NOTE_TITLE}) + continue + try: + db.sticky_notes.update_one({'_id': oid, 'user_id': user_id}, ops) + except DuplicateKeyError: + # שם תפוס — 409 עם הקוד הספציפי, ולא 500 גנרי. הלקוח + # מבחין לפי ``error`` בין זה לבין התנגשות גרסה, ורק + # ההבחנה הזו מונעת ממנו לנסות שוב לנצח. + results.append({'id': note_id, 'ok': False, 'status': 409, + 'error': 'duplicate_title', 'max': MAX_NOTE_TITLE}) + continue results.append({'id': note_id, 'ok': True, 'status': 200, 'updated_at': updates['updated_at'].isoformat()}) except Exception as e: try: @@ -1557,6 +1708,7 @@ def create_board_note(board_id: str): return jsonify({'ok': False, 'error': 'content_too_long', 'max': MAX_NOTE_CHARS}), 400 except _InvalidContentB64: return jsonify({'ok': False, 'error': 'invalid_content_b64'}), 400 + title = normalize_note_title(data.get('title')) # תקרות — ולפני הכתיבה, לא אחריה. ``_count_or_none`` מבחין בין אפס # לבין ספירה שנכשלה, ו-``check_note_quota`` דוחה על השנייה. @@ -1584,6 +1736,8 @@ def create_board_note(board_id: str): doc: Dict[str, Any] = { 'user_id': user_id, 'content': content, + # ריק ← השדה כלל אינו נכתב, כדי שלא ייכנס לאינדקס הייחודי + **({'title': title} if title else {}), 'position_x': _coerce_int(pos.get('x'), 100, 0, 100000), 'position_y': _coerce_int(pos.get('y'), 100, 0, 1000000), 'width': _coerce_int(size.get('width'), 250, 120, 1200), @@ -1598,7 +1752,14 @@ def create_board_note(board_id: str): # ממסמך לצאת עם שני משטחים או בלי אף אחד. doc.update(build_note_target(board_id=board_id)) - res = db.sticky_notes.insert_one(doc) + # גיבוי לאכיפה כשהאינדקס לא אומת. במצב התקין אינו עולה שאילתה. + if _title_conflict(db, user_id, doc.get('board_id'), title): + return jsonify({'ok': False, 'error': 'duplicate_title', 'max': MAX_NOTE_TITLE}), 409 + try: + res = db.sticky_notes.insert_one(doc) + except DuplicateKeyError: + # שם תפוס בלוח הזה — התנגשות, לא תקלה + return jsonify({'ok': False, 'error': 'duplicate_title', 'max': MAX_NOTE_TITLE}), 409 nid = str(getattr(res, 'inserted_id', '')) try: emit_event("sticky_note_created", severity="info", user_id=int(user_id), board_id=str(board_id)) diff --git a/webapp/templates/note_board.html b/webapp/templates/note_board.html index 7b49f531b..a38165c63 100644 --- a/webapp/templates/note_board.html +++ b/webapp/templates/note_board.html @@ -9,14 +9,51 @@ {% block content %}
-
- - כל הלוחות -
+
+ + + + + +
+ +