:summary: מה שחי בפייתון ובמונגו, והחוזה בין השרת ללקוחות שלו: יעד יחיד, מכסה fail-closed, אינדקס ייחודי בכל כותב, נורמליזציה בשני הקצוות, יתומים בקריאה, מצב התזכורת והשם שבו היא מציגה פתק, הקישור הקבוע לגרסה האחרונה של קובץ, תוכן כמו שנשלח, העדפות גופן ו-ETag, צבע, חיפוש, שרשורים וארכיון בשרת. עקרונות להוספת פיצ'ר לסטיקי-נוטס — השרת, המסד והחוזים בין השירותים ================================================================== העמוד הזה הוא חלק מ-:doc:`עקרונות להוספת פיצ'ר לסטיקי-נוטס `, ומחזיק את מה שחי בפייתון ובמונגו, ואת החוזה בין השרת ללקוחות שלו — הדפדפן ושרת ה-MCP. מנוע הרינדור של הפתק — ב-:doc:`עמוד מנוע המארקדאון `, והפתק כווידג'ט בדפדפן — ב-:doc:`עמוד הממשק `. היעד: בדיוק אחד, ובשכבה טהורה אחת --------------------------------- פתק שייך ליעד אחד בדיוק — קובץ, לוח, קובץ בריפו, או הארכיון. הכלל הזה חי במקום יחיד, ``sticky_notes_target.build_note_target``, וכל מסלולי הכתיבה עוברים דרכו. הוא אינו בדיקה בראוט שאפשר לשכוח: הוא בנאי שמקבל כוונה, בונה את שדות היעד, ומריץ ולידציה **לפני** ההחזרה — כך שקורא אינו יכול לייצר מסמך לא חוקי. כשמוסיפים סוג יעד, מוסיפים שורה ל-``TARGET_FIELDS`` — המפה שקובעת אילו שדות מותר לכל סוג לשאת — ולא רשימת "צירופים אסורים". הכלל הוא תכונתי: כל שדה שאינו של הסוג שנבחר נפסל, ומי שמוסיף סוג אינו צריך לזכור לעדכן מנייה במקום אחר. הארכיון נוסף בדיוק כך (:ref:`sticky-notes-archive`). .. important:: השכבה הזו טהורה בכוונה — בלי Flask ובלי pymongo — כדי שגם ה-webapp, גם ``mcp_server`` וגם ``services`` יוכלו לייבא ממנה. אל תוסיפו לה תלות כבדה; ולידציה שחיה רק בצד אחד היא הבטחה שהצד השני מפר בשקט. המכסה: ``fail-closed``, והפטור-לאדמין שהורג מכסות בשקט ------------------------------------------------------ ``check_note_quota`` דוחה כשספירת הפתקים נכשלה (``existing=None``), ולא מניחה אפס. תקרה שנפתחת לרווחה בדיוק כשהמסד מתקשה אינה תקרה. הפטור לאדמין מסוכן יותר ממה שהוא נראה. אם המשטח החדש נגיש לאדמינים בלבד — כפי שדפדפן הריפו נגיש — אז **כל** מי שמגיע אליו הוא אדמין, ומכסה שפטורה-לאדמין אינה נאכפת על אף אחד לעולם: קבוע מת שנראה חי. לפני שמעבירים ``is_admin`` למכסה, שאלו מה מטרתה. מכסה של **צורת-תוכן** (למשל "עשרים פתקים על קובץ = זה כבר עמוד תיעוד") חלה על כולם, ולכן נקראת עם ``is_admin=False`` תמיד. רק מכסת **הגנת-משאבים** נשארת פטורה. .. important:: טסט שבודק מכסה חייב לרוץ עם ``is_admin=False``. עם ``is_admin=True`` הפטור מקדים כל בדיקה אחרת, והטסט עובר בלי לבדוק כלום — בדיוק המלכודת "טסט עובר, מכסה שלא מכסה". .. note:: **מגבלה ידועה: הספירה והכתיבה אינן אטומיות.** כל התקרות (קובץ בריפו, לוח, משתמש, ארכיון) סופרות ואז כותבות, ולכן שתי בקשות מקבילות שמגיעות כשנותר מקום לאחת יכולות שתיהן לעבור — תקרה של 20 תיתן 21. זה ידוע ומקובל: התקרות הן שמירת צורת-תוכן והגנת-משאבים גסה, לא אילוץ נתונים, וחריגה של פתק אחד אינה מאבדת מידע ואינה פותחת פרצה. פתרון אמיתי (מונה מוגן-אילוץ, או טרנזקציה) הוא שינוי שצריך לחול על **כל** התקרות יחד ובמנגנון אחד — לא על אחת מהן. הוספת מונה ליעד אחד בלבד הייתה מוסיפה מצב שני שיכול להיסחף מול המסמכים בפועל, ומשאירה את האחרות כמות שהן. אינדקס ייחודי: ממוספר, ונבנה לפני שמפילים ----------------------------------------- כל אינדקס ייחודי חדש עובר דרך המנוע המשותף ``_ensure_versioned_unique_index``: בונים את החדש, מאמתים אותו ב-``index_information`` חוזר, **ורק אז** מפילים גרסה ישנה. מונגו אינו יודע לשנות שם של אינדקס, ולכן עדכון "במקום" מחייב את הסדר ההפוך — ובין ההפלה לבנייה אין ייחודיות כלל, ושתי בקשות מקבילות יכולות להכניס שני ערכים זהים. אז הבנייה החדשה נכשלת, והאכיפה נשארת מושבתת לצמיתות. לכן השם נושא גרסה (``_v1``, ``_v2``), ושינוי מפרט מעלה את המספר במקום לערוך את הקיים. הפונקציה מחזירה ``True`` רק אחרי הקריאה החוזרת — ערך החזרה של כתיבה אינו אימות. .. important:: ``partialFilterExpression`` הוא מה שמדיר את שאר הסוגים מהאינדקס. אינדקס הלוח מסנן על ``board_id`` קיים, אינדקס הריפו על ``repo_name`` **וגם** ``repo_path`` קיימים — שני חצאי היעד, כי מסמך שנושא רק אחד מהם אינו יעד ריפו חוקי, ובלי הדרישה הכפולה הוא בכל זאת נכנס לאינדקס תחת שדה חסר. בלי הסינון, פתקים מסוגים אחרים נכנסים עם שדה חסר, חולקים ערך מפתח, ושני פתקים שונים עם אותו שם נדחים ב-E11000. את זה **סטאב לא יכול לבדוק** — ``create_index`` שלו מחזיר ``None`` — ולכן בדיקת הייחודיות חייבת לרוץ מול מונגו אמיתי. אכיפה שנבנית רק בצד אחד היא הבטחה שהצד השני מפר בשקט ----------------------------------------------------- הסעיף שמעל עוסק ב**איך** בונים אינדקס אכיפה. זה עוסק ב**מי** בונה אותו, וזה כשל נפרד לגמרי: אינדקס תקין לחלוטין שנבנה רק באחד השירותים. לפתקים יש היום שני כותבים — הוובאפ ו-``mcp_server`` — ושניהם מחזירים ``duplicate_title`` על סמך דחייה של המסד. שירות שאינו בונה את האינדקס עדיין **מבטיח** את הייחודיות, ופשוט לעולם לא אוכף אותה: אין שגיאה, אין לוג, ושני פתקים עם אותו שם נכתבים. פריסה שבה רק ה-MCP רץ היא בדיוק המקרה הזה. לכן, כשמוסיפים אינדקס אכיפה: מוסיפים אותו **בכל כותב**, והמפרט חייב להיות זהה בייט-לבייט בין הבוטסטראפים. מונגו דוחה ב-``code 85/86`` אינדקס בשם קיים עם מפרט אחר, כלומר סטייה של תו אחד הופכת את הבוטסטראפ השני לכשל שקט לצמיתות — וגרוע מכך, ``_ensure_versioned_unique_index`` עלול להיכנס למחזור הפלה-ובנייה, שפותח בדיוק את החלון בלי ייחודיות שכל מנגנון הגרסאות נועד לסגור. שני כללים נוספים שנגזרים מזה: - **זוג דגלים עצמאי לכל אינדקס.** דגל משותף נותן לכשל של האחד לחסום את הניסיון החוזר של השני, ולהצלחה של האחד להדליק אכיפה שלא אומתה עבור השני. - **אינדקס אכיפה חדש נבנה במסלול הכתיבה, לא בכל קריאה.** ההבטחה נאמרת רק שם, ולכן רק היא משלמת על אימותה. זה הכלל לאינדקס שמוסיפים מהיום; **אינדקס הלוח הקיים אינו עומד בו** — ``_ensure_title_index`` עדיין נקרא מ-``_notes_coll`` ב-``mcp_server/backend``, כלומר גם ממסלולי קריאה. זה חוב קיים שקדם לכלל ולא יושר יחד איתו, כדי לא לערבב שינוי התנהגות בשירות חי עם הוספת יעד. אל תעתיקו ממנו. הבדיקה שהעניין הזה קיים אינה יכולה להיות סטאב. ``create_index`` של סטאב מחזיר ``None`` ומרוצה מכל מפרט. הריצו את שני הבוטסטראפים על אותו אוסף במונגו אמיתי, **בשני הסדרים**, ובדקו ששניהם עוברים ושסט האינדקסים שנוצר זהה. זהות לעומת נורמליזציה: אותה פונקציה בשני הקצוות ----------------------------------------------- כשמזהה יעד אינו אטומי — למשל נתיב קובץ — הוא חייב לעבור נורמליזציה אחת ואחידה, **גם בכתיבה וגם בקריאה**, דרך אותה פונקציה. נורמליזציה בצד אחד בלבד היא הכשל השקט הקלאסי: פתק שנכתב בצורה אחת לא יימצא בשאילתה שנבנתה בצורה אחרת — השאילתה רצה, מחזירה אפס, ולא זורקת כלום. והיעד של הנורמליזציה אינו "עקביות פנימית" אלא **התכנסות לצורה שכבר קיימת**. נתיב פתק ריפו חייב להתכנס לצורה שבה ``repo_files`` שומר נתיבים, אחרת גילוי היתומים — שמשווה מול המניפסט הזה — יסמן קובץ קיים כמיותם. יתומים: זיהוי בקריאה, לא בכתיבה ------------------------------- כשמשטח יכול "לזוז מתחת לפתק" (לוח שנמחק, קובץ שנעלם מהעץ), אל תזהו יתומים בסריקה תקופתית ואל תריצו ``update_many`` על מסלול קריאה. הפתק ממשיך להתקיים עם הזהות האחרונה הידועה, ומסומן כמיותם בעת הטעינה בלבד. וכשיש שתי רמות של "נעלם" (הקובץ נעלם / הריפו כולו נעלם), שתיהן צריכות תצוגה — אחרת הרמה החיצונית "נעלמת בשקט", כי אין לה מקום להופיע בו. וההפרדה הזו אינה פוטרת מ**שער בכתיבה**: זיהוי בקריאה נועד למשטח שזז אחרי שהפתק נוצר, לא לפתק שנוצר על משטח שמעולם לא היה שם. יצירה מאמתת את היעד מול אותו מניפסט שהקריאה משווה אליו — אחרת הפתק נולד יתום, נספר בתקרה, ומופיע רק ברשימת היתומים. כשל בקריאת המניפסט נסגר גם כאן. .. important:: כשל שאילתה נבדל תמיד מ"אין". מניפסט שלא נקרא אינו "אין יתומים", ורשימת מראות שלא נקראה אינה "הריפו נעלם" — שניהם מדווחים ``unknown``, כי קריאה שנכשלה אינה ראיה על מצב העולם. מצב התזכורת: שדה אחד מתאר, שניים סוטים --------------------------------------- לתזכורת יש שני שדות שמתארים את אותה עובדה — ``status``, עמודת מחזור החיים, ו-``ack_at``, שמסמן שהמשתמש ראה אותה. כל עוד הם נכתבים בנפרד הם נסחפים, וזה בדיוק מה שקרה: ``reminders_ack`` כתב ``ack_at`` ולא נגע ב-``status``. תזכורת שנצפתה ונסגרה נשארה ``pending`` **לנצח**, כי לא היה לה מצב סופי לעבור אליו. הסחיפה לא נראתה בקריאת אף אתר בנפרד, והיא צפה רק בהשוואה בין שני צרכנים: כרטיס הפוש בדשבורד סופר לפי ``status`` בלבד ודיווח תזכורות "בהמתנה", בזמן שהבועה — שמסננת גם ``ack_at`` — לא הציגה כלום. מדידה מול המסד הראתה שזה לא מקרה קצה אלא ההתנהגות היחידה שהייתה: **כל** המסמכים באוסף היו במצב הזה, כי אין מסלול אחר. לכן ההגדרה חיה היום במקום אחד, ``note_reminder_state``, והוא טהור מאותה סיבה ש-``sticky_notes_target`` טהור: שלושה מודולים שואלים "האם התזכורת פעילה" — הראוטים של הפתקים, הדשבורד, ושולח הפוש — והתשובה חייבת להיות אחת. ``acknowledge_fields`` מחזירה את **שני** השדות, ולכן מי שסוגר תזכורת אינו יכול לכתוב אחד ולשכוח את השני; אין לו את המילון החלקי בכלל. שתי מלכודות שנגזרות מזה, ושתיהן כבר נשכו: - **``status`` לבדו לא סינן כלום.** בלי מצב סופי, ``status in (pending, snoozed)`` היה נכון על כל מסמך באוסף — מי שסינן לפיו קיבל את הכול וחשב שסינן. עמודת lifecycle בלי מצב יציאה היא עמודה קבועה. - **שתי פעולות סותרות על אותה תזכורת.** ה-Service Worker הריץ ``snooze`` ומיד אחריו ``ack`` על אותה לחיצה, כי מסלול הפתיחה כלל אז גם את ``snooze_10``, כפולבק למיפוי כפתורים שגוי. הדחייה נקבעה ובוטלה באותה נשימה, והתזכורת לא חזרה. כשיוצאות שתי קריאות מצב מאותו אירוע, הכריעו ביניהן בקוד — לא בסדר ההגעה. מנובמבר 2026 הפולבק עצמו הוסר, כי מיפוי כזה אינו קיים: דחייה דוחה ובזה הלחיצה נגמרת, בלי פתיחה ובלי ``ack`` (:ref:`הלחיצה על ההתראה `). - **אישור בלי מועד סגר את התזכורת הלא נכונה.** ``ack`` התאים על ``(user_id, note_id, ack_at)`` בלי לומר *איזו* תזכורת, ו-``set_note_reminder`` עושה upsert על אותו מסמך — כך שהתראה שישבה במגש מאתמול סגרה את התזכורת שנקבעה מחדש למחר. עכשיו ההתראה והחלונית נושאות את ``remind_at`` של המועד שנורו עליו, הלקוח שולח אותו בגוף בקשת ה-``ack``, והפילטר תופס רק מסמך שעדיין נושא אותו (``note_reminder_state.parse_remind_at`` הופך אותו למפתח ברזולוציית מילישניות, כמו שה-BSON שומר). השדה אופציונלי: לקוח ישן ששולח ``note_id`` בלבד מאשר בלי קשירה, כמו קודם; מחרוזת שאינה ISO, או מועד שמחוץ לטווח, היא ``400 invalid_remind_at``. תשובת השרת לא השתנתה — ``{"ok": true}`` בלבד, בלי ``remind_at``. פעולה על ישות שיש לה "מופעים" חייבת לנקוב במופע. ומאותו מודול מגיעים גם שדות הכתיבה של הדריכה והדחייה (``armed_fields``, ``snoozed_fields``): קבוע מצב שמשתנה משנה כך גם מה נקרא וגם מה נכתב. ומכיוון שמסמכים שנכתבו לפני שהמצב הסופי היה קיים נשארים כפי שהם, הפילטר בודק את **שני** השדות גם אחרי התיקון, ומיגרציה חד-פעמית מיישרת את הישנים. וכלל המניפסט שבסעיף היתומים חל גם על מסלולי הבועה: שאילתה שנכשלה עונה 500, לא רשימה ריקה. ``ok:true, count:0`` על מסד שלא נקרא הוא בדיוק ה"אין" שהכלל אוסר — הבועה אמרה שיש, והחלונית אמרה שאין. .. _sticky-notes-reminder-label: השם של פתק בשורה אחת: פונקציה אחת לפוש, לפעמון ולדשבורד -------------------------------------------------------- התראת הפוש של תזכורת, חלונית הפעמון והדשבורד (כרטיס "הגדרות פוש", ציר הזמן ו"פתקים אחרונים", דרך ``app._dashboard_note_title``) מציגים כל פתק בשורה אחת קצרה, וכולם מקבלים אותה מ-``sticky_notes_target.note_label``: שם הפתק, ובלעדיו השורה הראשונה של התוכן (הראשונה שיש בה טקסט, ברווחים מכווצים), ובלי תוכן — טקסט העוגן. התקרה בתווים, ``NOTE_LABEL_MAX_CHARS``, היא התקרה של שם (``MAX_NOTE_TITLE``): מה שעומד במקום השם מקבל את הגבול שלו, ושורה אחת יכולה להחזיק את כל הפתק. מה שארוך ממנה נחתך שם ומקבל "…" — גם שם ארוך מהתקרה, שנקרא ב-``canonical_title_text`` ולא ב-``normalize_note_title``, כדי שלא ייחתך בשקט. מה שהמשתמש רואה: :ref:`sticky-note-reminders`. **למה פונקציה אחת.** עד נובמבר 2026 היו שלושה עותקים — ``push_api._coerce_preview`` והפונקציה הפנימית ב-``reminders_list`` לקחו שש מילים מתחילת התוכן, ו-``app._extract_note_preview`` בדשבורד לקח את השורה הראשונה — ושלושתם נכתבו לפני שלפתקים היו שמות, ולכן אף אחד מהם לא הכיר אותם. בדשבורד גם ההיטלה של ``_lookup_notes_map`` לא כללה את ``title``. כל מקום חדש שמציג פתק בשורה אחת קורא לאותה פונקציה, ומי ששולף את הפתק בשבילה שולף לפחות את ``NOTE_LABEL_FIELDS`` — השדות שהיא קוראת. **בשולח הפוש, הפתק נשלף רק מבין הפתקים של בעל התזכורת** (``_user_id_variants`` של ה-``user_id`` שלה), כמו ב-``reminders_list`` — תזכורת פגומה שמצביעה על פתק של משתמש אחר אינה מציגה את שמו — ותזכורת בלי ``user_id`` אינה מציגה כלום. **וכשל בקריאה אינו מפיל את ההתראה.** ``push_api._reminder_label`` תופסת כל חריגה, רושמת אותה (``push_reminder_label_failed``) ומחזירה מחרוזת ריקה — ההתראה יוצאת עם הכותרת בלבד. זה גבול ולא בליעה: התזכורת כבר נתפסה, ו-``_send_for_user`` ו-``_send_due_once`` אינם עוטפים משתמש בודד, כך שחריגה שעולה משם עוצרת את הסבב לכל המשתמשים שאחריו. .. _sticky-notes-permalink-latest-version: הקישור הקבוע של פתק על קובץ: הגרסה האחרונה, בזמן הקריאה --------------------------------------------------------- פתק על קובץ מזוהה עם הקובץ לפי השם — ``file_name`` ו-``scope_id``, ששינוי שם מעדכן (``sticky_notes_scope.sync_sticky_notes_on_rename``, שרץ אחרי שינוי שם בבוט — ``rename_file`` משנה את השם בכל הגרסאות — ואחרי שינוי שם של קובץ גדול בוובאפ; בוובאפ, שמירה של קובץ רגיל בשם חדש יוצרת קובץ חדש) — ואילו ``file_id`` שבפתק הוא רק הגרסה שעליה הפתק נוצר: כל שמירה של קובץ יוצרת מסמך חדש עם מזהה חדש. ``note_permalink`` (``/note/``) הפנה ל-``/md/`` השמור, ולכן לחיצה על התראה או על הפעמון הובילה לגרסה ישנה. היום הוא מפנה לגרסה הפעילה האחרונה של הקובץ, דרך ``app._note_file_target_id``: לפי השם שבפתק, ובפתק ישן בלי ``file_name`` — לפי השם של המסמך שב-``file_id``, תחום למשתמש. פתק על קובץ גדול נפתח ב-``file_id`` השמור, וזה נבדק **לפני** החיפוש לפי שם: לקובץ גדול אין גרסאות, והחיפוש לפי שם רואה רק את ``code_snippets``, כך שקובץ רגיל באותו שם היה נבחר במקומו. כשאין גרסה פעילה (הקובץ בסל), או כשהמסד נכשל, ההפניה היא גם ל-``file_id`` השמור, כמו קודם, כדי שהקישור לא יישבר. ``note`` ו-``thread`` עוברים כמו שהם, ושאר סוגי הפתקים לא השתנו. מה שהמשתמש רואה: :ref:`sticky-note-reminders`. **למה בזמן הקריאה ולא בכתיבה.** הפתקים אינם מתעדכנים בכל שמירה של קובץ: קבצים נשמרים מכמה מסלולים, ומסלול אחד ששוכח לעדכן היה מחזיר את הבאג בשקט — וזו גם כתיבה למסד על כל שמירה, בשביל פתקים שרובם לא ייפתחו. **"הגרסה האחרונה" היא הגדרה אחת.** ``_latest_active_version_doc`` ממיין לפי ``_LATEST_VERSION_SORT`` — ``version``, ואז ``updated_at``, ואז ``_id`` — ו-``api_resolve_file_by_name`` משתמש באותה פונקציה. ``version`` לבדו אינו מכריע: אין אינדקס ייחודי על מספר הגרסה, ושתי שמירות מקבילות יכולות לקבל אותו מספר. עד נובמבר 2026 הפונקציה מיינה לפי ``version`` בלבד, והראוט מיין בשלושת המפתחות, כל אחד בעותק משלו. ומי ששואל "האם זו הגרסה הנוכחית" — באנר הגרסה (``_file_version_context``) וההיסטוריה (``is_current`` ב-``api_file_history``) — משווה ``_id`` למסמך שנבחר ולא את המספר, אחרת בתיקו שני מסמכים היו נחשבים נוכחיים בזמן שהקישור הקבוע מפנה רק לאחד (:ref:`webapp-version-view`). הבדיקות: ``tests/test_note_permalink_latest_version.py`` ו-``tests/test_webapp_version_history_times.py``. הדגימה: כשל אינו "אין", ו-401 אינו כשל --------------------------------------------- בלוק הדגימה ב-``base.html`` (``initStickyRemindersIndicator``) שואל את השרת מתי התזכורת הבאה מבשילה ונרדם עד אז, בין דקה לחצי שעה. וכשיש בועה על המסך השרת עונה לכל היותר חמש דקות — ומוקדם יותר כשתזכורת נוספת מבשילה — כדי שניקוי ממכשיר אחר ומונה שמשתנה ייראו תוך כדי ולא אחרי חצי שעה. הכלל של היתומים — קריאה שנכשלה אינה ראיה על מצב העולם — חל גם עליו. **כל כשל הוא "לא ידוע", ולכולם מונה אחד ו-backoff אחד.** תשובת שגיאה, שגיאת רשת, JSON פגום ו-timeout נכנסים כולם לאותה פונקציה: המונה עולה, ההמתנה מכפילה את עצמה מדקה עד התקרה, והיא נכתבת לאותו ``__stickyRemindersBackoffUntil`` שמשרת את 429 — כך ש-``visibilitychange`` מכבד אותה בלי לדעת עליה. הצלחה מאפסת את המונה; בלי האיפוס, מצב הדיכוי מהתקלה הקודמת היה מרעיל את החלון הבא. הגרסה הקודמת חזרה כל דקה לנצח על כל כשל, ונרדמה חצי שעה על שגיאת רשת — שני הקצוות הלא נכונים, ודווקא כשהשרת נופל. והבועה שכבר הוצגה נשארת: היא המצב האחרון הידוע, וכשל אינו ראיה שהתזכורות נעלמו; רק תשובה שאומרת "אין" ו-401 מסירים אותה. ‏429 אינו כשל ואינו "אין": השרת נוקב בעצמו בהמתנה (``Retry-After``, ולפחות רבע שעה), היא נכתבת לאותו חלון, המונה אינו זז, והבועה נשארת. וכל כשל נרשם בקונסול הדפדפן — מספר הכשל, ההמתנה הבאה והסיבה — כי כשל שקט נראה בדיוק כמו "אין תזכורות". ובצד השרת כל 500 במסלולי התזכורות משאיר עקבה בלוג עם ה-traceback (``_failed``), כי הלקוח הופך אותו ל-backoff שקט וזה האות היחיד, בעוד קלט פסול — דקות שאינן מספר שלם, גוף שאינו אובייקט — הוא 400 בלי לוג, כי טעות של הלקוח אינה כשל של השרת; וכרטיס הדשבורד מציג "לא ידוע" ולא 0 כשספירת התזכורות נכשלת, כי מאז שהמונה נכון אפס הוא גם הערך הבריא. **401 עוצר, וההזדמנות הבאה היא הפוקוס הבא.** הסשן נגמר, והבועה אינה יכולה להיות נכונה עד שמתחברים מחדש. השרשרת לא דורכת טיימר, וטיימר שכבר היה תלוי מבוטל; ``visibilitychange`` מנסה מיד בכל חזרה ללשונית — רצפת הדקה שלו נועדה לשרשרת חיה ואינה חלה במצב עצור — כדי שהתחברות בלשונית אחרת תחיה את הבועה בלי רענון. 401 שמגיע בלחיצה על הבועה עוצר את השרשרת באותו אופן, והחלונית אומרת שההתחברות פגה במקום "לא ידוע". דגימה שהייתה באוויר ברגע העצירה — למשל כשה-401 הגיע בלחיצה בזמן דגימה — נזרקת כשהיא חוזרת: תשובה ישנה אינה מחזירה בועה ואינה דורכת טיימר. לכל מנגנון דיכוי צריכה להיות תשובה לשאלה "מתי הניסיון הבא כן יוצא" — וזו התשובה כאן. **ה-timeout יושב בלקוח, כי בשרת אין אחד.** ‏worker של gevent מודד את השתיקה של ה-worker ולא את אורך הבקשה (:doc:`/performance-sticky-notes`), וחיבור half-open — מעבר רשת בנייד, NAT שנפל — אינו מחזיר שום חבילה. ``fetch`` בלי סיגנל היה תלוי לנצח, ואיתו השרשרת כולה, כי היא דורכת את הטיימר הבא רק כשהבקשה הנוכחית נגמרת. ``AbortSignal.timeout`` נדחה עם ``TimeoutError`` ונופל לאותו מסלול כשל; בדפדפן שאינו מכיר אותו התקרה נבנית מ-``AbortController`` — נפילה-לאחור על היעדר יכולת, לא על כשל, ובלי מסלול גרוע יותר: בלי סיגנל בכלל, בקשה תקועה הייתה משאירה את הגארד ``inFlight`` דלוק לנצח, והפוקוס לא היה מציל. וגם החלונית: רשימה שלא נקראה אינה רשימה ריקה. ה-summary אמר שיש תזכורות — זו הסיבה שהבועה קיימת — ולכן "לא הצלחתי לבדוק" הוא המצב הנכון, ו"סגור" משאיר את הבועה. .. _sticky-notes-content-as-sent: התוכן: נשמר כמו שנשלח, וה-escape שייך לתצוגה -------------------------------------------- **גוף הפתק נשמר בדיוק כמו שנשלח, ונקרא בדיוק כמו שהוא שמור — אותו כלל של קבצים** (:doc:`/quality/code-normalization`). אין בשרת שום שלב שמנקה, מפענח או מקצץ את הגוף: לא ``html.unescape``, לא המרה של CRLF, לא מחיקה של תווי בקרה ולא ``strip``. ה-escape קורה רק בתצוגה, כשהטקסט נכנס ב-``textContent`` (:ref:`sticky-notes-render-untrusted`). **למה זה כתוב כאן.** עד אוקטובר 2026 כל כותב הריץ על הגוף ``html.unescape`` — בוובאפ ``_sanitize_text``, וב-MCP ``_sanitize_note_text`` — וגם הקוראים פענחו: ``_coerce_content_from_doc`` בוובאפ ו-``_as_note`` ב-MCP. הפענוח נוסף ב-#1227 כדי "לתקן" פתקים שנשמרו מקודדים אחרי ש-#1048 הריץ ``html.escape`` בשמירה. התוצאה: ``&`` שמשתמש הקליד נשמר כ-``&`` כבר בשמירה הראשונה, וזה לא נראה עד ריענון, כי הדפדפן הציג את מה שזכר; ``©=`` בתוך כתובת הפך ל-``©=``, כי ``html.unescape`` מפענח גם שמות ישנים בלי נקודה-פסיק; ו-``note_str_replace`` כתב בחזרה את כל הפתק מפוענח, גם את מה שאיש לא ביקש לשנות. ולפני ההסרה נמדד בייצור שאף פתק ואף גרסה בהיסטוריה אינם מכילים ישות שהפענוח בקריאה היה משנה — הרשומה ב-:doc:`/whats-new`, 10.10.2026. **כותב חדש של פתק:** - **רק פענוח של ההעברה.** ``content_b64`` הוא base64 של בתי ה-UTF-8, ו-``_decode_content_b64`` מבטל אותו. זה לא שינוי של התוכן. - **טיפוס בגבול.** ``content`` שאינו מחרוזת נדחה — בוובאפ ``_content_field`` מחזירה ``invalid_content``, וב-MCP ``_note_content_error``. בלי הבדיקה, ``str()`` היה שומר מילון כטקסט, או שמילון היה נשמר כתת-מסמך בשדה שכל הקוראים מצפים בו למחרוזת. - **אורך — דחייה, לא חיתוך.** ``MAX_NOTE_CHARS``, נמדד על מה שנשמר. - **ריקנות — על עותק.** ב-MCP גוף שכולו רווחים נדחה ב-``empty_content``, אבל הבדיקה רצה על עותק אחרי ``strip``, ומה שנשמר הוא המקור, כולל הרווחים בקצוות. - **מעבר בפונקציה המשותפת.** בוובאפ ``_note_content_from_request``, וב-MCP ``_note_content_error`` — שם גם הגוף ש-``note_str_replace`` בונה עובר בה, ולא בעותק של הבדיקות. מי שעוקף אותן מוסיף בדיוק את המקום שבו שכבה חדשה תשנה תוכן בשקט. **קורא חדש של פתק** מחזיר את הגוף כמו שהוא שמור, ולא מפענח. ``_coerce_content_from_doc`` בוובאפ ו-``_as_note`` ב-MCP הם המקומות שכבר עושים את זה. **חיפוש.** המחט היא השאילתה אחרי ``strip`` בלבד, כמו שהיא, כדי שתתאים לגוף כמו שהוא שמור. שתי דחיות, בשני הצרכנים: מחט עם NUL נענית ``invalid_query`` (``note_search_needle_error``), כי מונגו דוחה ``$regex`` כזה בחריגה — עד אוקטובר 2026 הניקוי מחק את ה-NUL, ולכן איש לא ראה את הדחייה. ו-``note_search_filter`` עצמו מעלה ``ValueError`` על מחט כזו, כי מחט התוכן יכולה להגיע אליו בנפרד (``content_query`` של ``backend.search_notes``), בלי לעבור בבדיקה של נקודת הכניסה; ומחט ארוכה מ-``NOTE_SEARCH_MAX_NEEDLE`` בוובאפ נענית ``query_too_long`` ולא נחתכת — חיתוך שקט היה מחפש קידומת, כלומר עונה על שאלה אחרת. **תשובת הכתיבה ב-MCP** מתארת את מה שנשמר ולא את מה שביקשו לשמור, באותו חוזה של הקבצים: ``note.content_sha256``, ו-``content_changed`` רק כשהקריאה כתבה גוף — עדכון של צבע או של שורה מחזיר את ה-hash בלעדיו. בעדכון — מהמסמך ש-``find_one_and_update`` מחזיר עם ``ReturnDocument.AFTER``, ולא מקריאה אחרי הכתיבה, כי הוובאפ כותב לאותם פתקים במקביל; ביצירה — מקריאה חוזרת לפי ה-``_id``, מה-primary, וכשהיא נכשלת ``note`` נושא רק ``id``, בלי hash, ו-``content_changed`` הוא ``null`` (``_created_note_answer``). הפירוט ב-:ref:`mcp-note-content-sha256`. **השומרים.** ``tests/test_content_cleaning_stays_in_the_bot.py`` נופל אם ``unescape``, או שם של פונקציית ניקוי, חוזר ל-``webapp/``, ל-``mcp_server/``, ל-``database/`` או למודולים שבשורש שפתקים עוברים בהם (``GUARDED_ROOT_MODULES``). את ההתנהגות עצמה בודקים ``tests/test_note_content_preserved_mcp.py`` (ה-MCP דרך ``mcp.call_tool``, ובו ``test_every_note_write_tool_checks_the_body_in_note_content_error``, שעובר על כל כלי כתיבה של פתקים שהשרת רושם), ``tests/test_note_content_preserved_real_mongo.py`` (מונגו אמיתי: הוובאפ, החיפוש, התזכורות וכלי הרשימה) ו-``tests/test_note_entities_browser.py`` (כרומיום: שמירה, ריענון ומצב עריכה). שני האחרונים אינם רצים ב-CI, ומי שנוגע בכתיבה או בקריאה של פתקים מריץ אותם לפני PR. .. _sticky-notes-font-device-scope: העדפה שיש לה גם מצב "רק במכשיר זה" ----------------------------------- הצד של הנושא הזה בממשק: :ref:`sticky-notes-font-token` ו-:ref:`sticky-notes-handwriting-class`. גופן הפתקים הוא הפיצ'ר השני בפרויקט שבו העדפה יכולה להישמר או לכל המכשירים או למכשיר הנוכחי בלבד; הראשון הוא ערכת הנושא. **ההכרעה זהה, ובכוונה** — משתמש שלמד את ההתנהגות במקום אחד לא אמור לגלות התנהגות אחרת בשני. הכלל בשורה אחת: **ה-cookie של המכשיר גובר רק כשה-cookie של התחולה אומר** ``device``\ **; בכל מקרה אחר ה-DB הוא המקור.** במצב ``device`` הערך אינו נכתב ל-DB כלל, ולכן טאבלט וטלפון אינם דורסים זה את זה. שלוש נקודות שנראות קטנות ואינן: **א. "לא נאמר כלום" אינו "נאמר: ברירת מחדל".** ``_decode_note_fonts`` מחזירה ``None`` על cookie חסר או פגום, ולא dict של ברירת מחדל. ההבחנה היא כל ההכרעה: הראשון נופל ל-DB, השני גובר עליו. פענוח שמחזיר ברירת מחדל בשני המקרים גורם למכשיר ב-``device`` בלי ערך שמור להציג "הכל רגיל" במקום את ההעדפה המשותפת. **ב. הערך חייב להיפתר בשרת.** ה-cookie הוא ``httponly``, ולכן JS אינו יכול לקרוא אותו כלל. ההעדפה מגיעה לתבניות דרך ``inject_globals`` ומשם ל-``_note_fonts_head.html``, שמחיל אותה בזמן פרסור ה-``head``. זו גם הסיבה שהמחלקה יושבת על ```` ולא על ````: בשלב הזה ``document.body`` הוא ``null``. **ג. כל ערך cookie חדש חייב להיכנס ל-**\ ``needs_cookie_update``. הרשימה הזו מחליטה אם בכלל נבנית תגובה עם קוקיז. ערך שנשכח בה נבנה, עובר ולידציה, ואז נזרק בחזרה המוקדמת — **התגובה 200 והקוקי לא נשלח**. זה קרה בפועל ל-``ui_note_fonts`` במצב ``device``, שאינו כותב ל-DB ולכן ``needs_db_update`` לא כיסה אותו. הבדיקה שתפסה זאת אסרה על ה-cookie **בתגובה עצמה**; בדיקה שהסתפקה בסטטוס 200 הייתה עוברת. .. _sticky-notes-font-settings: גודל הטקסט בעמוד ההגדרות: מה שמסוכן יושב סביב הבורר ---------------------------------------------------- הצד של הנושא הזה בממשק: :ref:`sticky-notes-font-token` ו-:ref:`sticky-notes-handwriting-class`. הבורר שבמודאל הלוח נשמר ב-``localStorage`` לכל לוח בנפרד. הבורר שבעמוד ההגדרות **אינו יכול** לעבוד כך, משתי סיבות שכבר מתועדות למעלה: ה-cookie הוא ``httponly`` ולכן JS אינו יכול לקרוא אותו, ו-``localStorage`` אינו עובר מכשיר ולכן "לכל המכשירים" לא היה עובד. הערך נפתר בשרת ומגיע מרונדר, בדיוק כמו הבחירה של כתב היד שלצידו. ההגדרה חלה על **שני משטחים יחד**, ולכן היא ערך יחיד ולא תת-מסמך; אין כאן את סיבוך המיזוג של ``note_fonts``, שבו כתיבה מלאה הייתה מוחקת שדות שכנים. **בורר התחולה נפרד.** ``note_fonts`` והגודל מחזיקים כל אחד cookie תחולה משלו, כדי שאפשר יהיה להחזיק גודל למכשיר אחד וכתב יד לכולם. ערבוב ביניהם היה מעביר בשקט גם את כתב היד למכשיר כשמשנים רק את הגודל — כשל שנראה תקין לחלוטין עד שמישהו בודק במכשיר שני. מה שמסוכן כאן אינו הבורר אלא **שלושה מקומות שההעדפה חייבת להירשם בהם**, וכולם כבר נשכו בפרויקט הזה: .. danger:: **א. הוולידטור של ה-ETag.** ``_note_fonts_head.html`` מרנדר את ההעדפה לתוך ה-HTML. ערך שאינו נכנס ל-``_note_fonts_etag_key`` אינו משנה את ה-ETag, השרת מחזיר 304, והדפדפן מציג את העמוד הישן עם הגודל הישן. המסלול הזה אינו תלוי בקאש של Redis — הוא רץ לפני בלוק הקאש — ולכן הוא חי בכל תצורה. המפתח נושא היום את **שתי** ההעדפות, כתב היד והגודל. **ב. ``ETAG_USER_PROJECTION``.** מסמך המשתמש נשלף לוולידטורים עם פרויקציה מפורטת. שדה שאינו בה אינו קורס — הוא פשוט חוזר לברירת המחדל, וזה נראה בדיוק כמו "ההגדרה לא נשמרה". **ג. ``_etag_needs_user_doc``.** יש קיצור דרך שמדלג על שליפת מסמך המשתמש כשכל ההעדפות הוכרעו מה-cookie. העדפה שאינה נספרת שם מחושבת לפי ברירת המחדל דווקא כשהדילוג פועל — כלומר המפתח אינו זז אחרי שינוי ההגדרה, שזה הכשל שסעיף א' נועד למנוע. ומעבר לשלושתם נשאר הכלל שכבר מתועד למעלה על ``needs_cookie_update``: ערך cookie שנשכח ברשימה נבנה, עובר ולידציה, ואז נזרק בחזרה המוקדמת — **התגובה 200 והקוקי לא נשלח**. במצב ``device`` שום דבר אינו נכתב ל-DB, ולכן ``needs_db_update`` אינו מכסה אותו. הבדיקה שתופסת זאת חייבת לטעון על ה-cookie **בתגובה עצמה**; בדיקה שהסתפקה בסטטוס 200 עוברת על הבאג במלואו. .. note:: **הערך מגיע מחוץ לתהליך, ולכן בדיקת הטיפוס קודמת לכל נגיעה בו.** גוף ה-JSON יכול להכיל מספר, רשימה, מילון או ``null``, ו-``.strip()`` על כל אחד מהם זורק — כלומר 500 במקום 400, לפני שהוולידציה הספיקה לומר מה לא תקין. הדחייה מוחלטת ולא המרה: טוקן לא מוכר שהיה נשמר כמות שהוא אינו מוחל בדפדפן, והמשתמש היה מקבל "נשמר" על גודל שלא ישתנה לעולם. הרשימה שהשרת מקבל היא **מקור שני** לצד ``STICKY_NOTE_FONT_MENUS.settings``, ואי אפשר לגזור אחד מהשני בזמן ריצה. בדיקה משווה ביניהם: טוקן שיתווסף לתפריט בלי להתווסף לשרת מפיל שמירה, וטוקן שיוסר מהתפריט ויישאר בשרת נשמר בלי דרך לבחור אותו. **והלוחות נשארים מחוץ לזה.** התבנית המשותפת מרנדרת ערך ריק כשהמשטח הוא ``board``, כי שתי החלטות על אותה מחלקה ב-```` היו נאבקות זו בזו — והמנצחת הייתה זו שרצה אחרונה, כלומר תלויה בסדר ההכללה ולא בכוונה. .. _sticky-notes-color: צבע: מזהה במסד, ``hex`` נגזר, וכינוי במקום מיגרציה --------------------------------------------------- הצבע נשמר כ**מזהה** מהפלטה (``yellow``, ``pink_light``) ולא כ-``hex``. השיקול הוא עלות השינוי הבא ולא אסתטיקה: החלפת גוון, או הוספת משמעות לצבע ("דחוף"), נוגעת בשורה אחת ב-``NOTE_COLORS`` ואף פתק אינו צריך מיגרציה. עם ``hex`` שמור, כל שינוי כזה הוא כתיבה על כל הפתקים של כל המשתמשים. **וה-``hex`` נגזר, לעולם לא נשמר לצידו.** שני שדות שחייבים להישאר מסונכרנים הם שני שדות שיסטו; לכן במסמך יש ערך אחד, ו-``note_color_hex`` גוזרת ממנו את מה שהדפדפן צובע. .. important:: **‏``note_color_hex`` עוברת דרך ``note_color_id``, ולא במקביל לה.** הגרסה הראשונה שאלה "האם זה מזהה?" ואם לא — החזירה את ה-``hex`` כמות שהוא, בלי להתייעץ עם טבלת הגוונים. שתי הפונקציות ענו אז תשובות שונות על אותו ערך: אחת אמרה "זה הצהוב", השנייה החזירה גוון אחר — כלומר הבורר מסמן עיגול אחד והפתק צבוע בשני. **וזה הסעיף שהכי קל למחוק בטעות**, כי עם ``legacy`` ריק הפער אינו מתבטא היום באף ערך: הגוון הקנוני של מזהה הוא ממילא מה שהנורמליזציה מחזירה. הוא יתבטא **ברגע שמישהו ימלא ``legacy``** — כלומר בדיוק בשינוי הגוון הראשון, ובדיוק במנגנון שנבנה כדי לעשות אותו בזול. זה אותו דפוס שכבר תועד כאן על :ref:`מפריד הטבלה ` ועל :ref:`יצרן ההזחה `: **שני חלקים שחולקים על אותה שאלה.** נתפס בבדיקה ולא בעין. .. important:: **הצהוב הקיים הוא צבע בפלטה, ולא גוון היסטורי של אחר — וזו ההחלטה שהכי קל לטעות בה.** ``#FFFFCC`` היה ברירת המחדל של הפתקים מאז ומתמיד, ולכן כמעט כל פתק שקיים היום נושא אותו. הפיתוי הוא למפות אותו אל ``yellow_light`` שדומה לו, "כדי שייכנס לפלטה" — וזה בדיוק מה שנבנה כאן בסבב הראשון. המחיר היה **שינוי גוון לכל פתק שקיים בעולם**, בפעולה שאיש לא ביקש. ומה שהצדיק אותו היה שגוי: ההנחה הייתה ש"בלי מיפוי, סינון לפי צבע לא ימצא את הפתקים האלה". זה אינו נכון — מה שנשמר במסד ומה שמוצג הם שני דברים נפרדים, והטבלה כאן כבר מספיקה כדי שהשאילתה תכסה כל גוון של מזהה. **מיגרציה היא אף פעם לא התנאי לכך שחיפוש ימצא משהו.** הפתרון נקי יותר משניהם: לצהוב הקיים עיגול משלו. אף פתק אינו זז, כולם בתוך הפלטה, והוא נשאר ברירת המחדל לפתק חדש. שני הצהובים קרובים ונפרדים, בדיוק כמו כל שני צבעים אחרים. ``legacy`` הוא מה שיחסוך את המיגרציה **הבאה**: גוונים שמזהה נשא בעבר, שממשיכים להתמפות אליו. הוא ריק היום בכוונה — אין כאן גוון שהוחלף. **מי שמחליף גוון בעתיד מוסיף את הישן לשורה שלו, ובזה המיגרציה נגמרת** — וזה הדבר שהמזהה קיים בשבילו. הקלט סובלני, האחסון קנוני ~~~~~~~~~~~~~~~~~~~~~~~~~~ ``resolve_note_color`` היא הפונקציה היחידה שמחליטה מה נכתב לשדה, ושלושת הכותבים עוברים דרכה. היא מקבלת מזהה **או** ``hex`` — כי הבורר בממשק כותב מזהה בעוד שסוכן דרך ה-MCP כותב ``hex``, ושניהם צריכים להגיע לאותה צורה מאוחסנת. הנורמליזציה אינה קוסמטיקה: ``#FFFFCC``, ``#ffffcc`` ו-``#FFC`` הם אותו פיקסל בדיוק, ובלי התכנסות לצורה אחת הם שלושה ערכים שונים במסד — כלומר סינון לפי צבע שמחזיר שליש מהתשובה, בלי שגיאה ובלי סימן. זה אותו כלל שכבר מנוסח כאן על נתיב פתק ריפו, ומאותו נימוק. .. note:: **הרג'קס ``3,8`` לא צומצם**, אף שחמש ושבע ספרות אינן ``hex`` חוקי ב-CSS. ``mcp_server/handlers`` קיבל אותן, ולכן ערכים כאלה כבר יושבים במסד ובגיבויים; דחייה שלהם עכשיו הייתה מוחקת צבע קיים במקום להשאירו ``legacy``. .. important:: **‏``resolve_note_color`` מדווחת שלא הצליחה לפענח; מה עושים עם זה הוא החלטה של הקורא.** ``default=None`` מחזיר ``""``, ושני הקוראים מחליטים אחרת **בכוונה**: * **ה-MCP דוחה** — ``invalid_color`` עם רשימת המזהים התקינים, ביצירה ובעדכון כאחד. סוכן שמקבל ``ok`` על צבע שלא הוחל **מדווח למשתמש דבר לא נכון**; ערוץ הכשל היה קיים ואיש לא קרא בו, וזה בדיוק האישור השקרי של ``CRITICAL-PATTERNS`` K11. והוא גם היחיד שיכול לתקן בניסיון הבא — אם אומרים לו מה חוקי. * **הוובאפ שומט** בעדכון. הבורר שם שולח רק מזהים שעברו בדיקה מול הפלטה בצד הלקוח, ולכן ערך פסול אינו קלט משתמש אלא באג בקוד שלנו — ודריסת הצבע הקיים בברירת מחדל הייתה הופכת אותו מ-no-op לאובדן נתונים. **ההבדל מתועד כדי שלא ייקרא כסתירה**, ומי שמיישר אותם צריך להחליט לאיזה כיוון ולמה — לא "כי הם לא זהים". .. danger:: **"לא ביקשתי צבע" אינו "ביקשתי צבע שאינו קיים", וההבחנה היא כל מה שמפריד בין ולידציה מועילה לבין ולידציה שמעצבנת.** ``None`` ומחרוזת ריקה (או רווחים בלבד) הם הראשון, ולכן נופלים לברירת המחדל בשקט — הם אינם בקשה שנכשלה. כל ערך אחר שאינו נפתר הוא השני. **והדחייה בעדכון עוצרת את הקריאה כולה ולא רק את הצבע.** דילוג על השדה בלבד היה כותב את שאר הקריאה — התוכן מתעדכן והצבע לא — כלומר עדכון שנכתב למחצה, שהוא בדיוק המצב שבו הסוכן מדווח על משהו שקרה רק חלקית. מכוסה בבדיקה, ומוטציה שמחזירה את הדילוג מפילה אותה. ``color`` בתשובת ה-API הוא ``hex``, ולא מה שמאוחסן ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ התשובה נושאת שני שדות: ``color`` תמיד ``hex`` חוקי, ו-``color_id`` שהוא המזהה או ``""`` ל-``legacy``. **אותו חוזה בדיוק בשני הסריאלייזרים** — הוובאפ וה-MCP — כי אין שום סיבה שסוכן ודפדפן יקבלו צורות שונות לאותה שאלה. ``color`` נשאר ``hex`` כי הצרכן היחיד שלו הוא ``style.backgroundColor``, ולקוח שנטען מקאש ישן היה מכניס לשם ``pink_light`` — ערך CSS לא חוקי, כלומר פתק שקוף על גבי הקוד שמתחתיו. אותה סיבה בדיוק היא שהנפילה ב-``note_color_hex`` היא לגוון ברירת המחדל ולא ל-``""``. ``color_id`` ריק אינו כשל אלא התשובה הנכונה ל"הצבע הזה אינו בפלטה", והבורר נפתח בלי סימון. עיגול שהיה מסומן שם היה אומר למשתמש שהפתק בצבע שהוא אינו בו. **ויש שני סריאלייזרים, לא אחד.** ``webapp/sticky_notes_api._as_note_response`` מגיש לדפדפן, ו-``mcp_server/backend._as_note`` מגיש לסוכן — ושניהם חייבים לגזור את הצבע, לא להעתיק אותו מהמסמך. .. danger:: **הסבב הראשון תיקן רק את הראשון, וזה נתפס בריוויו ולא בבדיקה.** ``_as_note`` העתיק את ``color`` גולמי, ולכן מרגע שהמסד החזיק את שתי הצורות זו לצד זו — פתק שהמיגרציה יישרה ופתק שלא — **סוכן שקרא שני פתקים באותו צבע בדיוק קיבל שני ערכים שונים**: ``"yellow"`` מהאחד ו-``"#FFFFCC"`` מהשני. אין לו שום דרך לדעת איזו צורה תגיע. זה גרוע מחוזה שהשתנה, וההבחנה שווה את המילים: חוזה שהשתנה לומדים פעם אחת, וחוזה שמשתנה **לפי מצב הפתק** לא לומדים בכלל. **והלקח רחב מהמקרה: ספירת הכותבים אינה ספירת הקוראים.** המעבר לפלטה סרק את כל מי שכותב ל-``sticky_notes`` ומצא ארבעה — כולל את מסלול שחזור הגיבוי, שהוא הפחות גלוי שבהם. אבל שינוי בצורת האחסון נוגע בדיוק באותה מידה בכל מי ש**קורא** ממנו, וזה חיפוש אחר לגמרי. ``_as_note_ref`` — סריאלייזר פגיעות החיפוש — נבדק גם הוא באותה הזדמנות, ואינו חושף צבע כלל. .. _sticky-notes-palette-twice: הפלטה חיה פעמיים, ובדיקה מצליבה אותן ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ המקור הוא ``sticky_notes_target.NOTE_COLORS``, אבל ``sticky-notes.js`` מחזיק עותק — אי אפשר לגזור אחד מהשני בזמן ריצה בלי לטעון פייתון בדפדפן. זו בדיוק המצוקה שכבר תועדה על הרשימה שהשרת מקבל לצד ``STICKY_NOTE_FONT_MENUS``, והתשובה שם היא התשובה כאן: **מקור שני מותר, בתנאי שבדיקה מצליבה ביניהם**. הדרך האחרת — לקרוא את המפה מ-``window``, כמו ``ADMONITION_TITLES`` — נשקלה ונדחתה. היא מחליפה תלות בבדיקה בתלות ב**סדר טעינת תבניות**, וזה בדיוק הכשל ש\ :ref:`האזהרה ` על ``note_board.html`` מתעדת: משטח שלא טען את המפה הציג הכל שגוי, בלי שגיאה ובלי לוג. .. warning:: **וההצלבה חייבת לכסות את כל השורה, לא רק את הגוון והתווית.** הגרסה הראשונה שלה השוותה שניים מהשלושה ועברה, בזמן שה-JS בנה את מפת ה-``hex`` בלי ``legacy``. התוצאה באותו רגע: גוון שזוהה בשרת ולא בלקוח, כלומר פתק שפתח בורר בלי שום עיגול מסומן. ``legacy`` ריק היום, אבל ההצלבה בודקת אותו — כי השדה הזה קיים בדיוק בשביל השינוי הבא, ושומר שלא יכסה אותו היה מגלה את זה רק אז. **שומר שבודק חלק מהשורה שומר על חלק מהתשובה.** .. _sticky-notes-color-measurement: הצבעים והמדידה ~~~~~~~~~~~~~~~ צבעים של רכיבים שמוצגים על הנייר של הפתק, ואיך הם נמדדים: :ref:`sticky-notes-details-colors` ו-:ref:`sticky-notes-in-note-search-colors`. כללי הבורר ב-``sticky-notes.css`` אינם משתמשים ב-``var()`` כלל, ובדיקה אוכפת את זה. זה אינו "סתם עוד משטח קשיח": הבורר מציג את הצבעים **עצמם**, ולכן ערכה שהייתה צובעת את הכרטיס מחדש הייתה משנה בדיוק את הדבר שהמשתמש בא להשוות אליו. שלושת המספרים שכל צבע בפלטה צריך לעמוד בהם. נמדדו ב-2026-10-27 בכרומיום, בדגימת פיקסלים על הבורר עצמו, על כל צבע ב-``NOTE_COLOR_ORDER``. ``tests/test_sticky_color_picker_browser.py`` מודדת אותם באותה שיטה ואוכפת את הספים — לא את המספרים — ולכן צבע שיתווסף לפלטה נבדק בלי שמישהו יזכור למדוד: * **טקסט על ה-swatch** — הדיו ``#2b3f6a`` מול גווני הפלטה יוצא 8.14:1 (ורוד, הגרוע) עד 10.29:1 (השנהב), כלומר מעל הסף המחמיר של WCAG AAA. לכן התווית יושבת **על** הצבע ואינה צריכה רצועה משלה. * **הגבול של ה-swatch** — הוא שמחזיק את הכל. גווני הפלטה בהירים מאוד, ו-``#f0f8ff`` הוא 1.02:1 מול כרטיס הבורר: בלי גבול הכחול והסגול פשוט נעלמים בו. הגבול הוא פס שיושב חציו על ה-swatch וחציו על הכרטיס, ולכן **נמדד לשני הכיוונים**, מול הסף 3:1 של WCAG 1.4.11 לרכיבי ממשק. בערך שנבחר, ``.7``, הגרוע הוא 3.93:1 מול ה-swatch (ורוד) ו-4.08:1 מול הכרטיס (השנהב). ``.6`` כבר על הסף (3.07 / 3.12), ו-``.35``, שנראה סביר לחלוטין, נכשל ב-1.84 / 1.76. * **טבעת הבחירה** בדיו מלא: 8.14:1 במקרה הגרוע. **מול הכרטיס, הצבע הבהיר ביותר הוא הגרוע.** הגבול הוא דיו שקופה בחלקה מעל הצבע שמתחתיו, ולכן ככל שהצבע בהיר יותר גם הגבול בהיר יותר, וקרוב יותר לכרטיס. השנהב הוא היום הבהיר בפלטה, ולכן הוא שקובע את המספר מול הכרטיס — וצבע בהיר ממנו יוריד אותו שוב. **השנהב** (``ivory``, ``#fffff0``) נוסף אחרי השוואה ידנית על פתקי בדיקה בין גוונים בהירים מהקיימים: על גוונים כהים יותר, רכיבי המארקדאון בפתק — המרקר, ``::: details``, קוד, ציטוט וקישור — לא הסתדרו, והוא היחיד שעבר את ההשוואה. בבורר: טקסט וטבעת 10.29:1, וגבול 4.41:1 מול ה-swatch ו-4.08:1 מול הכרטיס. **רכיבי המארקדאון על השנהב נבדקו בעין ולא נמדדו**, ולכן אין לו מספר מרקר כמו לגוונים שבאזהרה למטה. החיפוש בתוך פתק נבדק עליו כמו על כל נייר: שתי בדיקות הדפדפן של :ref:`sticky-notes-in-note-search-colors` רצות על כל צבע ב-``NOTE_COLOR_ORDER``, ועברו על השנהב ב-2026-10-27. .. warning:: **הטוקן היחיד בפתק שתלוי ברקע הוא המרקר: צבע אטום אחד, משותף לכל צבעי הפלטה, שיושב כעת על כל אחד מהם במקום על נייר אחד.** כל שאר הצבעים בקובץ שקופים או נגזרים מהדיו (``transparent``, ``rgba(0,0,0,…)``, ``color-mix(… , transparent)``) ולכן מסתגלים לכל גוון מאליהם; ``--sticky-mark-bg``/``--sticky-mark-line`` הם צבעים אטומים שההערה בקוד מתעדת שכוילו מול הנייר הצהוב. נמדד: מרחק המרקר מהרקע יורד מ-102 בצהוב ל-85 בכתום ול-69 בוורוד — כלומר **מתחת ל-76 שאותה הערה כבר פסלה** כ"ורוד שנבלע בנייר". הוא נשאר כמות שהוא בכל הצבעים, בהחלטה מפורשת אחרי בדיקה חזותית של האלמנטים על הפלטה: המדד הוא מרחק RGB אוקלידי, והוא גס במיוחד כשהוא משווה ורוד לוורוד ולא ורוד לצהוב. מי שיוסיף כאן צבע — או ישנה את גוון הוורוד — מודד את המספר הזה, ואם המרקר נבלע התשובה היא **טוקן מרקר פר-צבע** ולא כיול מחדש של הערך המשותף. הצהוב הקיים, שכולם כויל מולו, נשאר בפלטה ולכן הכיול המקורי לא איבד את המשטח שלו. .. note:: **המודאל נבנה כולו בצמתים, בלי ``innerHTML``** — בשונה ממודאל התזכורת שלצידו. הפטור ש\ :ref:`הכלל בפתקים ` נותן לתבנית סטטית לחלוטין תקף גם כאן, אבל ``docs/webapp/language-icons.rst`` מנסח את מה שמעליו. הרווח אינו סגנוני: שלד שנבנה בצמתים אפשר לבדוק בלי פרסר HTML, כלומר בלי להוסיף תלות לפרויקט רק כדי שהבדיקה תוכל לרוץ. ומאותה משפחה: הצבע יושב ב-``style`` של ה-swatch ולא במחלקה. כלל CSS לכל גוון היה מקור שני לאותה שאלה, וצבע חדש היה נוסף לפלטה ויוצא עיגול לבן. .. note:: **ומה שהסוכן קורא על הצבע נגזר אף הוא מהפלטה.** ``_build_note_color_doc`` ב-``mcp_server/server`` בונה את תיאור הפרמטר מ-``NOTE_COLORS`` עצמה, ולא מונה את הצבעים ביד: טקסט מוקלד היה מתיישן בשקט בצבע הבא, והסוכן היה ממשיך לראות רשימה חלקית בלי שאף בדיקה תשים לב. .. important:: **הצבע הנוכחי נקרא מהרשומה (``_getEntry``), לא מהאלמנט.** ``style.backgroundColor`` מוחזר מדפדפן אמיתי כ-``rgb(...)`` ולא כ-``hex``, ולכן השוואה מולו הייתה נכשלת תמיד ואף עיגול לא היה מסומן — מצב שנראה תקין לחלוטין. הרשומה היא כבר המקום שבו חיים מצב הביטול וזיכרון הבלוקים הפתוחים, ולכן אין כאן ``dataset`` שני שיכול להיסחף. שני חצאים למיגרציה, ואף אחד מהם אינו מיותר ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ הקריאה מנורמלת בעצמה — ``note_color_id`` מזהה ``#FFFFCC`` כ-``yellow`` בכל שליפה — ולכן התצוגה והבורר נכונים גם בלי להריץ דבר, **ואף פתק אינו משנה מראה בהרצה**. מה שהסקריפט קונה הוא צורת אחסון אחת: מסד שמחזיק חלק מהפתקים כ-``#FFFFCC``, חלק כ-``#ffffcc`` וחלק כ-``yellow`` מכריח כל שאילתת סינון לכסות את שלוש הצורות, ושכחה של אחת מהן מחזירה חלק מהתשובה בלי שגיאה. לכן ``scripts/migrate_note_colors.py`` מיישר את מה שקיים, והנורמליזציה בקריאה מכסה את מה שנכתב בין ההרצה לפריסה ואת גיבויים שמשוחזרים אחריה. הסקריפט הוא דוח בלבד כברירת מחדל, בתבנית ``migrate_note_boards.py``, והוא **אינו תנאי לפריסה**. .. important:: **‏``--normalize-legacy`` הוא דגל נפרד.** צבע שאינו בפלטה אינו מוחלף בכוח — זו הדרישה. אבל ``#AABBCC`` ו-``#aabbcc`` הם אותו פיקסל ושני ערכים במסד, כלומר אותה בעיית סינון בקטן. הדגל מיישר **כתיב בלבד** ולעולם לא צבע, וההחלטה להריץ אותו נשארת של מי שמריץ. וערך שאי אפשר לפענח כלל (``None``, מספר, זבל) נספר ומדווח ו**אינו נכתב**. שכבת התצוגה כבר מציגה אותו כצהוב; כתיבת הניחוש הזה למסד הייתה הופכת אותו לעובדה ומוחקת את העדות שהיה שם משהו אחר. .. danger:: **‏``distinct`` מפרק מערכים, וזו השרשרת שהופכת את הסקריפט הזה למסוכן.** מסמך שבו ``color`` הוא מערך אינו פתק חוקי ואף כותב אינו מייצר אחד — אבל הוא יכול לשבת במסד מכתיבה ישירה או מגיבוי פגום, והוא המקרה היחיד שבו הסקריפט עלול **להרוס** נתונים במקום ליישר אותם. שלוש חוליות, שלושתן מתועדות ב-MongoDB: 1. ``distinct`` מגיש את איברי המערך כערכים נפרדים, ולכן ``["#FFFFCC", "x"]`` תורם את המחרוזת ``"#FFFFCC"`` לסריקה ונראה בדיוק כמו ערך רגיל. 2. שאילתת שוויון תואמת **איבר** במערך, ולכן המסמך נספר ונכנס לעדכון. 3. ``$set`` דורס את השדה כולו, ולכן המערך היה מוחלף במחרוזת אחת — כלומר האיברים האחרים נמחקים, בפעולה שכל מטרתה לא לשנות שום צבע בכוח. **ההגנה היא ``$type: "array"``, והיא היחידה שעובדת.** הוא הטיפוס היחיד שמתייחס לשדה עצמו ולא לאיבריו; ``$type: "string"`` דווקא **אינו** מגן, כי על מערך הוא תואם אם איבר אחד מתאים. מסמכים כאלה נספרים ומדווחים בדוח, ולעולם אינם נכתבים. .. important:: **והכשל האמיתי כאן היה בבדיקה, לא בקוד.** הדמה של האוסף לא שחזרה את סמנטיקת המערכים של מונגו, ולכן הבאג לא היה יכול להופיע בה כלל — בדיקה ירוקה שאינה מסוגלת להיכשל. הדמה מיישמת היום את שלוש ההתנהגויות במפורש, וכל אחת מהן מצוטטת למקור לצידה. **ואחת מהן נראית חסרת משקל ואינה:** מוטציה שמסירה את "שוויון תופס איבר במערך" לבדה אינה מפילה כלום, כי ההגנה מסננת את המערך קודם. אבל מוטציה שמסירה את **ההגנה** ואת השורה הזו יחד — **עוברת**. כלומר זו החוליה שהופכת את הבדיקה למסוגלת להיכשל, ומחיקתה כ"קוד מת" הייתה משתיקה אותה בלי שאף בדיקה תרד לאדום. נמדד, לא הונח. .. danger:: **‏``update_many`` מדווח על עבודתו ב-``modified_count`` ואינו זורק כשלא נגע בכלום.** סקריפט שסופר את מה שתכנן היה מדפיס "עודכנו 3" גם כשאפס נכתבו — האישור השקרי של ``CRITICAL-PATTERNS`` K11, שכבר נשך בריפו הזה יותר מפעם אחת. המונה נגזר מתשובת המסד, ופער בין הספירה לכתיבה מדווח ואינו נבלע. .. _sticky-notes-search-regex: חיפוש בפתקים: למה רג'קס ולא ``$text`` -------------------------------------- הצד של הנושא הזה בממשק: :ref:`sticky-notes-in-note-search`. החיפוש בוובאפ וב-MCP הוא **אותה פונקציית התאמה** — ``sticky_notes_target.note_search_filter``. שני הצרכנים נבדלים רק במה שהם **מציגים**: ה-MCP מחזיר הפניות בלבד, והעמוד מוסיף תצוגה מקדימה של 200 תווים. זו ההפרדה שמונעת את המצב שבו אותה מילה מחזירה קבוצות שונות בשני המקומות. **ומאותה סיבה, הארכיון מוסתר שם ולא אצל כל צרכן.** בלי ``include_archived`` הפילטר מוסיף את התנאי של ``active_notes_filter`` — אותה הגדרה של "לא בארכיון" שתקרת המשתמש נספרת לפיה, ולא עותק שלה. הצרכנים רק מעבירים את הבקשה: בוובאפ ``include_archived=1`` ב-query, שהעמוד שולח כשהתיבה "כלול ארכיון" מסומנת, וכל ערך אחר מלבד ``0`` או כלום נדחה ב-``invalid_include_archived`` (``bool()`` על המחרוזת היה הופך ``"false"`` לאמת); וב-MCP הפרמטר באותו שם. רק ``True`` כולל, גם בפילטר וגם ב-handler של ה-MCP, שנקרא גם בלי pydantic באמצע. שתי התשובות מחזירות את מה שהוחל — ``include_archived`` בוובאפ ו-``searched_archive`` ב-MCP. ``list_board_notes`` לא השתנה: ההעברה מוחקת את ``board_id``, ולכן פתק בארכיון אינו ברשימת הלוח ממילא. .. important:: **‏``$text`` נבחן ונפסל, על נתונים אמיתיים.** אינדקס טקסט נראה כמו התשובה הנכונה — הוא מדורג, ממוין במסד, ויש עליו אינדקס אמיתי. המדידה על פתקים אמיתיים בעברית הראתה שהוא מפספס כ-**18%** מההתאמות שהרג'קס מוצא, ובמונחים נפוצים הרבה יותר: חיפוש "שימוש" החזיר 2 פתקים מתוך 27 שהרג'קס מצא. הסיבה אינה באג אלא התנהגות מתועדת: ``$text`` מתאים **טוקנים שלמים**, ו-``default_language: "none"`` מתעד במפורש שהוא מתעלם מגזירת סיומות. בעברית, שבה תחיליות וסופיות נדבקות למילה, "פתק" אינו מוצא "בפתק". רג'קס תת-מחרוזת מוצא את שניהם. **וזו גם הסיבה שההחלטה הזו מתועדת ולא רק מיושמת:** ``$text`` יחזור לעלות כהצעה, כי הוא הפתרון המתבקש לכל חיפוש טקסט. מי שמעלה אותו שוב צריך את המספר הזה מולו — ואת העובדה שמונגו מתירה **אינדקס טקסט אחד לכל אוסף**, כלומר זו החלטה שקשה לחזור ממנה. .. note:: **מה שלא נעשה, ובכוונה: נורמליזציה של ניקוד.** ``amir-bug-patterns`` H4 מתאר שחיפוש שאינו מנרמל ניקוד לא ימצא מסמך שנכתב עם ניקוד. מדידה על הפתקים שבמסד מצאה **פתק אחד** עם ניקוד אמיתי; שאר ההתאמות בטווח היוניקוד הזה היו מקף עברי ופיסוק, שאינם משפיעים על התאמת מילים. אבל הסיבה המכריעה אינה העלות. H4 דורש לנרמל את השאילתה **ואת** התוכן יחד, אחרת הנרמול חד-כיווני — כלומר שדה מאוחסן נוסף ומיגרציה. ו-``note_search_filter`` משותף: נרמול רק בוובאפ היה יוצר בדיוק את הפער שהפונקציה המשותפת קיימת כדי למנוע. אם ניקוד יהפוך לבעיה אמיתית, הוא נפתר **שם**, לשני הצרכנים יחד. .. danger:: **חיתוך התצוגה המקדימה הוא ``$substrCP``, ולעולם לא ``$substrBytes``.** אות עברית היא שני בייטים, ואינדקס שנספר בתווים שנכנס לאופרטור שמודד בבייטים נוחת באמצע תו ומפיל את השאילתה (``Location28657``). זה בדיוק האירוע שקרה בחיפוש הקוד בריפו הזה: השגיאה נבלעה ב-``try``, מסלול חלופי יקר רץ במקומה, והמשתמש קיבל "לא נמצאו תוצאות" על חיפוש שמעולם לא רץ. ומאותה סיבה, **ההדגשה נעשית בצד הלקוח ולא לפי היסטים מהשרת**: השרת סופר תווים, מונגו נקודות-קוד, ו-JS יחידות UTF-16. שלוש יחידות לאותו טקסט הן שלוש הזדמנויות לערבב, ומי שמערבב מקבל הדגשה שזזה במילה — או שגיאה. ההתאמה נעשית על ה-200 תווים שכבר הגיעו, ואין מה להעביר. .. important:: **סדר שלבי הצינור אינו קוסמטי.** ``$project`` שמסיר את גוף הפתק חייב לשבת **לפני** ה-``$sort``. בסדר ההפוך מונגו החזירה בפרודקשן שגיאה 292 (``QueryExceededMemoryLimitNoDiskUseAllowed``), כי ``$sort`` מאגר בזיכרון את מה שהוא ממיין; ``allowDiskUse`` אינו מציל, כי Atlas מתעלם ממנו בקלאסטרים חינמיים ו-Flex. **וה-**\ ``$project``\ **מונה את מה שנשאר, ולא את מה שיורד** (``_SEARCH_HIT_PROJECTION`` ב-``webapp/sticky_notes_api.py``). עד אוקטובר 2026 הוא היה ``{"content": 0}``, ובמיקום הנכון — ובכל זאת הגוף הגיע למיון: החרגה שבאה אחרי ``$addFields`` לא הורידה אותו. ``explain`` מול מונגו 8 על 200 פתקים ארוכים בעברית, בתקרה של 50, הראה ``totalDataSizeSortedBytesEstimate`` של 1,792,484 בייטים — 51 גופים שלמים — ו-57,260 בהכללה, עם אותה תוצאה. זה ``amir-bug-patterns``, ``BY-STACK/mongodb.md`` דפוס 5, שנותן את הפונקציה הזו כדוגמה. הרשימה היא השדות ש-``_as_search_hit`` קורא, כל ``TARGET_FIELDS`` (כדי שיעד שיתווסף יגיע מאליו), והשדות שנגזרו ב-``$addFields``. בדיקה ב-``tests/test_notes_search.py`` משווה, לכל סוג יעד, פגיעה מהצינור לפגיעה שנבנית מהמסמך המלא, והמדידה עצמה נעולה ב-``tests/test_note_archive_mongo.py``. והדגשה חשובה על **איך זה נבדק**: לא על ערך ההחזרה של פונקציית הבנייה, אלא על הצינור שאתר הקריאה באמת שולח ל-``aggregate``. אפשר לכתוב בונה נכון לחלוטין ולשכוח לחבר אליו את הראוט — והבדיקה נשארת ירוקה. התקדים הוא ``tests/test_files_pipelines_drop_heavy_fields_early.py``, שנכתב אחרי שבדיוק זה קרה. .. important:: **האינדקס שהחיפוש צריך הוא ``(user_id, updated_at)``, וגילינו אותו במדידה ולא בהיגיון.** הדוקסטרינג של ``note_search_filter`` טען שנים ש-``user_id`` כפרדיקט ראשון חוסם את הסריקה למכסת המשתמש "ואינו COLLSCAN". ``explain`` על השאילתה האמיתית הראה שהתוכנית **הזוכה** בוחרת דווקא ב-``updated_desc`` — כלומר סורקת את הפתקים של כל המשתמשים לפי סדר עדכון. התוכניות שנשענות על תחילית ``user_id`` נדחו כולן, כי כל אחת דורשת מיון חוסם. התיאור הישן תיאר את מה שנדחה. **ההסתרה של הארכיון הוסיפה אינדקס משלה, ומאותה סיבה בדיוק — מדידה.** עם ``archived_at: None`` בפילטר, המתכנן בחר בין ``user_updated_idx`` (סדר מוכן, ``archived_at`` כשארית) לבין ``user_archived_idx`` (גבולות הדוקים ו-``SORT`` חוסם). לשאילתה של מילה נדירה ניצח השני, ומונגו שמרה אותו במטמון התוכניות — ושאילתה של מילה נפוצה שרצה אחריה השתמשה באותה תוכנית וקראה את כל הפתקים הפעילים של המשתמש, במקום לעצור בתקרה. נמדד בהרצה אמיתית עם הפרופיילר: 1000 מסמכים שנקראו במקום 22, בכל הרצה. ``user_archived_updated_idx`` — ``(user_id, archived_at, updated_at)`` — נותן שוויון, שוויון ואז סדר המיון, ולכן התוכנית קוראת לפי הסדר ונעצרת בתקרה גם מתוך המטמון. חיפוש לפי שם בלבד נשען עליו גם הוא, ועם ``include_archived`` התוכנית חוזרת ל-``user_updated_idx``. נעול ב-``tests/test_note_archive_mongo.py``, שמריץ את החיפוש האמיתי של ה-MCP. **וכל הוספת אינדקס לפתקים נוגעת בשלושה מקומות:** ``_QUERY_INDEX_SPECS`` בוובאפ, הרשימה המקבילה ב-``mcp_server.backend._notes_coll``, ו**קידום** ``_INDEX_READY_CACHE_KEY``. בלי הקידום, כל תהליך שקורא דגל מוכנוּת חי מדלג על הבנייה ליממה שלמה, והאינדקס החדש פשוט לא נבנה — בשקט. ``tests/test_note_boards_api.py`` נועל את שני הצדדים יחד, כך שאי אפשר לשנות אחד בלי לגעת בשני. .. note:: **סינון הצבע: מיגרציה קודם, ופונקציה אחת שיודעת את התשובה.** ``note_color_query`` מחזירה ``{"color": {"$in": [<מזהה>, <רג'קס מעוגן חסר-רישיות>]}}``. המזהה תופס את מה שנכתב אחרי המעבר לפלטה, והרג'קס את ה-``hex`` ההיסטורי בכל צורת כתיבה — ``#FFFFCC``, ``#ffffcc``, ``#FfFfCc``, ``#ffc``. **רג'קס ולא רשימת כתיבים**, כי רשימה הייתה צריכה למנות את כל צירופי הרישיות של שש ספרות; דגל ``i`` אחד עושה את אותה עבודה. ``$in`` ולא ``$or``, כי מונגו מתירה רג'קס כערך בתוך ``$in`` ואינה מתירה שם ביטוי ``$regex``. **והחצי השני הוא ``scripts/migrate_note_colors.py``**, שמיישר את מה שכבר קיים. כשכל הסביבות מיושרות ומאומתות, מוחקים את הרג'קס מ-``note_color_query`` — נקודת הסרה אחת, ואין מקום שני לתקן. .. _sticky-notes-threads-server: שרשורי תגובות: השרת ------------------- הצד של הנושא הזה בממשק: :ref:`sticky-notes-threads-ui`. השרת: הבעלות קודמת לכל, והתשובה מהמסד ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ כל כתיבה עוברת ב-``note_threads.py``, הכותב היחיד לאוסף, והראוטים תחת ``/api/sticky-notes/note//threads`` ב-``webapp/sticky_notes_api.py`` הם עטיפה דקה מעליו (הרשימה ב-:doc:`/webapp/api-reference`). ``user_id`` מגיע מה-session בלבד, ו-``_owned_note_id`` בודק שהפתק קיים ושייך למשתמש **לפני** כל קריאה למודול: פתק של משתמש אחר ופתק שאינו קיים עונים אותו 404, ושום דבר לא נכתב. קודי הסירוב של המודול עוברים כמו שהם, עם הסטטוס מ-``_THREAD_ERROR_STATUS``. **התשובה לכתיבה נבנית ממה שהמסד החזיר** (``_thread_written``): מצב השרשור אחרי הכתיבה, התגובה כפי שנשמרה, ותקצירי כל השרשורים של הפתק, שנקראים אחרי הכתיבה — כך שהמספר "שרשור N" של שרשור חדש מגיע מהמקום היחיד שמחשב אותו. כשל בקריאה החוזרת אינו כשל של הכתיבה, שכבר קרתה: ``threads: null`` (או ``thread: null``) אומר ללקוח "לא ידוע, קרא שוב". **השם, ראשי התיבות והגוון של הכותב נקבעים בשרת**: ``nt.author_label`` (אצל המשתמש — ``first_name`` מה-session, ו-``_THREAD_USER_FALLBACK_NAME`` כשהוא ריק), ``_author_initials`` ו-``_author_tone``. כך אין ב-JS עותק שני של מיפוי הלקוחות, וגוון שאין לו כלל ב-CSS מקבל את האפור של "סוכן אחר". **הבועות מגיעות עם הפתקים.** שלושת ראוטי הרשימה — לוח, קובץ וריפו — מצרפים לכל פתק את תקצירי השרשורים שלו (``_with_thread_summaries``), בשאילתה אחת לכל הרשימה (``nt.list_threads_for_notes``) ובלי התגובות עצמן. כשל מסד בשאילתה הזו אינו מפיל את הרשימה: הפתקים חוזרים בלי ``threads`` ועם ``threads_unavailable``, כדי שהלקוח יבחין בין "אין שרשורים" לבין "לא נטענו". התגובות נשלפות רק כשפותחים שרשור, שרשור אחד בכל פעם: שרשור יכול להגיע ל-``MAX_THREAD_BODY_BYTES``, ומספר השרשורים על פתק אינו מוגבל. **מחיקה:** ``delete_note`` מוחק את השרשורים (``nt.delete_note_threads``) רק אחרי ש-``delete_one`` של הפתק החזיר ``deleted_count == 1`` — בסדר ההפוך, מחיקה שנכשלה הייתה מוחקת שרשורים של פתק שנשאר. כשל מסד (``PyMongoError``) במחיקת השרשורים נרשם בלוג ואינו מכשיל את הבקשה: הפתק כבר נמחק, ושרשור יתום אינו מוצג בשום מקום. חריגה מסוג אחר היא באג, ולא "המסד לא ענה" — היא עולה ל-``except`` הכללי של הראוט, שעונה 500, למרות שהפתק כבר נמחק. **התקרה של תגובה מגיעה לממשק מהשרת**: ``MAX_COMMENT_CHARS`` עובר לתבניות כ-``max_thread_comment_chars`` (context processor ב-``webapp/app.py``), ומשם ל-``window.NOTE_THREAD_MAX_CHARS`` בשלושת העמודים — ולא מוקלד ב-JS. .. _sticky-notes-archive: ארכיון: היעד הרביעי -------------------- הצד של הנושא הזה בממשק: :ref:`sticky-notes-archive-ui`. ומה המשתמש רואה: :ref:`sticky-note-archive`. **המצב הוא סוג היעד.** פתק בארכיון הוא פתק שהיעד שלו ``archive``, ושדה הזיהוי שלו ``archived_at`` — הזמן שבו הועבר. אין דגל "בארכיון" לצד שדות היעד הקיימים: דגל כזה היה מצב שני שיכול לסתור את הראשון, ופתק שנושא גם ``board_id`` וגם דגל היה מחייב הכרעה בכל קורא. לכן ``TARGET_FIELDS`` קיבל שורה, ו-``build_note_target(archived_at=...)`` בונה את היעד כמו כל יעד אחר. ומאותה סיבה **השם בלוח המקור משתחרר**: אינדקסי השם הייחודיים מסוננים על ``board_id`` ועל ``repo_path``, וההעברה מוחקת אותם. בארכיון אין אינדקס שם, ושני פתקים עם אותו שם מותרים. **שדה זיהוי שהוא תאריך.** כל שדה זיהוי אחר נבדק כמחרוזת לא ריקה; ``archived_at`` נבדק כ-``datetime`` (``_is_archive_stamp``, דרך ``_IDENTITY_PRESENT``). זו אותה הגדרה של השאילתות, ``{"$type": "date"}``: אילו כל ערך לא ריק היה "ארכיון", מחרוזת בשדה הייתה הופכת פתק ל"פתק ארכיון" שאינו ברשימת הארכיון ואינו נספר בתקרה שלו — פתק שאין דרך להגיע אליו. ``note_target_kind`` מחזיר את הסוג בלי לזרוק, למי שרק מסתעף לפיו: הקישור הקבוע ``/note/`` (``webapp/boards_ui.note_permalink``) מפנה פתק בארכיון ל-``/boards/archive?note=``, ומעביר ``thread`` הלאה כמו בכל יעד. **שני מסננים, וצורתם נמדדה.** ``active_notes_filter`` כותב ``"archived_at": None`` (חסר או ``null``) ולא ``$exists: False``, ו-``archived_notes_filter`` כותב ``$type: "date"``. רק הצורות האלה נענות מהאינדקס ``user_archived_idx`` — ``(user_id, archived_at, _id)`` — בלי לקרוא אף מסמך: ``COUNT_SCAN`` עם אפס מסמכים שנקראו, כשהחלופה ``$exists: False`` קוראת כל פתק של המשתמש. ספירת התקרה רצה בכל יצירת פתק, ולכן זה משנה. נמדד ב-explain על מה שהקוד שולח (``count_documents`` נשלח כ-``aggregate`` של ``$match`` ואחריו ``$group``), ונעול ב-``tests/test_note_archive_mongo.py``. האינדקס נוסף בשלושת המקומות שכל אינדקס פתקים נוגע בהם (:ref:`sticky-notes-search-regex`, בסוף הסעיף). **תקרה נפרדת, ותקרת המשתמש סופרת רק פתקים פעילים.** ``MAX_ARCHIVED_NOTES_PER_USER`` היא תקרה משלה, בנוסף ל-``MAX_NOTES_PER_USER``, ואדמין פטור ממנה — שתיהן הגנת-משאבים. תקרת המשתמש נספרת דרך ``active_notes_filter`` בכל כותב שסופר אותה; מי שמוסיף כותב חדש סופר דרך הפונקציה, ולא ``{"user_id": ...}``. ``_WRITERS`` ב-``tests/test_note_archive_mongo.py`` הוא רשימת הכותבים, והטסט מריץ כל אחד מהם מול תקרה מלאה של פתקי ארכיון (נוצר) ומול תקרה מלאה של פתקים פעילים (נדחה). **ההעברה: כתיבה מותנית אחת.** ``POST /api/sticky-notes/note//archive`` (``archive_note``) בודק לפי הסדר: הפתק ובעלותו; "כבר בארכיון" (``200`` עם ``already_archived``, בלי כתיבה — לחיצה כפולה או מכשיר שני אינם שגיאה); השם (``resolve_archive_title``: פתק בלי שם דורש שם, ושם ארוך מהתקרה נדחה ב-``title_too_long`` ולא נחתך); והתקרה. הכתיבה היא ``find_one_and_update`` שהפילטר שלו כולל את ``active_notes_filter`` — שתי העברות מקבילות של אותו פתק, ורק אחת כותבת. ``None`` מהמסד אינו הצלחה: קוראים שוב ומסווגים — נמחק בינתיים (404), הועבר בבקשה המקבילה (``already_archived``), או ``archived_at`` פגום שאינו תאריך (``409 archive_not_applied``). העדכון נבנה ב-``archive_note_update``: ``$set`` של ``archived_at``, השם ו-``updated_at``, ו-``$unset`` של כל שדות היעד האחרים — **נגזר מ-**\ ``TARGET_FIELDS``\ **,** כך שיעד שיתווסף נמחק בהעברה בלי שמישהו יזכור אותו — ושל שדות המיקום על היעד הקודם (``ARCHIVE_DROPPED_PLACEMENT_FIELDS``: מצב ההצמדה והעוגן). התשובה נבנית מהמסמך שהכתיבה השאירה (``ReturnDocument.AFTER``). **השרשורים נסגרים אחרי הכתיבה, והתזכורת לא משתנה.** פתק בארכיון אינו ממתין לתגובה, ולכן ``nt.resolve_open_threads`` מסמן את השרשורים הפתוחים שלו "נפתר" (``update_many`` אחד, עם ``status`` פתוח בפילטר) — והם יורדים משורת הפריימר. זה קורה **רק אחרי** שההעברה נכתבה, וכשל שם (``PyMongoError``) אינו מבטל אותה: ``threads_resolved: null`` אומר ללקוח שהשרשורים לא נסגרו, והכשל נרשם בלוג. התזכורת נשארת פעילה, וההתראה פותחת את ``/note/``, שמפנה לארכיון. המסמך ב-``note_reminders`` עדיין נושא את ``board_id``/``file_id`` מהרגע שהתזכורת נקבעה — תיאור ישן שאינו משמש לניווט, ולכן לא מעודכן. **הרשימה והפתק הבודד.** ``GET /api/sticky-notes/archive`` מחזיר כרטיסים בלבד — שם, צבע וזמן העברה (``_ARCHIVE_CARD_PROJECTION``, וסריאלייזר מרשימה מפורשת, ``_as_archive_card``) — מהחדש לישן (``ARCHIVE_LIST_SORT``, עם ``_id`` כשובר שוויון). **בלי** ``limit``: התשובה היא הרשת כולה, ו-``limit`` בלי דפדוף היה מסתיר מאדמין, שפטור מהתקרה, את הפתקים הישנים בלי שום סימן. העבודה פרופורציונלית לתשובה בדרך אחרת: explain מראה ``IXSCAN`` בלי ``SORT``, וכל מסמך שנקרא חוזר. כשל ← 500, ולא רשימה ריקה. ``GET /api/sticky-notes/archive/`` מחזיר פתק מלא עם תקצירי השרשורים, ורק פתק שבארכיון. **החיפוש: הארכיון בחוץ, אלא אם ביקשו.** פתק בארכיון עזב את המקום שבו עבדו עליו, ולכן שני החיפושים — העמוד וה-MCP — אינם מחזירים אותו בלי בקשה מפורשת. ההסתרה, הפרמטר והאינדקס שהיא דרשה — :ref:`sticky-notes-search-regex`. **השדה בכל מקום שמונה שדות יעד.** ``_as_note_response`` בוובאפ ו-``_NOTE_FIELDS`` ב-MCP נושאים את ``archived_at``, וגם שתי ההיטלות הצרות של ה-MCP — פגיעת חיפוש (``_NOTE_REF_FIELDS``) וה-``$project`` של ``pending_note_threads``: בלעדיו ``note_target_ref`` היה מסווג פתק ארכיון כ-``unknown``. בפריימר פתק כזה "בארכיון" (``_pending_item`` ב-``mcp_server/primer.py``). **גיבוי ושחזור.** הייצוא ממיר את ``archived_at`` ל-ISO כמו שאר התאריכים, והשחזור מחזיר פתק ארכיון **לארכיון**, עם השם (``_restore_archive_note``), בענף שקודם לענף הלוח: עד היום פתק בלי לוח ובלי ``file_name`` נזרק בשחזור בשקט. כפילות נבדקת לפי גוף זהה בארכיון, ותקרת הארכיון חלה גם בשחזור. מה לבדוק, ובמה -------------- - **מודל טהור** (יעד, נורמליזציה, מכסה): טסטי יחידה, בלי מסד. כל טסט חדש נבדק במוטציה — ביטול התיקון חייב להפילו. - **אינדקס ייחודי ופילטר חלקי**: מול מונגו אמיתי בלבד. סטאב מחזיר ``None`` מ-``create_index`` וכל בדיקת כפילות עוברת בו, גם אם האינדקס מעולם לא נוצר. - **צבע** (:ref:`sticky-notes-color`): הפלטה המוקלדת והנורמליזציה — ``tests/test_sticky_notes_target.py``; ההצלבה בין הפלטה בפייתון לעותק ב-JS, והבורר עצמו — ``tests/sticky-notes-target.test.js``, ב-node. שניהם רצים ב-CI. המספרים שכל צבע עומד בהם בבורר — ``tests/test_sticky_color_picker_browser.py``, על כל צבע ב-``NOTE_COLOR_ORDER``, ואינו רץ ב-CI. - **ארכיון**: ``tests/test_note_archive.py`` — השכבה הטהורה והראוטים מול דמה עם הסמנטיקה של מונגו למסננים, ורץ ב-CI. ``tests/test_note_archive_mongo.py`` — explain, המרוץ, כל הכותבים, גיבוי ו-MCP מול מונגו אמיתי, ואינו רץ ב-CI. ``tests/test_note_permalink.py`` — ענף הארכיון בקישור הקבוע.