הראה קוד מקור ל webapp.sticky_notes_api

"""
Sticky Notes API for Markdown preview
- Stores user-specific notes per file in MongoDB
- Endpoints: list, create, update, delete
"""
from __future__ import annotations

from flask import Blueprint, jsonify, request, session
from functools import wraps
from typing import Any, Dict, List, Optional, Tuple, cast
from datetime import datetime, timezone, timedelta
import time
import html
import re
import base64
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_NOTE_TITLE, MAX_NOTES_PER_BOARD, MAX_NOTES_PER_USER,
    MAX_NOTES_PER_REPO_FILE,
    normalize_note_title,
    normalize_repo_path,
    ensure_title_index,
    ensure_repo_title_index,
    repo_notes_filter,
    title_is_taken,
    repo_title_is_taken,
    mirrored_repo_names,
    repo_file_exists,
    resolve_note_color,
    note_color_hex,
    note_color_id,
)
# מצב התזכורת — אותה שכבה טהורה, ומאותה סיבה: שלושה מודולים שואלים "האם
# התזכורת פעילה", והתשובה חייבת להיות אחת.
from note_reminder_state import (
    acknowledge_fields,
    active_reminder_filter,
    armed_fields,
    parse_remind_at,
    seconds_until as _seconds_until,
    snoozed_fields,
)
# ``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
    from bson.errors import InvalidId  # type: ignore
except Exception:  # pragma: no cover
    class InvalidId(Exception):
        pass
    def ObjectId(x):  # type: ignore
        # Minimal fallback that accepts hex-like strings; raises on others
        s = str(x or "")
        if len(s) != 24:
            raise InvalidId("malformed ObjectId")
        return s

# Fail-open observability and tracing
try:  # type: ignore
    from observability import emit_event  # type: ignore
except Exception:  # pragma: no cover
[תיעוד] def emit_event(event: str, severity: str = "info", **fields): # type: ignore return None
try: # type: ignore from observability_instrumentation import traced # type: ignore except Exception: # pragma: no cover def traced(*_a, **_k): # type: ignore def _inner(f): return f return _inner # Access to Mongo client via app helper
[תיעוד] def get_db(): from webapp.app import get_db as _get_db # local import to avoid circulars return _get_db()
# Blueprint sticky_notes_bp = Blueprint("sticky_notes", __name__, url_prefix="/api/sticky-notes") logger = logging.getLogger(__name__) try: from cache_manager import cache # type: ignore except Exception: cache = None # type: ignore # Module-level guard to ensure indexes only once per process _INDEX_READY = False _INDEX_READY_LOCK = threading.Lock() #: **גרסת המפתח הועלתה ל-v3 בכוונה.** הדגל הזה נכתב היום רק אחרי ששני #: אינדקסי השם אומתו — של הלוח ושל הריפו — ולכן קריאתו מותר שתדליק גם את #: ``_TITLE_INDEX_OK`` וגם את ``_REPO_TITLE_INDEX_OK``. דגל ``v2`` שנכתב #: בגרסה הקודמת של הקוד העיד על אינדקס הלוח בלבד, ותחת אותו מפתח הוא היה #: מתפרש כאימות של אינדקס הריפו שלא היה — למשך יממה, ובכל התהליכים. #: #: **ו-v4 מאותה סיבה בדיוק.** הדגל אינו אומר "בדקנו פעם" אלא "כל האינדקסים #: שברשימה קיימים", ולכן הוא חייב לעלות בכל פעם שהרשימה משתנה. ``v4`` #: מלווה את הוספת ``user_updated_idx``: בלי ההעלאה, כל תהליך שקורא דגל #: ``v3`` חי היה מדלג על ``_ensure_indexes`` ליממה שלמה, והאינדקס החדש לא #: היה נבנה באף אחד מהם — כשל שקט שבדיוק כמוהו כבר קרה כאן. _INDEX_READY_CACHE_KEY = "sticky_notes_indexes_ready_v4" _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 #: האם ``one_title_per_repo_file_v1`` **אומת** במסד בהרצה האחרונה. #: #: נפרד מ-``_TITLE_INDEX_OK`` בכוונה: שני האינדקסים נבנים בנפרד, וכשל של #: אחד אינו עדות לגבי השני. דגל משותף היה מדליק אכיפה שלא אומתה. _REPO_TITLE_INDEX_OK = False #: מתי מותר לנסות לבנות שוב אחרי כשל, כ-``time.monotonic``. #: #: בלי החסם הזה כשל מתמשך באינדקס אחד היה גורם ל**כל בקשה** להיכנס #: ל-``_ensure_indexes``, לתפוס את הנעילה ולבנות מחדש את כל שש קבוצות #: האינדקסים — לולאה חמה שמסריאלת את השירות כולו סביב מנעול אחד. _INDEX_RETRY_AFTER = 0.0 #: כמה להמתין בין ניסיונות בנייה כושלים, בשניות. _INDEX_RETRY_SECONDS = 60.0 #: **מפרט שבעת אינדקסי השאילתה של** ``sticky_notes`` **— מקור אמת אחד.** #: #: שלושת הצרכנים — המסלול המהיר (``create_indexes``), מסלול הגיבוי #: (``create_index`` אחד-אחד), והאימות בקריאה חוזרת — נגזרים כולם מכאן. #: כשכל אחד החזיק עותק משלו, אינדקס שנוסף לרשימה אחת ולא לשתיים האחרות #: פשוט לא נבנה במסלול הגיבוי **ולא סומן כחסר באימות** — כלומר בדיוק #: ההשמטה השקטה שהאימות נועד לתפוס. _QUERY_INDEX_SPECS: Tuple[Tuple[str, List[Tuple[str, int]]], ...] = ( ("user_file_idx", [("user_id", 1), ("file_id", 1)]), ("user_file_created", [("user_id", 1), ("file_id", 1), ("created_at", 1)]), ("updated_desc", [("updated_at", -1)]), # שאילתת ה-list הראשית היא ``$or`` על scope_id/file_id ("user_scope_idx", [("user_id", 1), ("scope_id", 1)]), # פתקי לוח: שאילתה ישירה, בלי ``$or`` ובלי code_snippets ("user_board_idx", [("user_id", 1), ("board_id", 1)]), # פתקי ריפו: המפתח הוא הזוג ``(repo_name, repo_path)``, ולכן האינדקס # מורכב משניהם. בלי ``ref``/ענף — בכוונה: פתק שנרשם על ``main`` חייב # להופיע גם בענף PR. ("user_repo_idx", [("user_id", 1), ("repo_name", 1), ("repo_path", 1)]), # חיפוש פתק לפי שם חוצה את שלושת היעדים, ולכן אינו יכול להישען על אף # אחד משני האינדקסים הייחודיים: ה-``partialFilterExpression`` שלהם # דורש ``board_id``, או ``repo_name`` **וגם** ``repo_path`` — פרדיקטים # שהחיפוש אינו נושא, ולכן הוא אינו תת-קבוצה של אף אחד מהם. ("user_title_idx", [("user_id", 1), ("title", 1)]), # **התחילית והמיון יחד.** ``explain`` על חיפוש הפתקים — הפילטר של # ``note_search_filter`` עם ``.sort("updated_at", -1)``, כפי # ש-``mcp_server.backend.search_notes`` מריץ אותו — הראה שמונגו בוחרת # דווקא ב-``updated_desc``, כלומר סורקת את הפתקים של **כל** המשתמשים # לפי סדר עדכון ומסננת ``user_id`` כשארית. התוכניות שנשענות על תחילית # ``user_id`` נדחות כולן, כי כל אחת מהן דורשת ``SORT`` חוסם (32MB). # האינדקס הזה הוא היחיד שנותן את שניהם: איתור ישיר של המשתמש, ובתוכו # סדר מוכן. ("user_updated_idx", [("user_id", 1), ("updated_at", -1)]), ) def _emit_index_event(stage: str, duration_ms: Optional[int] = None, error: Optional[str] = None) -> None: """Emit lightweight observability events without failing the request.""" try: severity = "info" if not error else "error" emit_event( "sticky_indexes_warmup", severity=severity, stage=stage, duration_ms=duration_ms, error=error, ) except Exception: pass def _cache_flag_ready() -> bool: """Check shared cache flag (best-effort) to avoid duplicate index builds. **הדגל המשותף גורר את שני דגלי אינדקס-השם.** הוא נכתב רק דרך ``_mark_indexes_ready``, שרץ רק כששני אינדקסי השם אומתו — כלומר קיומו הוא עדות לכך ששני האילוצים חיים. בלי הגרירה הזו, worker חדש שיורש את הדגל היה מדלג על הבנייה, נשאר עם דגל כבוי לתמיד, ומריץ שאילתת גיבוי מיותרת בכל כתיבה עם שם. גרסת המפתח הועלתה ל-``v3`` בדיוק בשביל זה: דגל ``v2`` ישן העיד רק על אינדקס הלוח, ואסור שיסמן את אינדקס הריפו כמאומת בלי שאומת. """ global _INDEX_READY, _INDEX_CACHE_LAST_CHECK, _TITLE_INDEX_OK, _REPO_TITLE_INDEX_OK if _INDEX_READY: return True cache_obj = cache if 'cache' in globals() else None if cache_obj is None or not getattr(cache_obj, "is_enabled", False): return False now = time.time() if now - _INDEX_CACHE_LAST_CHECK < 30.0: return False _INDEX_CACHE_LAST_CHECK = now try: flag = cache_obj.get(_INDEX_READY_CACHE_KEY) except Exception: flag = None if flag: _INDEX_READY = True _TITLE_INDEX_OK = True _REPO_TITLE_INDEX_OK = True return True return False def _mark_cache_flag() -> None: cache_obj = cache if 'cache' in globals() else None if cache_obj is None or not getattr(cache_obj, "is_enabled", False): return try: cache_obj.set( _INDEX_READY_CACHE_KEY, {"ready": True, "ts": int(time.time())}, _INDEX_READY_CACHE_TTL_SECONDS, ) except Exception: pass def _mark_indexes_ready(duration_ms: Optional[int] = None) -> None: global _INDEX_READY _INDEX_READY = True _mark_cache_flag() _emit_index_event("done", duration_ms=duration_ms)
[תיעוד] def kickoff_index_warmup(*, background: bool = True, delay_seconds: float = 0.0) -> None: """Run index warmup once during startup so requests won't block on it.""" if _INDEX_READY or _cache_flag_ready() or _WARMUP_TRIGGERED.is_set(): return _WARMUP_TRIGGERED.set() def _job(): if delay_seconds > 0: try: time.sleep(delay_seconds) except Exception: pass _ensure_indexes() if background: try: # אם אנחנו בתוך event loop (למשל שירות aiohttp) – עדיף להעיף ל-executor כדי לא לחסום. # ב-Flask/Wsgi (ללא לולאה רצה) ניפול חזרה ל-thread דמון. try: loop = asyncio.get_running_loop() except RuntimeError: loop = None if loop is not None: loop.run_in_executor(None, _job) else: threading.Thread(target=_job, name="sticky-index-warmup", daemon=True).start() except Exception: _job() else: _job()
def _ensure_indexes() -> None: global _TITLE_INDEX_OK, _REPO_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 try: from pymongo import IndexModel # type: ignore # הכיוונים כבר במפרט (``-1`` ל-``updated_desc``); ``ASCENDING`` # ו-``DESCENDING`` הם 1 ו--1 בדיוק, ולכן אין כאן תרגום. indexes = [ IndexModel(keys, name=name) for name, keys in _QUERY_INDEX_SPECS ] coll.create_indexes(indexes) except Exception: # Best-effort: if pymongo typings not available or running in stub env # # **``try`` לכל אינדקס בנפרד, ולא אחד סביב כולם.** ``try`` # יחיד הופך את הרשימה לשרשרת: כשל באינדקס הראשון מדלג על כל # השאר, והם לא נבנים לעולם — בלי שום שגיאה. האינדקס האחרון # ברשימה הוא הקורבן הסביר ביותר. בלוק ``note_reminders`` # שמתחת כבר בנוי כך; זה היה החריג. for name, keys in _QUERY_INDEX_SPECS: try: coll.create_index(keys, name=name) except Exception: logger.warning( "sticky notes index %s creation failed (non-fatal)", name, exc_info=True ) # **אימות בקריאה חוזרת לשבעת אינדקסי השאילתה.** # # עד כאן שום דבר לא נבדק: ``create_indexes`` ו-``create_index`` # מדווחים הצלחה בערך ההחזרה, וערך החזרה של כתיבה אינו אימות. # בסביבת stub הם אפילו no-op. קריאה אחת ל-``index_information`` # הופכת "בנינו" ל"קיים", וחסר מתועד בלוג במקום להיעלם. # # **ובכוונה אינו חוסם את** ``_INDEX_READY``, בשונה משני אינדקסי # השם שמתחת. ההבדל אינו קפריזה: אינדקס אכיפה חסר הוא באג # נכונות — ``duplicate_title`` מובטח ולא קיים — ולכן ראוי # שיחזור לנסות. אינדקס שאילתה חסר הוא האטה. חסימת הדגל עליו # הייתה מריצה את הבוטסטראפ כולו בכל בקשה, לנצח, בכל סביבת stub # וגם מול אינדקס שפשוט אינו ניתן לבנייה. try: present = coll.index_information() or {} missing, mismatched = [], [] for name, keys in _QUERY_INDEX_SPECS: info = present.get(name) if info is None: missing.append(name) continue # **השוואת מפתחות, לא רק שם.** אינדקס שקיים תחת השם הזה # עם מפתחות אחרים גורם למונגו לדחות את היצירה # ב-``code 85/86``, ובדיקה לפי שם בלבד הייתה מדווחת # "קיים" — כלומר בדיוק המצב שבו השאילתה ממשיכה # ב-COLLSCAN בשקט. מונגו מחזיר ``key`` כרשימת זוגות. actual = [(str(f), int(d)) for f, d in (info.get("key") or [])] if actual != [(f, int(d)) for f, d in keys]: mismatched.append((name, actual)) if missing: logger.error("sticky notes query indexes missing after build: %s", missing) if mismatched: # לא מיישבים כאן: הפלה-ובנייה על אינדקס חי היא פעולה # שדורשת החלטה, ולא תופעת לוואי של בוטסטראפ. השם # הממוספר הוא הכלי ליישוב מכוון (ראו # ``_ensure_versioned_unique_index``). logger.error( "sticky notes query indexes exist with a different key spec: %s", mismatched, ) except Exception: logger.warning("sticky notes index verification failed", exc_info=True) # האינדקס הייחודי נוצר **בנפרד משני המסלולים**, ולא בתוכם. # # שני טעמים. האחד: כשהוא ישב ברשימת ``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) # אח מקביל לפתקי ריפו, ומאותם שני טעמים בדיוק. דגל נפרד, כי # הצלחת האחד אינה עדות להצלחת השני — ודגל משותף היה מדליק # אכיפה שלא אומתה. try: _REPO_TITLE_INDEX_OK = bool(ensure_repo_title_index(coll)) if not _REPO_TITLE_INDEX_OK: logger.error("one_title_per_repo_file index not confirmed after create") except Exception: _REPO_TITLE_INDEX_OK = False logger.error("one_title_per_repo_file index creation failed", exc_info=True) # אינדקסים ללוחות הפתקים (best-effort) try: nb = db.note_boards # ``one_default_per_user`` ייחודי-חלקי: הוא מה שסוגר את המרוץ # שבו שתי בקשות מקבילות מגלות שאין לוח ברירת מחדל ושתיהן # יוצרות. הגנת קוד לבדה לא מספיקה שם — המסד חייב לדחות. try: from pymongo import ASCENDING, IndexModel # type: ignore nb.create_indexes([ IndexModel([("user_id", ASCENDING), ("order", ASCENDING)], name="user_order_idx"), IndexModel( [("user_id", ASCENDING)], name="one_default_per_user", unique=True, partialFilterExpression={"is_default": True}, ), ]) except Exception: try: nb.create_index([("user_id", 1), ("order", 1)], name="user_order_idx") except Exception: pass try: nb.create_index( [("user_id", 1)], name="one_default_per_user", unique=True, partialFilterExpression={"is_default": True}, ) except Exception: pass except Exception: pass # Ensure note reminders collection indexes (best-effort) try: nr = db.note_reminders try: from pymongo import ASCENDING, DESCENDING, IndexModel # type: ignore nr.create_indexes([ IndexModel([("user_id", ASCENDING), ("note_id", ASCENDING)], name="user_note_idx"), IndexModel([("user_id", ASCENDING), ("status", ASCENDING), ("remind_at", ASCENDING)], name="user_status_time_idx"), IndexModel([("remind_at", ASCENDING)], name="remind_at_idx"), ]) except Exception: try: nr.create_index([("user_id", 1), ("note_id", 1)], name="user_note_idx") except Exception: pass try: nr.create_index([("user_id", 1), ("status", 1), ("remind_at", 1)], name="user_status_time_idx") except Exception: pass try: nr.create_index([("remind_at", 1)], name="remind_at_idx") except Exception: pass except Exception: # Never fail request because of index creation pass duration_ms = int(max(0.0, (time.perf_counter() - started) * 1000.0)) # **שני אינדקסי השם חייבים להתאמת לפני סימון "מוכן".** אחרת, # אם אינדקס הלוח הצליח ואינדקס הריפו נכשל, הדגל היה נדלק (כולל # ברדיס ל-24 שעות), הבנייה הייתה מדלגת בכל הבקשות הבאות, ואכיפת # השם על פתקי ריפו הייתה נעדרת ליממה — בכל התהליכים. if _TITLE_INDEX_OK and _REPO_TITLE_INDEX_OK: _INDEX_RETRY_AFTER = 0.0 _mark_indexes_ready(duration_ms=duration_ms) else: _INDEX_RETRY_AFTER = time.monotonic() + _INDEX_RETRY_SECONDS which = "one_title_per_board" if not _TITLE_INDEX_OK else "one_title_per_repo_file" _emit_index_event("failed", error=f"{which} 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 _repo_title_conflict( db: Any, user_id: Any, repo_name: Any, repo_path: Any, title: str, exclude_id: Any = None ) -> bool: """אותו גיבוי בדיוק, לפתקי ריפו — ורק כשהאינדקס שלהם לא אומת.""" if _REPO_TITLE_INDEX_OK or not title or not repo_name or not repo_path: return False return repo_title_is_taken( db.sticky_notes, user_id=user_id, repo_name=repo_name, repo_path=repo_path, title=title, exclude_id=exclude_id, ) def _duplicate_title_for_note( db: Any, user_id: Any, note: Dict[str, Any], title: str, exclude_id: Any = None ) -> bool: """בדיקת הגיבוי לכפילות שם, **לפי סוג היעד של הפתק**. ``_title_conflict`` יוצא מיד כשאין ``board_id`` — ולפתק ריפו אין — ולכן כשאינדקס הריפו לא אומת לא הייתה שום אכיפה: אף אחד לא בדק, המסד לא אכף, ושני פתקים על אותו קובץ קיבלו את אותו שם. **הפיצול חי כאן ולא בכל ראוט בנפרד.** כשהוא היה משוכפל, ``update_note`` קיבל אותו ו-``batch`` נשאר מאחור — ומכיוון שהלקוח שומר דרך ``_queueSave``, שמאגד ל-``/batch``, דווקא המסלול השקוף היה זה שלא אכף. סוג יעד רביעי בעתיד ישנה שורה אחת, לא שתיים. """ if not title: return False if note.get('repo_path'): return _repo_title_conflict( db, user_id, note.get('repo_name'), note.get('repo_path'), title, exclude_id=exclude_id, ) return _title_conflict(db, user_id, note.get('board_id'), title, exclude_id=exclude_id)
[תיעוד] def require_auth(f): @wraps(f) def _inner(*args, **kwargs): if 'user_id' not in session: return jsonify({'ok': False, 'error': 'Unauthorized'}), 401 return f(*args, **kwargs) return _inner
def _failed(route: str, error: str = 'Failed'): """500 עם עקבה בשרת: הלוג נושא את ה-traceback, הלקוח מקבל רק ``error``. ``@traced`` רושם רק חריגה שיוצאת מהפונקציה, וה-``except`` הגורף במסלולי התזכורות תופס אותה קודם — ולכן עד היום נתיב שנפל לא השאיר שום סימן בשרת, בזמן שהלקוח הופך את ה-500 ל-backoff שקט. ההודעה קבועה, והחריגה עוברת את מסנן ההשחרה של הלוגים כמו כל ``exc_info`` אחר במודול. חייב להיקרא מתוך ה-``except``, כי ``exc_info=True`` קורא את החריגה הפעילה. ``error`` הוא הטקסט שהלקוח כבר מכיר במסלול הזה (``Failed``, או ``Failed to save`` בשמירה); הוא לעולם אינו נושא פרטי חריגה. """ logger.error("sticky notes %s failed", route, exc_info=True) return jsonify({'ok': False, 'error': error}), 500 def _json_object(): """גוף ה-JSON של הבקשה כמילון — או ``None`` כשהגוף אינו אובייקט. ``request.get_json(silent=True) or {}`` תופס רק גוף ריק: גוף שהוא רשימה או מחרוזת הוא truthy, עובר את ה-``or``, ואז ``.get`` זורק ``AttributeError`` שה-``except`` הגורף הופך ל-500 — ומאז ``_failed`` גם לשורת ERROR עם traceback — על טעות של הלקוח. הבדיקה היא על הטיפוס, לא על האמיתות: אין גוף ← מילון ריק, כי למסלולים יש ברירות מחדל (דחייה בלי ``minutes`` היא שעה); גוף שאינו אובייקט ← ``None``, והמסלול עונה ``invalid_payload`` 400. """ payload = request.get_json(silent=True) if payload is None: return {} return payload if isinstance(payload, dict) else None # Simple in-memory rate limiter per user and endpoint key _RATE_LOG: Dict[tuple, list] = {} def _rate_limit_check(user_id: int, key: str, max_per_minute: int) -> tuple[bool, int]: now = time.time() window_start = now - 60.0 bucket_key = (int(user_id or 0), str(key or "")) try: entries = _RATE_LOG.get(bucket_key, []) # drop old timestamps i = 0 for i, ts in enumerate(entries): if ts > window_start: break if entries: if entries[0] <= window_start: # remove all up to i (inclusive if still old) cutoff = i if entries[i] > window_start else (i + 1) entries = entries[cutoff:] # allow? allowed = len(entries) < max(1, int(max_per_minute or 1)) if allowed: entries.append(now) _RATE_LOG[bucket_key] = entries return True, 0 else: # estimate retry-after (rough) retry_after = int(max(1.0, 60.0 - (now - (entries[0] if entries else window_start)))) return False, retry_after except Exception: return True, 0
[תיעוד] def notes_rate_limit(key: str, max_per_minute: int): def _decorator(f): @wraps(f) def _inner(*args, **kwargs): try: uid = int(session.get('user_id') or 0) except Exception: uid = 0 if uid: allowed, retry_after = _rate_limit_check(uid, key, max_per_minute) if not allowed: resp = jsonify({'ok': False, 'error': 'Rate limited'}) try: resp.headers['Retry-After'] = str(int(retry_after)) except Exception: pass return resp, 429 return f(*args, **kwargs) return _inner return _decorator
_CONTROL_CHARS_RE = re.compile(r"[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]") def _sanitize_text(text: Any, max_length: int = 20000) -> str: """Normalize user text without HTML escaping. שומר על טקסט כפי שהמשתמש הזין (כולל מרכאות וסימנים אחרים) תוך הסרת תווים לא מודפסים והגבלת אורך סבירה כדי למנוע פגיעה בבסיס הנתונים. """ if text is None: return "" try: s = str(text) except Exception: s = "" # החזרת מחרוזות שהשתמרו כ-html entities (כמו &quot;) s = html.unescape(s) # נרמול קפיצות שורה והסרת תווים שאינם מודפסים s = s.replace("\r\n", "\n").replace("\r", "\n") s = _CONTROL_CHARS_RE.sub("", s) if max_length and max_length > 0: s = s[:max_length] return s def _decode_content_b64(value: Any, *, max_decoded_chars: int = MAX_NOTE_CHARS, max_b64_len: int = 120000) -> str: """Decode Base64 UTF-8 content safely. מיועד ל-`content_b64` כדי למנוע חסימות/פילטרים על מילים "חשודות" בזמן העברה. הטקסט שנשמר ב-DB הוא תמיד טקסט רגיל אחרי sanitize. """ if value is None: return "" if not isinstance(value, str): raise ValueError("content_b64 must be a string") s = value.strip() if not s: return "" # Best-effort safety: avoid decoding extremely large blobs. # Note: Base64 is ~4/3 expansion. UTF-8 can be up to 4 bytes per char. # We keep this limit comfortably above the 5k-char sticky-note cap to avoid # rejecting valid UTF-8 (e.g., emoji-heavy notes). if max_b64_len and len(s) > int(max_b64_len): raise ValueError("content_b64 too large") # Remove whitespace and normalize urlsafe variants try: s = "".join(s.split()) except Exception: s = s.replace(" ", "") s = s.replace("-", "+").replace("_", "/") # Fix missing padding (common in transport layers) pad = (-len(s)) % 4 if pad: s = s + ("=" * pad) try: raw = base64.b64decode(s, validate=True) except Exception as exc: raise ValueError("invalid base64") from exc try: text = raw.decode("utf-8", errors="strict") except Exception as exc: raise ValueError("invalid utf-8") from exc return _sanitize_text(text, int(max_decoded_chars or MAX_NOTE_CHARS)) def _coerce_int(value: Any, default: int, min_v: Optional[int] = None, max_v: Optional[int] = None) -> int: try: x = int(value) except Exception: x = int(default) if min_v is not None and x < min_v: x = min_v if max_v is not None and x > max_v: x = max_v return x def _mode_update(raw: Any, note: Dict[str, Any]) -> Tuple[Any, Optional[str]]: """מאמת ``mode`` שהתקבל בעדכון, ומחזיר את הערך לשמירה. מחזירה ``(ערך_לשמירה, קוד_שגיאה)``; אחד מהם תמיד ``None``. החזרת קוד ולא ``raise`` — כדי שלא יהיה טקסט של חריגה שיכול לזלוג לתשובה. ``mode`` נכתב עד היום רק ביצירה, ולכן כפתור המצב בלוח לא יכול היה להישמר בכלל. הוא מותר ב**לוח ובפתק ריפו** — שניהם נושאים ``surface``/``screen`` ומצמידים את הפתק למסגרת התצוגה — ונשאר חסום בפתקי **קובץ**: שם ``_resolveMode`` קורא את השדה **לפני** הסנטינלים, כך ש-``mode`` היה משתלט על מסלול העיגון לשורות המקור. """ from sticky_notes_target import is_valid_board_mode, normalize_mode, DEFAULT_BOARD_MODE # פתק ריפו נושא ``mode`` בדיוק כמו פתק לוח — surface/screen — ולכן כפתור # המצב שלו חייב להישמר. בלי ``repo_path`` כאן הוא היה נדחה ב- # ``mode_not_supported``, והכפתור היה נשבר בשקט. פתקי **קובץ** נשארים # מחוץ, כי אצלם ``_resolveMode`` קורא את ``mode`` לפני הסנטינלים והוא # היה משתלט על מסלול עיגון-השורה. if not note.get('board_id') and not note.get('repo_path'): return None, 'mode_not_supported' if raw is not None and not is_valid_board_mode(raw): return None, 'invalid_mode' return normalize_mode(raw, DEFAULT_BOARD_MODE), None class _ContentTooLong(ValueError): """התוכן חרג מהתקרה.""" class _InvalidContentB64(ValueError): """``content_b64`` פגום ואין ``content`` לנפול אליו.""" def _note_content_from_request(data: Dict[str, Any]) -> str: """התוכן שיישמר, מ-``content_b64`` או מ-``content``. **מקום אחד שמחליט.** לפני כן ההמרה הייתה משוכפלת בחמישה ראוטים, וכל אחד מהם הקליד את התקרה בעצמו. השינוי המהותי: התקרה נאכפת ב**דחייה** ולא בחיתוך. חיתוך שקט הוא מחיקת מידע — הדבקה של 16,291 תווים נשמרה כ-5,000 והשרת החזיר 200 OK, כלומר "נשמר". ``mcp_server/handlers`` דוחה כבר היום; הוובאפ היה החריג. **זהות השגיאה חיה בסוג החריגה ולא בטקסט שלה.** ``str(exc)`` בתשובה ל-HTTP הוא דלף ממתין: ``raise`` עתידי שמשרשר נתיב או שאילתה מוצא אותם ללקוח, ו-CodeQL מסמן בדיוק את הזרימה הזו. מי שתופס כאן מחזיר ליטרל. :raises _ContentTooLong: התוכן ארוך מ-:data:`MAX_NOTE_CHARS`. :raises _InvalidContentB64: ``content_b64`` פגום ואין ``content``. """ if 'content_b64' in data: try: # חסם של תו אחד מעל התקרה: מספיק כדי לזהות חריגה במדויק, # ובלי להחזיק בזיכרון תוכן גדול לחינם. text = _decode_content_b64(data.get('content_b64'), max_decoded_chars=MAX_NOTE_CHARS + 1) except ValueError: # תאימות לאחור: ``content`` רגיל, אם נשלח לצד ה-b64 if 'content' not in data: raise _InvalidContentB64() text = _sanitize_text(data.get('content'), MAX_NOTE_CHARS + 1) else: text = _sanitize_text(data.get('content', ''), MAX_NOTE_CHARS + 1) if len(text) > MAX_NOTE_CHARS: raise _ContentTooLong() return text def _make_scope_id(user_id: int, file_name: Optional[str]) -> Optional[str]: # פונקציה קנונית (ללא תלות ב-Flask) כדי למנוע סטיות בין שירותים. from sticky_notes_scope import make_scope_id return make_scope_id(int(user_id), file_name) def _resolve_scope(db, user_id: int, file_id: Any) -> Tuple[Optional[str], Optional[str], List[str]]: normalized_id = str(file_id or '').strip() related_ids: List[str] = [] if normalized_id: related_ids.append(normalized_id) file_name: Optional[str] = None scope_id: Optional[str] = None if db is None: return scope_id, file_name, related_ids oid = None try: oid = ObjectId(str(file_id)) except Exception: oid = None doc = None if oid is not None: try: doc = db.code_snippets.find_one({'_id': oid, 'user_id': user_id}, {'file_name': 1}) except Exception: doc = None if doc and isinstance(doc, dict): file_name = doc.get('file_name') if file_name: scope_id = _make_scope_id(user_id, file_name) try: cursor = db.code_snippets.find({'user_id': user_id, 'file_name': file_name}, {'_id': 1}) except Exception: cursor = None if cursor is not None: for entry in cursor: try: rid = str((entry or {}).get('_id') or '') except Exception: rid = '' if rid: related_ids.append(rid) seen = set() deduped: List[str] = [] for rid in related_ids: if not rid or rid in seen: continue seen.add(rid) deduped.append(rid) return scope_id, file_name, deduped def _coerce_content_from_doc(value: Any) -> str: if value is None: return "" try: s = str(value) except Exception: s = "" return html.unescape(s) def _as_note_response(doc: Dict[str, Any]) -> Dict[str, Any]: return { '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), }, 'size': { 'width': int(doc.get('width', 240) or 240), 'height': int(doc.get('height', 180) or 180), }, # ``color`` נשאר **תמיד ``hex`` חוקי**, כי הצרכן היחיד שלו הוא # ``style.backgroundColor`` — לקוח שנטען מקאש ישן ימשיך לצבוע # נכון גם אחרי שהמסד עבר למזהים. ``color_id`` הוא מה שבאמת # מאוחסן, והוא מה שהבורר מסמן ומה שסינון לפי צבע ישווה אליו; # ``''`` שם פירושו צבע ``legacy`` שאינו בפלטה. 'color': note_color_hex(doc.get('color')), 'color_id': note_color_id(doc.get('color')), 'is_minimized': bool(doc.get('is_minimized', False)), 'line_start': doc.get('line_start'), 'line_end': doc.get('line_end'), 'anchor_id': doc.get('anchor_id') or '', 'anchor_text': doc.get('anchor_text') or '', # פתק לוח. הלקוח מזהה לפי זה לאיזה משטח לצרף אותו, ו-``mode`` קובע # אם הוא נע עם הלוח או נשאר על המסך. בפתק קובץ שניהם ריקים, # והמצב ממשיך להיגזר מ-``anchor_id`` כמו קודם. 'board_id': str(doc.get('board_id', '') or ''), 'mode': str(doc.get('mode', '') or ''), 'updated_at': (doc.get('updated_at').isoformat() if doc.get('updated_at') else None), 'created_at': (doc.get('created_at').isoformat() if doc.get('created_at') else None), } # --- Routes --- @sticky_notes_bp.route('/<file_id>', methods=['GET']) @require_auth @notes_rate_limit('list', 180) @traced("sticky_notes.list") def list_notes(file_id: str): """List all sticky notes for current user and file.""" try: _ensure_indexes() user_id = int(session['user_id']) db = get_db() scope_id, file_name, related_ids = _resolve_scope(db, user_id, file_id) query: Dict[str, Any] = {'user_id': user_id} criteria: List[Dict[str, Any]] = [] if scope_id: criteria.append({'scope_id': scope_id}) if related_ids: criteria.append({'file_id': {'$in': related_ids}}) if criteria: query['$or'] = criteria else: query['file_id'] = str(file_id) cursor = db.sticky_notes.find(query).sort('created_at', 1) raw_docs = list(cursor) if cursor is not None else [] notes = [ _as_note_response(doc) for doc in raw_docs if isinstance(doc, dict) ] if scope_id: missing_ids = [doc.get('_id') for doc in raw_docs if isinstance(doc, dict) and not doc.get('scope_id')] if missing_ids: try: update_payload: Dict[str, Any] = {'scope_id': scope_id} if file_name: update_payload['file_name'] = file_name db.sticky_notes.update_many({'_id': {'$in': missing_ids}}, {'$set': update_payload}) except Exception: pass resp = jsonify({'ok': True, 'notes': notes, 'count': len(notes)}) # מניעת קאשינג בדפדפן/פרוקסי כדי שלא תוחזר גרסה ישנה של פתקים try: resp.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate' resp.headers['Pragma'] = 'no-cache' resp.headers['Expires'] = '0' except Exception: pass return resp except Exception as e: try: emit_event("sticky_notes_list_error", severity="anomaly", file_id=str(file_id), error=str(e)) except Exception: pass return jsonify({'ok': False, 'error': 'Failed to list notes'}), 500 # --- Sticky note reminders API --- def _parse_when_to_utc(payload: Dict[str, Any], user_tz: str) -> Optional[datetime]: """Parse reminder time from payload into aware UTC datetime. Supports: - preset values: "1h", "3h", "24h", "1w", "today-21", "tomorrow-09" - explicit: payload["at"] as ISO-like string ("YYYY-MM-DDTHH:MM") with optional seconds - free text: payload["time_text"] using reminders.utils.parse_time """ try: from zoneinfo import ZoneInfo # type: ignore except Exception: # pragma: no cover ZoneInfo = None # type: ignore now_local = None try: tz = ZoneInfo(user_tz) if (user_tz and ZoneInfo) else None except Exception: tz = None try: now_local = datetime.now(tz or timezone.utc) except Exception: now_local = datetime.now(timezone.utc) preset = str((payload or {}).get('preset') or '').strip().lower() if preset: if preset in {'1h', '1hr'}: return (now_local + timedelta(hours=1)).astimezone(timezone.utc) if preset in {'3h', '3hr'}: return (now_local + timedelta(hours=3)).astimezone(timezone.utc) if preset in {'24h', '1d'}: return (now_local + timedelta(hours=24)).astimezone(timezone.utc) if preset in {'1w', '7d'}: return (now_local + timedelta(days=7)).astimezone(timezone.utc) if preset == 'today-21': base = now_local.replace(hour=21, minute=0, second=0, microsecond=0) if base <= now_local: # if passed, schedule for tomorrow 21:00 base = base + timedelta(days=1) return base.astimezone(timezone.utc) if preset == 'tomorrow-09': base = (now_local + timedelta(days=1)).replace(hour=9, minute=0, second=0, microsecond=0) return base.astimezone(timezone.utc) at = (payload or {}).get('at') if at: try: # Expecting local naive string like "YYYY-MM-DDTHH:MM" (datetime-local) # If seconds provided, they'll be ignored by slicing s = str(at).strip() # Normalize seconds if present if len(s) >= 16: from datetime import datetime as _dt local_naive = _dt.strptime(s[:16], '%Y-%m-%dT%H:%M') if tz: aware = local_naive.replace(tzinfo=tz) else: aware = local_naive.replace(tzinfo=timezone.utc) return aware.astimezone(timezone.utc) except Exception: pass # Free text time_text = (payload or {}).get('time_text') if time_text: try: try: from reminders.utils import parse_time as _parse except Exception: _parse = None # type: ignore if _parse: dt = _parse(str(time_text), user_tz or 'UTC') if dt: return dt.astimezone(timezone.utc) except Exception: pass return None def _ensure_user_owns_note(db, user_id: int, note_id: str) -> Optional[Dict[str, Any]]: raw_id = str(note_id or "").strip() if not raw_id: return None candidates: List[Any] = [] try: from bson import ObjectId # type: ignore except Exception: ObjectId = None # type: ignore if ObjectId and raw_id: try: candidates.append(ObjectId(raw_id)) except Exception: pass candidates.append(raw_id) for candidate in candidates: try: note = db.sticky_notes.find_one({'_id': candidate, 'user_id': int(user_id)}) except Exception: note = None if isinstance(note, dict): return note return None @sticky_notes_bp.route('/note/<note_id>/reminder', methods=['GET']) @require_auth @notes_rate_limit('note_reminder_get', 180) @traced('sticky_notes.reminder_get') def get_note_reminder(note_id: str): try: _ensure_indexes() user_id = int(session['user_id']) db = get_db() note = _ensure_user_owns_note(db, user_id, note_id) if not note: return jsonify({'ok': False, 'error': 'Note not found'}), 404 # הפילטר המלא, ולא ``status`` לבדו: מסמך שנכתב לפני שהמצב הסופי # היה קיים נושא ``ack_at`` מלא ו-``status`` ישן, ובלי בדיקת # ``ack_at`` הראוט היה מחזיר תזכורת שהמשתמש כבר סגר כאילו היא חיה. r = db.note_reminders.find_one(dict( active_reminder_filter(), user_id=user_id, note_id=str(note_id), )) if not r: return jsonify({'ok': True, 'reminder': None}) out = { 'id': str(r.get('_id')), 'status': r.get('status', 'pending'), 'remind_at': (r.get('remind_at').isoformat() if isinstance(r.get('remind_at'), datetime) else None), 'snooze_until': (r.get('snooze_until').isoformat() if isinstance(r.get('snooze_until'), datetime) else None), } return jsonify({'ok': True, 'reminder': out}) except Exception: return _failed('get_note_reminder') @sticky_notes_bp.route('/note/<note_id>/reminder', methods=['POST']) @require_auth @notes_rate_limit('note_reminder_set', 60) @traced('sticky_notes.reminder_set') def set_note_reminder(note_id: str): try: _ensure_indexes() user_id = int(session['user_id']) db = get_db() note = _ensure_user_owns_note(db, user_id, note_id) if not note: return jsonify({'ok': False, 'error': 'Note not found'}), 404 payload = _json_object() if payload is None: return jsonify({'ok': False, 'error': 'invalid_payload'}), 400 client_tz = str(payload.get('tz') or 'Asia/Jerusalem') dt_utc = _parse_when_to_utc(payload, client_tz) if not dt_utc: return jsonify({'ok': False, 'error': 'Invalid time'}), 400 if dt_utc <= datetime.now(timezone.utc): return jsonify({'ok': False, 'error': 'Time must be in the future'}), 400 now_utc = datetime.now(timezone.utc) # Fields to set on every update set_fields = { 'user_id': user_id, 'note_id': str(note_id), 'file_id': str(note.get('file_id', '')), # לפתק לוח אין file_id, ובלי השדה הזה ה-Service Worker # לא היה יודע לאן לפתוח את ההתראה. 'board_id': str(note.get('board_id', '') or ''), # שדות מחזור החיים מגיעים מהמודול, כדי שקבוע שמשתנה ישנה גם את הכתיבה. **armed_fields(dt_utc, now_utc), } # Upsert: keep only one active reminder per note for simplicity try: db.note_reminders.update_one( {'user_id': user_id, 'note_id': str(note_id)}, { '$set': set_fields, '$setOnInsert': {'created_at': now_utc}, }, upsert=True, ) except Exception: return _failed('set_note_reminder.save', error='Failed to save') try: emit_event('note_reminder_set', severity='info', user_id=user_id, note_id=str(note_id)) except Exception: pass return jsonify({'ok': True, 'remind_at': dt_utc.isoformat()}) except Exception: return _failed('set_note_reminder') @sticky_notes_bp.route('/note/<note_id>/reminder', methods=['DELETE']) @require_auth @notes_rate_limit('note_reminder_delete', 60) @traced('sticky_notes.reminder_delete') def delete_note_reminder(note_id: str): try: user_id = int(session['user_id']) db = get_db() note = _ensure_user_owns_note(db, user_id, note_id) if not note: return jsonify({'ok': False, 'error': 'Note not found'}), 404 db.note_reminders.delete_one({'user_id': user_id, 'note_id': str(note_id)}) return jsonify({'ok': True}) except Exception: return _failed('delete_note_reminder') @sticky_notes_bp.route('/note/<note_id>/snooze', methods=['POST']) @require_auth @notes_rate_limit('note_reminder_snooze', 120) @traced('sticky_notes.reminder_snooze') def snooze_note_reminder(note_id: str): try: user_id = int(session['user_id']) db = get_db() payload = _json_object() if payload is None: return jsonify({'ok': False, 'error': 'invalid_payload'}), 400 # קלט חיצוני, ולכן בדיקת טיפוס לפני ההמרה ולא ``except`` אחריה: ``int()`` # על מחרוזת או רשימה זורק — וזה היה 500 עם traceback על טעות של הלקוח — # ועל ``True`` או ``3.9`` הוא ממיר בשקט (1, 3) ומקבע דחייה שאיש לא ביקש. # מספר שלם בלבד; ``bool`` הוא תת-טיפוס של ``int`` ולכן מוחרג במפורש. # חסר או ``null`` ← ברירת המחדל המתועדת, שעה. minutes = payload.get('minutes') if minutes is None: minutes = 60 if isinstance(minutes, bool) or not isinstance(minutes, int) or minutes < 1 or minutes > 24 * 60: return jsonify({'ok': False, 'error': 'Invalid minutes'}), 400 new_time = datetime.now(timezone.utc) + timedelta(minutes=minutes) # גם כאן הפילטר המלא: דחייה מחיה תזכורת **פעילה**, לא כזו שהמשתמש כבר # סגר. בלי התנאי, לחיצה על דחייה בהתראה ישנה הייתה מחזירה לחיים תזכורת # שנסגרה מזמן, בשקט. ומכיוון שהפילטר כבר דורש ``ack_at`` ריק, הכתיבה # אינה נוגעת בו — שדות הדחייה מגיעים מהמודול. r = db.note_reminders.update_one( dict( active_reminder_filter(), user_id=user_id, note_id=str(note_id), ), {'$set': snoozed_fields(new_time, datetime.now(timezone.utc))}, ) if getattr(r, 'matched_count', 0) <= 0: return jsonify({'ok': False, 'error': 'Reminder not found'}), 404 return jsonify({'ok': True, 'remind_at': new_time.isoformat()}) except Exception: return _failed('snooze_note_reminder') #: כשיש בועה על המסך השרת מבקש מהלקוח לחזור לכל היותר בעוד חמש דקות — המרווח #: הקבוע שהיה כאן לפני הדגימה לפי השרת, וזה שריענן גם ניקוי ממכשיר אחר וגם #: מונה שהשתנה. הערך יושב בשרת ולא בלקוח כדי שללקוח יישאר כלל אחד: "ישן כמה #: שהשרת אמר, בין הרצפה לתקרה". תזכורת נוספת שמבשילה מוקדם יותר גוברת עליו. BADGE_REFRESH_SECONDS = 5 * 60 @sticky_notes_bp.route('/reminders/summary', methods=['GET']) @require_auth @notes_rate_limit('note_reminders_summary', 300) @traced('sticky_notes.reminders_summary') def reminders_summary(): """Return minimal summary for persistent UI badge. Response: { ok, has_due: bool, count_due: int, next_in_seconds: int | null } **``next_in_seconds`` הוא מה שמחליף את הדגימה הקבועה.** עד כאן הלקוח דגם כל חמש דקות בלי קשר למצב, כי "כן/לא" היה כל מה שקיבל — 954 קריאות ביממה שכולן החזירו "אין". השרת הוא היחיד שיודע גם *מתי* התזכורת הבאה, ולכן הוא זה שאומר ללקוח מתי לחזור — גם כשיש בועה: אז התשובה היא לכל היותר ``BADGE_REFRESH_SECONDS``, כדי שבועה שנוקתה ממכשיר אחר ומונה שהשתנה ייראו תוך דקות ולא אחרי חצי שעה. אין תזכורת עתידית ואין בועה ← ``None``, והלקוח נרדם עד התקרה שלו (חצי שעה) — לא לנצח, כדי שתזכורת שתיקבע ממכשיר אחר עדיין תתגלה. **והערך יחסי, לא חותמת.** ``remind_at`` שנקרא מהמסד הוא מודע-אזור רק כל עוד הלקוח נבנה עם ``tz_aware`` — ומחרוזת ISO בלי offset נקראת ב-JavaScript כזמן **מקומי**, כלומר שלוש שעות סטייה בלי שגיאה. מספר שניות אין לו אזור זמן, והוא גם חסין לשעון לקוח שסוטה. """ try: _ensure_indexes() user_id = int(session['user_id']) db = get_db() now = datetime.now(timezone.utc) base_filter = active_reminder_filter() base_filter['user_id'] = user_id due_filter = dict(base_filter, remind_at={'$lte': now}) # **שאילתה שנכשלה אינה "אין תזכורות".** כאן ישב ``except`` שהחזיר # ``count_due = 0``, והתשובה יצאה ``ok: true, has_due: false`` — # כלומר המשתמש קיבל "הכול נקי" על מסד שלא ענה. עכשיו החריגה עולה # ל-``except`` החיצוני ומוחזרת כ-500, והלקוח מבדיל בין "אין" לבין # "לא ידוע". זה גם מה שהופך את ``next_in_seconds`` לאמין: לקוח # שנרדם לחצי שעה על סמך כשל הוא גרוע מלקוח שדוגם יותר מדי. count_due = int(db.note_reminders.count_documents(due_filter)) has_due = count_due > 0 # מתי כדאי לשאול שוב — בשני המצבים. התזכורת העתידית הקרובה קובעת את # המועד; כשיש בועה, התשובה נחתכת ב-BADGE_REFRESH_SECONDS, כי בועה שנוקתה # ממכשיר אחר אינה שולחת שום אות, וחצי שעה של בועה ישנה היא בדיוק מה # שהדגימה לפי השרת לא נועדה לייצר. זו השאילתה היחידה שנשארה לצד הספירה: # היא שואלת על העתיד והספירה על ההווה, ולכן שתי התשובות אינן יכולות # לסתור זו את זו — ``has_due`` נגזר מהספירה בלבד. upcoming = db.note_reminders.find_one( dict(base_filter, remind_at={'$gt': now}), {'remind_at': 1}, sort=[('remind_at', 1)], ) next_in_seconds = _seconds_until(upcoming.get('remind_at') if upcoming else None, now) if has_due: next_in_seconds = ( BADGE_REFRESH_SECONDS if next_in_seconds is None else min(BADGE_REFRESH_SECONDS, next_in_seconds) ) return jsonify({ 'ok': True, 'has_due': has_due, 'count_due': count_due, 'next_in_seconds': next_in_seconds, }) except Exception: return _failed('reminders_summary') @sticky_notes_bp.route('/reminders/list', methods=['GET']) @require_auth @notes_rate_limit('note_reminders_list', 300) @traced('sticky_notes.reminders_list') def reminders_list(): """Return a list of due sticky‑note reminders for the current user. Response: .. code-block:: json { "ok": true, "items": [ { "note_id": "...", "file_id": "...", "preview": "...", "anchor_id": "h2-intro", "anchor_text": "Intro", "remind_at": "2026-09-20T09:00:00+00:00" } ], "count": 1 } ``remind_at`` הוא המועד שהפריט הזה נורה עליו; החלונית מחזירה אותו ב-``ack``, כדי שהאישור ייקשר למועד הזה ולא ל"איזו שהיא" תזכורת של הפתק. """ try: _ensure_indexes() user_id = int(session['user_id']) db = get_db() now = datetime.now(timezone.utc) # Pagination bounds try: limit_param = int(request.args.get('limit', 20)) except Exception: limit_param = 20 limit_param = max(1, min(50, limit_param)) # בלי ``try`` סביב השאילתה — בכוונה. ``get_db()`` מחזיר ``None`` # בחלון הצינון שאחרי כשל התחברות, וה-``AttributeError`` שנובע מזה # חייב להגיע ל-handler החיצוני ולענות 500. הגרסה הקודמת בלעה אותו # ל-``cursor = []`` וענתה ``ok:true, count:0`` — "אין תזכורות" על # מסד שלא נקרא. ``reminders_summary`` עונה 500 על אותו מצב, ומסלולי # הבועה חייבים חוזה אחד: אחרת הבועה אומרת "3" והחלונית "0". cursor = ( db.note_reminders .find(dict( active_reminder_filter(), user_id=user_id, remind_at={'$lte': now}, )) .sort('remind_at', 1) .limit(limit_param) ) reminders = list(cursor) items = [] def _first_n_words(text: str, n: int = 6) -> str: try: s = _sanitize_text(text or '', MAX_NOTE_CHARS) words = [w for w in s.strip().split() if w] if not words: return '' head = words[:max(1, n)] out = ' '.join(head) if len(words) > n: out += '…' return out except Exception: return '' for r in reminders: try: note_id = str(r.get('note_id') or '') file_id = str(r.get('file_id') or '') board_id = str(r.get('board_id') or '') preview = '' anchor_id = '' anchor_text = '' note_doc = None # Try ObjectId first for performance/accuracy try: oid = ObjectId(note_id) except Exception: oid = None if oid is not None: try: note_doc = db.sticky_notes.find_one({'_id': oid, 'user_id': user_id}) except Exception: note_doc = None if note_doc is None and note_id: try: note_doc = db.sticky_notes.find_one({'_id': note_id, 'user_id': user_id}) except Exception: note_doc = None if isinstance(note_doc, dict): preview_source = _coerce_content_from_doc(note_doc.get('content', '')) or (note_doc.get('anchor_text') or '') preview = _first_n_words(preview_source, 6) anchor_id = str(note_doc.get('anchor_id') or '') anchor_text = str(note_doc.get('anchor_text') or '') # Prefer file_id/board_id from note if missing on reminder # (defensive — תזכורות ישנות נכתבו לפני שהשדה נוסף) if not file_id: try: file_id = str(note_doc.get('file_id') or '') except Exception: pass if not board_id: try: board_id = str(note_doc.get('board_id') or '') except Exception: pass else: preview = '' remind_at = r.get('remind_at') items.append({ 'note_id': note_id, 'file_id': file_id, 'board_id': board_id, 'preview': preview, 'anchor_id': anchor_id, 'anchor_text': anchor_text, 'remind_at': remind_at.isoformat() if isinstance(remind_at, datetime) else '', }) except Exception: # Skip malformed entries rather than failing the entire list continue return jsonify({'ok': True, 'items': items, 'count': len(items)}) except Exception: return _failed('reminders_list') @sticky_notes_bp.route('/reminders/ack', methods=['POST']) @require_auth @notes_rate_limit('note_reminders_ack', 300) @traced('sticky_notes.reminders_ack') def reminders_ack(): """Mark current due reminder as acknowledged (user opened it). **סוגר את שני שדות המצב יחד.** עד כאן נכתב ``ack_at`` בלבד, ו-``status`` נשאר ``pending`` לנצח — כך שכרטיס הדשבורד, שסופר לפי ``status``, דיווח תזכורות "בהמתנה" שאיש לא המתין להן. השדות מגיעים מ- :func:`note_reminder_state.acknowledge_fields`, שמחזירה את שניהם או אף אחד, ומונגו מחילה אותם ב-``$set`` יחיד. **ונקשר למועד שההתראה נשאה.** בלי ``remind_at`` בגוף, האישור סגר "איזו שהיא" תזכורת של הפתק; וכשהפתק נדרך מחדש אחרי שההתראה נורתה — בדיוק מה שעושים כשתזכורת מגיעה ברגע לא נוח — התראה ישנה במגש סגרה את התזכורת של מחר, כי ``set_note_reminder`` עושה upsert על אותו מסמך ויש תמיד שורה אחת לפגוע בה. עם המועד, הפילטר תופס רק את המסמך שעדיין נושא אותו: מסמך שנדרך מחדש או נדחה מאז עונה 404, ונשאר פעיל. לקוח שאינו שולח מועד (SW ישן במטמון, התראה שהוצגה לפני השדה) מאשר בלי קשירה — ההתנהגות של קודם. """ try: user_id = int(session['user_id']) db = get_db() payload = _json_object() if payload is None: return jsonify({'ok': False, 'error': 'invalid_payload'}), 400 note_id = str(payload.get('note_id') or '').strip() if not note_id: return jsonify({'ok': False, 'error': 'note_id required'}), 400 # קלט חיצוני: מחרוזת ISO או כלום. מחרוזת שאינה נקראת היא 400 ולא # "בלי קשירה" — אחרת באג בלקוח היה סוגר תזכורת שרירותית של הפתק. try: occurrence = parse_remind_at(payload.get('remind_at')) except ValueError: return jsonify({'ok': False, 'error': 'invalid_remind_at'}), 400 ack_filter = {'user_id': user_id, 'note_id': note_id, 'ack_at': None} if occurrence is not None: ack_filter['remind_at'] = occurrence r = db.note_reminders.update_one( ack_filter, {'$set': acknowledge_fields(datetime.now(timezone.utc))} ) if getattr(r, 'matched_count', 0) <= 0: return jsonify({'ok': False, 'error': 'Not found'}), 404 return jsonify({'ok': True}) except Exception: return _failed('reminders_ack') @sticky_notes_bp.route('/<file_id>', methods=['POST']) @require_auth @notes_rate_limit('create', 60) @traced("sticky_notes.create") def create_note(file_id: str): """Create a new sticky note for a file.""" try: from sticky_notes_target import NoteQuotaExceeded, NoteQuotaUnknown, check_note_quota _ensure_indexes() user_id = int(session['user_id']) db = get_db() scope_id, scope_file_name, _ = _resolve_scope(db, user_id, file_id) data = request.get_json(silent=True) or {} try: content = _note_content_from_request(data) except _ContentTooLong: 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 = resolve_note_color(data.get('color')) is_minimized = bool(data.get('is_minimized', False)) line_start = data.get('line_start') line_end = data.get('line_end') anchor_id = (data.get('anchor_id') or '').strip()[:256] anchor_text = (data.get('anchor_text') or '').strip()[:256] # תקרת הפתקים למשתמש חלה על כל הפתקים, לא רק על אלה שבלוחות. # היא מתועדת מזה זמן ולא נאכפה בשום מקום; אכיפה רק במסלול הלוח # הייתה הופכת את התיעוד לנכון-למחצה. try: check_note_quota( _count_or_none(db.sticky_notes, {'user_id': user_id}), MAX_NOTES_PER_USER, is_admin=_current_user_is_admin(), ) except NoteQuotaUnknown: return jsonify({'ok': False, 'error': 'note_quota_unknown'}), 409 except NoteQuotaExceeded: return jsonify({'ok': False, 'error': 'note_quota_exceeded'}), 409 doc = { '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), 'height': _coerce_int(size.get('height'), 200, 80, 1200), 'color': color, 'is_minimized': bool(is_minimized), 'line_start': int(line_start) if isinstance(line_start, int) else None, 'line_end': int(line_end) if isinstance(line_end, int) else None, 'anchor_id': anchor_id or None, 'anchor_text': anchor_text or None, 'created_at': datetime.now(timezone.utc), 'updated_at': datetime.now(timezone.utc), } if scope_id: doc['scope_id'] = scope_id if scope_file_name: doc['file_name'] = scope_file_name 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)) except Exception: pass resp = jsonify({'ok': True, 'id': nid}) try: resp.headers['Cache-Control'] = 'no-store' except Exception: pass return resp, 201 except Exception as e: try: emit_event("sticky_notes_create_error", severity="anomaly", file_id=str(file_id), error=str(e)) except Exception: pass return jsonify({'ok': False, 'error': 'Failed to create note'}), 500 @sticky_notes_bp.route('/note/<note_id>', methods=['PUT']) @require_auth @notes_rate_limit('update', 300) @traced("sticky_notes.update") def update_note(note_id: str): """Update existing note; only owner can update.""" try: user_id = int(session['user_id']) data = request.get_json(silent=True) or {} updates: Dict[str, Any] = {} # Prefer content_b64 if provided (avoid "on-the-wire" clear text) if 'content_b64' in data or 'content' in data: try: updates['content'] = _note_content_from_request(data) except _ContentTooLong: return jsonify({'ok': False, 'error': 'content_too_long', 'max': MAX_NOTE_CHARS}), 400 except _InvalidContentB64: return jsonify({'ok': False, 'error': 'invalid_content_b64'}), 400 if 'position' in data and isinstance(data.get('position'), dict): pos = data['position'] updates['position_x'] = _coerce_int(pos.get('x'), 100, 0, 100000) updates['position_y'] = _coerce_int(pos.get('y'), 100, 0, 1000000) if 'size' in data and isinstance(data.get('size'), dict): size = data['size'] updates['width'] = _coerce_int(size.get('width'), 250, 120, 1200) updates['height'] = _coerce_int(size.get('height'), 200, 80, 1200) if 'color' in data: # ``default=None`` ← ערך פסול **נשמט** ואינו דורס את הצבע # הקיים בברירת מחדל. # # **ובכוונה שונה מ-``mcp_server/handlers``, שדוחה בשגיאה.** # הבורר כאן שולח רק מזהים שעברו בדיקה מול הפלטה בצד הלקוח, # ולכן ערך פסול אינו קלט משתמש אלא באג אצלנו; שם הכותב הוא # סוכן שכותב מה שהוא רוצה, ו-``ok`` על צבע שלא הוחל מגיע # למשתמש כדיווח שגוי. ההסבר המלא ב-``resolve_note_color``. color = resolve_note_color(data.get('color'), default=None) if color: updates['color'] = color if 'is_minimized' in data: updates['is_minimized'] = bool(data.get('is_minimized')) if 'line_start' in data: try: updates['line_start'] = int(data.get('line_start')) except Exception: updates['line_start'] = None if 'line_end' in data: try: updates['line_end'] = int(data.get('line_end')) except Exception: updates['line_end'] = None if 'anchor_id' in data: aid = (data.get('anchor_id') or '').strip()[:256] updates['anchor_id'] = aid or None 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 and not unset_fields: return jsonify({'ok': False, 'error': 'No fields to update'}), 400 updates['updated_at'] = datetime.now(timezone.utc) db = get_db() # Validate ObjectId early and return 400 on malformed input try: oid = ObjectId(note_id) except InvalidId: return jsonify({'ok': False, 'error': 'Invalid note_id'}), 400 note = db.sticky_notes.find_one({'_id': oid, 'user_id': user_id}) if not note: return jsonify({'ok': False, 'error': 'Note not found'}), 404 if wants_mode: mode_value, mode_error = _mode_update(data.get('mode'), note) if mode_error: return jsonify({'ok': False, 'error': mode_error}), 400 updates['mode'] = mode_value # פתק לוח וּפתק ריפו אינם נושאים scope_id ולעולם לא יישאו — ובלי # השומר הזה כל עדכון שלהם היה מריץ _resolve_scope, כלומר קריאה # ל-code_snippets עם file_id ריק רק כדי לגלות שאין קובץ. if not note.get('scope_id') and not note.get('board_id') and not note.get('repo_path'): scope_id, scope_file_name, _ = _resolve_scope(db, user_id, note.get('file_id')) if scope_id: updates['scope_id'] = scope_id if scope_file_name and 'file_name' not in updates: updates['file_name'] = scope_file_name # מניעת דריסה בין מכשירים: אם התקבלה prev_updated_at ונמוכה מהעדכנית – החזר 409 try: prev_updated_at = data.get('prev_updated_at') if prev_updated_at: try: prev_dt = datetime.fromisoformat(str(prev_updated_at)) except Exception: prev_dt = None if prev_dt and isinstance(note.get('updated_at'), datetime) and prev_dt < note['updated_at']: return jsonify({'ok': False, 'error': 'Conflict', 'updated_at': note['updated_at'].isoformat()}), 409 except Exception: pass ops: Dict[str, Any] = {'$set': updates} if unset_fields: ops['$unset'] = unset_fields # **בדיקת הגיבוי נבחרת לפי סוג היעד.** ``_title_conflict`` יוצא # מיד כשאין ``board_id`` — ולפתק ריפו אין — ולכן כשאינדקס הריפו לא # אומת (``_REPO_TITLE_INDEX_OK`` כבוי) לא הייתה כאן שום אכיפה: אף # אחד לא בדק, המסד לא אכף, ושני פתקים על אותו קובץ קיבלו את אותו # שם. מסלול היצירה כבר מפצל כך; העדכון היה החריג. if 'title' in updates and _duplicate_title_for_note( db, user_id, note, 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: pass # שליחת חותמת הזמן שנוצרה עבור העדכון הנוכחי (ללא שאילתא נוספת) try: updated_at_iso = updates.get('updated_at').isoformat() if updates.get('updated_at') else None except Exception: updated_at_iso = None resp = jsonify({'ok': True, 'updated_at': updated_at_iso}) try: resp.headers['Cache-Control'] = 'no-store' except Exception: pass return resp except Exception as e: try: emit_event("sticky_notes_update_error", severity="anomaly", note_id=str(note_id), error=str(e)) except Exception: pass return jsonify({'ok': False, 'error': 'Failed to update note'}), 500 @sticky_notes_bp.route('/note/<note_id>', methods=['DELETE']) @require_auth @notes_rate_limit('delete', 120) @traced("sticky_notes.delete") def delete_note(note_id: str): """Delete a note; only owner can delete.""" try: user_id = int(session['user_id']) db = get_db() try: oid = ObjectId(note_id) except InvalidId: return jsonify({'ok': False, 'error': 'Invalid note_id'}), 400 res = db.sticky_notes.delete_one({'_id': oid, 'user_id': user_id}) if int(getattr(res, 'deleted_count', 0) or 0) <= 0: return jsonify({'ok': False, 'error': 'Note not found'}), 404 try: emit_event("sticky_note_deleted", severity="info", user_id=int(user_id), note_id=str(note_id)) except Exception: pass resp = jsonify({'ok': True}) try: resp.headers['Cache-Control'] = 'no-store' except Exception: pass return resp except Exception as e: try: emit_event("sticky_notes_delete_error", severity="anomaly", note_id=str(note_id), error=str(e)) except Exception: pass return jsonify({'ok': False, 'error': 'Failed to delete note'}), 500 @sticky_notes_bp.route('/batch', methods=['POST']) @require_auth @notes_rate_limit('batch', 300) @traced("sticky_notes.batch") def batch_update_notes(): """Batch update multiple notes in one request. Body format (JSON): .. code-block:: json { "updates": [ { "id": "...", "content": "...", "position": {"x": 120, "y": 240}, "size": {"width": 260, "height": 200}, "color": "yellow_light", "is_minimized": false, "line_start": 10, "line_end": null, "anchor_id": "h2-intro", "anchor_text": "Intro", "mode": "surface", "prev_updated_at": "2024-01-01T00:00:00+00:00" } ] } Response JSON contains ``results`` with per-item status, e.g. 200/409. """ try: user_id = int(session['user_id']) db = get_db() payload = request.get_json(silent=True) or {} updates_input = payload.get('updates') if isinstance(updates_input, list): items = updates_input elif isinstance(payload, list): items = payload else: items = [] if not items: return jsonify({'ok': False, 'error': 'No updates provided'}), 400 results: List[Dict[str, Any]] = [] for item in items: try: note_id = str((item or {}).get('id') or '').strip() if not note_id: results.append({'id': None, 'ok': False, 'status': 400, 'error': 'Missing id'}) continue try: oid = ObjectId(note_id) except InvalidId: results.append({'id': note_id, 'ok': False, 'status': 400, 'error': 'Invalid id'}) continue note = db.sticky_notes.find_one({'_id': oid, 'user_id': user_id}) if not note: results.append({'id': note_id, 'ok': False, 'status': 404, 'error': 'Not found'}) continue fragment = item updates: Dict[str, Any] = {} if 'content_b64' in fragment or 'content' in fragment: try: updates['content'] = _note_content_from_request(fragment) except _ContentTooLong: results.append({'id': note_id, 'ok': False, 'status': 400, 'error': 'content_too_long', 'max': MAX_NOTE_CHARS}) continue except _InvalidContentB64: results.append({'id': note_id, 'ok': False, 'status': 400, 'error': 'invalid_content_b64'}) continue if 'position' in fragment and isinstance(fragment.get('position'), dict): pos = fragment['position'] updates['position_x'] = _coerce_int(pos.get('x'), 100, 0, 100000) updates['position_y'] = _coerce_int(pos.get('y'), 100, 0, 1000000) if 'size' in fragment and isinstance(fragment.get('size'), dict): size = fragment['size'] updates['width'] = _coerce_int(size.get('width'), 250, 120, 1200) updates['height'] = _coerce_int(size.get('height'), 200, 80, 1200) if 'color' in fragment: col = resolve_note_color(fragment.get('color'), default=None) if col: updates['color'] = col if 'is_minimized' in fragment: updates['is_minimized'] = bool(fragment.get('is_minimized')) if 'line_start' in fragment: try: updates['line_start'] = int(fragment.get('line_start')) except Exception: updates['line_start'] = None if 'line_end' in fragment: try: updates['line_end'] = int(fragment.get('line_end')) except Exception: updates['line_end'] = None if 'anchor_id' in fragment: aid = (fragment.get('anchor_id') or '').strip()[:256] updates['anchor_id'] = aid or None if 'anchor_text' in fragment: atx = (fragment.get('anchor_text') or '').strip()[:256] updates['anchor_text'] = atx or None if 'mode' in fragment: mode_value, mode_error = _mode_update(fragment.get('mode'), note) if mode_error: 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: prev_updated_at = fragment.get('prev_updated_at') if prev_updated_at: try: prev_dt = datetime.fromisoformat(str(prev_updated_at)) except Exception: prev_dt = None if prev_dt and isinstance(note.get('updated_at'), datetime) and prev_dt < note['updated_at']: results.append({'id': note_id, 'ok': False, 'status': 409, 'error': 'Conflict', 'updated_at': note['updated_at'].isoformat()}) continue # stamp scope if missing — אך לא לפתק לוח, שאין לו # scope ולעולם לא יהיה. ראו את השומר המקביל ב-update_note. if not note.get('scope_id') and not note.get('board_id'): scope_id, scope_file_name, _ = _resolve_scope(db, user_id, note.get('file_id')) if scope_id: updates['scope_id'] = scope_id if scope_file_name and 'file_name' not in updates: updates['file_name'] = scope_file_name except Exception: pass updates['updated_at'] = datetime.now(timezone.utc) ops: Dict[str, Any] = {'$set': updates} if unset_fields: ops['$unset'] = unset_fields if 'title' in updates and _duplicate_title_for_note( db, user_id, note, 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: emit_event("sticky_notes_batch_item_error", severity="anomaly", error=str(e)) except Exception: pass nid = None try: nid = str((item or {}).get('id') or '') except Exception: nid = None results.append({'id': nid, 'ok': False, 'status': 500, 'error': 'Failed'}) resp = jsonify({'ok': True, 'results': results}) try: resp.headers['Cache-Control'] = 'no-store' except Exception: pass return resp except Exception as e: try: emit_event("sticky_notes_batch_error", severity="anomaly", error=str(e)) except Exception: pass return jsonify({'ok': False, 'error': 'Failed to process batch'}), 500 # --- Board notes --- # # ראוטים נפרדים לפתקי לוח, ולא הכללה של ``/<file_id>``. הכללה הייתה מכריחה # כל צרכן להכיר קידוד כלשהו בתוך הפרמטר, ושוברת גם את ה-JS וגם את הטסטים # שמניחים שהסגמנט הזה הוא מזהה קובץ. אין התנגשות ניתוב: ``/board/<x>`` הוא # שני סגמנטים ו-``/<file_id>`` אחד — בדיוק כמו ``/note/<id>`` ו- # ``/reminders/*`` שכבר חיים כאן. # # מה שלא חוזר על עצמו כאן בכוונה: ``PUT /note/<id>``, ``DELETE /note/<id>``, # ``POST /batch`` וכל ראוטי התזכורות מזהים פתק לפי ``_id + user_id`` בלבד, # ולכן הם עובדים על פתקי לוח בלי שורת קוד אחת. זה כל הרווח של "אוסף פתקים # אחד": מסלול הכתיבה, ה-debounce, ה-optimistic concurrency וה-keepalive # מגיעים בירושה. def _current_user_is_admin() -> bool: """אדמין פטור מתקרות. ``user_roles`` הוא מודול טהור ולכן אין כאן מעגל.""" try: from user_roles import is_admin return bool(is_admin(int(session.get('user_id') or 0))) except Exception: return False def _count_or_none(coll: Any, query: Dict[str, Any]) -> Optional[int]: """ספירה, או ``None`` כשהיא נכשלה. ההבחנה חשובה: ``check_note_quota`` דוחה על ``None`` במקום להניח אפס. """ try: return int(coll.count_documents(query)) except Exception: return None def _resolve_owned_board(db: Any, user_id: int, board_id: str) -> Optional[Dict[str, Any]]: """הלוח, אם הוא קיים ושייך למשתמש. אחרת ``None``. בלי הבדיקה הזו אפשר היה ליצור פתקים על ``board_id`` שרירותי — פתקים שאינם נראים בשום ממשק אבל כן נספרים בתקרה. ``mcp_server/backend`` עושה את המקבילה לקובץ ומחזיר ``file_not_found``. """ try: oid = ObjectId(str(board_id)) except Exception: return None try: doc = db.note_boards.find_one({'_id': oid, 'user_id': int(user_id)}) except Exception: return None return doc if isinstance(doc, dict) else None @sticky_notes_bp.route('/board/<board_id>', methods=['GET']) @require_auth @notes_rate_limit('board_list', 180) @traced("sticky_notes.board_list") def list_board_notes(board_id: str): """פתקי לוח. שאילתה ישירה, בלי ``$or`` ובלי מעבר ב-``code_snippets``.""" try: from sticky_notes_target import board_notes_filter _ensure_indexes() user_id = int(session['user_id']) db = get_db() if not _resolve_owned_board(db, user_id, board_id): return jsonify({'ok': False, 'error': 'board_not_found'}), 404 cursor = db.sticky_notes.find(board_notes_filter(user_id, board_id)).sort('created_at', 1) raw_docs = list(cursor) if cursor is not None else [] notes = [_as_note_response(doc) for doc in raw_docs if isinstance(doc, dict)] resp = jsonify({'ok': True, 'notes': notes, 'count': len(notes)}) try: resp.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate' resp.headers['Pragma'] = 'no-cache' resp.headers['Expires'] = '0' except Exception: pass return resp except Exception as e: try: emit_event("sticky_notes_board_list_error", severity="anomaly", error=str(e)) except Exception: pass return jsonify({'ok': False, 'error': 'Failed to list board notes'}), 500 @sticky_notes_bp.route('/board/<board_id>', methods=['POST']) @require_auth @notes_rate_limit('board_create', 60) @traced("sticky_notes.board_create") def create_board_note(board_id: str): """פתק חדש על לוח.""" try: from sticky_notes_target import ( DEFAULT_BOARD_MODE, NoteQuotaExceeded, NoteQuotaUnknown, board_notes_filter, build_note_target, check_note_quota, is_valid_board_mode, normalize_mode, ) _ensure_indexes() user_id = int(session['user_id']) db = get_db() if not _resolve_owned_board(db, user_id, board_id): return jsonify({'ok': False, 'error': 'board_not_found'}), 404 data = request.get_json(silent=True) or {} raw_mode = data.get('mode') if raw_mode is not None and not is_valid_board_mode(raw_mode): return jsonify({'ok': False, 'error': 'invalid_mode'}), 400 mode = normalize_mode(raw_mode, DEFAULT_BOARD_MODE) try: content = _note_content_from_request(data) except _ContentTooLong: 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`` דוחה על השנייה. is_admin_user = _current_user_is_admin() try: check_note_quota( _count_or_none(db.sticky_notes, board_notes_filter(user_id, board_id)), MAX_NOTES_PER_BOARD, is_admin=is_admin_user, ) check_note_quota( _count_or_none(db.sticky_notes, {'user_id': user_id}), MAX_NOTES_PER_USER, is_admin=is_admin_user, ) except NoteQuotaUnknown: return jsonify({'ok': False, 'error': 'note_quota_unknown'}), 409 except NoteQuotaExceeded: return jsonify({'ok': False, 'error': 'note_quota_exceeded'}), 409 pos = data.get('position') or {} size = data.get('size') or {} color = resolve_note_color(data.get('color')) 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), 'height': _coerce_int(size.get('height'), 200, 80, 1200), 'color': color, 'is_minimized': bool(data.get('is_minimized', False)), 'mode': mode, 'created_at': datetime.now(timezone.utc), 'updated_at': datetime.now(timezone.utc), } # שדות היעד עוברים דרך הבנאי, ולא נכתבים כאן ביד. זה מה שמונע # ממסמך לצאת עם שני משטחים או בלי אף אחד. doc.update(build_note_target(board_id=board_id)) # גיבוי לאכיפה כשהאינדקס לא אומת. במצב התקין אינו עולה שאילתה. 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)) except Exception: pass resp = jsonify({'ok': True, 'id': nid}) try: resp.headers['Cache-Control'] = 'no-store' except Exception: pass return resp, 201 except Exception as e: try: emit_event("sticky_notes_board_create_error", severity="anomaly", error=str(e)) except Exception: pass return jsonify({'ok': False, 'error': 'Failed to create note'}), 500 # --------------------------------------------------------------------------- # פתקי ריפו — היעד השלישי # # **שני ראוטי היתומים יושבים מחוץ לתת-העץ ``/repo/<name>/`` בכוונה.** # ``GET /repo/<name>/orphans`` היה מתנגש עם ``/repo/<name>/<path:repo_path>``, # והראוט הסטטי מנצח — כלומר קובץ אמיתי בשם ``orphans`` בשורש ריפו (שם סביר # לגמרי) לא היה נגיש דרך ה-API לעולם, בלי שום שגיאה. לכן ``repo-orphans`` # ו-``orphan-repos``, ולא תת-נתיבים. # --------------------------------------------------------------------------- @sticky_notes_bp.route('/repo/<repo_name>/<path:repo_path>', methods=['GET']) @require_auth @notes_rate_limit('repo_list', 180) @traced("sticky_notes.repo_list") def list_repo_notes(repo_name: str, repo_path: str): """פתקים על קובץ בריפו ממורר. **סימון היתומים נעשה בקריאה, ולא בכתיבה.** אין סריקה תקופתית ואין ``update_many`` על מסלול קריאה — זה הלקח מסריקת היתומים של הלוחות. הפתק ממשיך להיות מוחזר עם הנתיב האחרון הידוע, ורק נושא דגל. """ try: _ensure_indexes() user_id = int(session['user_id']) db = get_db() clean_path = normalize_repo_path(repo_path) if not clean_path: return jsonify({'ok': False, 'error': 'invalid_repo_path'}), 400 cursor = db.sticky_notes.find( repo_notes_filter(user_id, repo_name, clean_path) ).sort('created_at', 1) # אותה הבחנה שהראוט הזה עושה על המניפסט, גם על הפתקים עצמם: # ``None`` הוא כשל שאילתה ולא "אין פתקים". החזרת ``notes: []`` # כאן אינה רק תצוגה חסרה — הלקוח כותב את התשובה לקאש המקומי # (``_saveCache``), ולכן קריאה שנכשלה הייתה **מוחקת** את הפתקים # שהיו שמורים בו. if cursor is None: raise RuntimeError('repo notes query returned no cursor') raw_docs = list(cursor) notes = [_as_note_response(doc) for doc in raw_docs if isinstance(doc, dict)] # ``None`` (כשל שאילתה) אינו "הקובץ נעלם" — ואז לא מסמנים כלום. exists = repo_file_exists(db, str(repo_name), clean_path) payload: Dict[str, Any] = {'ok': True, 'notes': notes, 'count': len(notes)} if exists is False: payload['orphaned'] = True resp = jsonify(payload) try: resp.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate' resp.headers['Pragma'] = 'no-cache' resp.headers['Expires'] = '0' except Exception: pass return resp except Exception as e: try: emit_event("sticky_notes_repo_list_error", severity="anomaly", error=str(e)) except Exception: pass return jsonify({'ok': False, 'error': 'Failed to list repo notes'}), 500 @sticky_notes_bp.route('/repo/<repo_name>/<path:repo_path>', methods=['POST']) @require_auth @notes_rate_limit('repo_create', 60) @traced("sticky_notes.repo_create") def create_repo_note(repo_name: str, repo_path: str): """פתק חדש על קובץ בריפו ממורר.""" try: from sticky_notes_target import ( DEFAULT_BOARD_MODE, NoteQuotaExceeded, NoteQuotaUnknown, NoteTargetError, build_note_target, check_note_quota, is_valid_board_mode, normalize_mode, ) _ensure_indexes() user_id = int(session['user_id']) db = get_db() clean_path = normalize_repo_path(repo_path) if not clean_path: return jsonify({'ok': False, 'error': 'invalid_repo_path'}), 400 # רק ריפו ממורר. בלי הבדיקה אפשר היה ליצור פתקים על ``repo_name`` # שרירותי — פתקים שאינם נראים בשום ממשק אבל כן נספרים בתקרה, # בדיוק כמו ש-``_resolve_owned_board`` מונע בלוחות. # # **כשל בקריאת רשימת המראות נסגר, לא נפתח.** ``None`` אינו "אין # ריפואים" — הוא "לא ידוע", וקריאה שנכשלה אינה רשיון ליצור פתק על # ריפו שאולי אינו ממורר. זה אותו כלל של ``check_note_quota``: כשל # בדיקה נסגר. known = mirrored_repo_names(db) if known is None: return jsonify({'ok': False, 'error': 'repo_list_unavailable'}), 503 if str(repo_name) not in known: return jsonify({'ok': False, 'error': 'repo_not_found'}), 404 # **וגם הקובץ עצמו חייב להיות בעץ.** ריפו ממורר אינו מספיק: נתיב # שאינו ב-``repo_files`` הוא בדיוק מה שמסלול הקריאה מסמן # ``orphaned`` — כלומר הפתק היה נולד יתום, נספר בתקרה, ומוצג # ברשימת היתומים בלי שאי-פעם היה לו קובץ. עץ הדפדפן נבנה מאותו # ``repo_files``, ולכן כל קובץ שניתן לפתוח בממשק עובר כאן. # # ``None`` נסגר, בדיוק כמו רשימת המראות: קריאה שנכשלה אינה עדות # שהקובץ קיים, ואינה רשיון לכתוב. file_exists = repo_file_exists(db, str(repo_name), clean_path) if file_exists is None: return jsonify({'ok': False, 'error': 'repo_file_unavailable'}), 503 if not file_exists: return jsonify({'ok': False, 'error': 'repo_file_not_found'}), 404 data = request.get_json(silent=True) # גוף שאינו אובייקט (מערך, מחרוזת) הוא קלט פגום — 400, לא 500 # מ-``data.get`` על טיפוס לא נכון. if data is None: data = {} elif not isinstance(data, dict): return jsonify({'ok': False, 'error': 'invalid_payload'}), 400 # ``anchored`` אינו חוקי כאן מאותה סיבה שהוא אינו חוקי בלוח: הוא # דורש שורת מקור, והעיגון כאן הוא ברמת קובץ. שני המצבים מצמידים # את הפתק למסגרת התצוגה, לא לשורת קוד. raw_mode = data.get('mode') if raw_mode is not None and not is_valid_board_mode(raw_mode): return jsonify({'ok': False, 'error': 'invalid_mode'}), 400 mode = normalize_mode(raw_mode, DEFAULT_BOARD_MODE) try: content = _note_content_from_request(data) except _ContentTooLong: 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')) # **ה-cap לקובץ נאכף גם על אדמין, במכוון.** דפדפן הריפו חסום # לאדמינים, ולכן ``is_admin=is_admin_user`` היה הופך את התקרה # לקבוע מת שלא נאכף על אף אחד. מטרתה שמירת צורת-תוכן ("עשרים # פתקים על קובץ = זה כבר עמוד תיעוד") ולא הגנת-משאבים. # תקרת המשתמש, לעומתה, היא הגנת-משאבים ונשארת פטורה-לאדמין. is_admin_user = _current_user_is_admin() try: check_note_quota( _count_or_none(db.sticky_notes, repo_notes_filter(user_id, repo_name, clean_path)), MAX_NOTES_PER_REPO_FILE, is_admin=False, ) check_note_quota( _count_or_none(db.sticky_notes, {'user_id': user_id}), MAX_NOTES_PER_USER, is_admin=is_admin_user, ) except NoteQuotaUnknown: return jsonify({'ok': False, 'error': 'note_quota_unknown'}), 409 except NoteQuotaExceeded: return jsonify({'ok': False, 'error': 'note_quota_exceeded'}), 409 # ``position``/``size`` שאינם אובייקט הם קלט פגום — ``{}`` רק כשחסר, # אחרת ``pos.get`` על מערך/מחרוזת היה מפיל ל-500. pos = data.get('position') size = data.get('size') if pos is not None and not isinstance(pos, dict): return jsonify({'ok': False, 'error': 'invalid_payload'}), 400 if size is not None and not isinstance(size, dict): return jsonify({'ok': False, 'error': 'invalid_payload'}), 400 pos = pos or {} size = size or {} color = resolve_note_color(data.get('color')) 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), 'height': _coerce_int(size.get('height'), 200, 80, 1200), 'color': color, 'is_minimized': bool(data.get('is_minimized', False)), 'mode': mode, 'created_at': datetime.now(timezone.utc), 'updated_at': datetime.now(timezone.utc), } # שדות היעד דרך הבנאי, שמריץ את הנרמול ואת האילוץ "בדיוק יעד אחד". try: doc.update(build_note_target(repo_name=repo_name, repo_path=clean_path)) except NoteTargetError: return jsonify({'ok': False, 'error': 'invalid_repo_target'}), 400 if _repo_title_conflict(db, user_id, repo_name, clean_path, 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), repo_name=str(repo_name)) except Exception: pass resp = jsonify({'ok': True, 'id': nid}) try: resp.headers['Cache-Control'] = 'no-store' except Exception: pass return resp, 201 except Exception as e: try: emit_event("sticky_notes_repo_create_error", severity="anomaly", error=str(e)) except Exception: pass return jsonify({'ok': False, 'error': 'Failed to create note'}), 500 @sticky_notes_bp.route('/repo-orphans/<repo_name>', methods=['GET']) @require_auth @notes_rate_limit('repo_orphans', 60) @traced("sticky_notes.repo_orphans") def list_repo_orphans(repo_name: str): """קומה ראשונה: פתקים בריפו הזה שהקובץ שלהם כבר אינו בעץ. **"מיותם" נגזר מהמראה, לא מ-GitHub.** דפדפן הריפו מציג עותק מסונכרן, ולכן קובץ שנמחק ב-GitHub לפני שעתיים עדיין קיים כאן והפתק עליו ייראה תקין עד הסנכרון הבא. לכן מוחזר גם ``last_sync_time`` — זה לא באג אם כתוב, וכן באג אם לא. """ try: _ensure_indexes() user_id = int(session['user_id']) db = get_db() # כשל בקריאת הפתקים אינו "אין יתומים" — הוא "לא ידוע", בדיוק כמו # כשל בקריאת המניפסט. בלי ההבחנה, נפילת שאילתה הייתה מוצגת כרשימה # ריקה של יתומים. # # ``None`` הוא ערוץ כשל בפני עצמו, לא רק חריגה: דרייבר או שכבת # עטיפה שמחזירים ``None`` במקום סמן לא זורקים כלום, והקוד הישן # המשיך עם רשימה ריקה ועם ``notes_failed=False`` — כלומר ``unknown`` # היה יוצא ``false`` ו"לא הצלחנו לקרוא" היה מוצג כ"אין יתומים". notes_failed = False try: cursor = db.sticky_notes.find({ 'user_id': user_id, 'repo_name': str(repo_name), 'repo_path': {'$exists': True}, }).sort('created_at', 1) if cursor is None: raw_docs = [] notes_failed = True else: raw_docs = list(cursor) except Exception: raw_docs = [] notes_failed = True # מניפסט הריפו בשאילתה אחת, במקום ``find_one`` לכל פתק. try: paths = db.repo_files.distinct('path', {'repo_name': str(repo_name)}) known_paths = {str(p) for p in paths if p} if isinstance(paths, (list, tuple, set)) else None except Exception: known_paths = None orphans = [] if known_paths is not None and not notes_failed: for doc in raw_docs: if not isinstance(doc, dict): continue if str(doc.get('repo_path') or '') not in known_paths: item = _as_note_response(doc) item['repo_path'] = str(doc.get('repo_path') or '') item['repo_name'] = str(doc.get('repo_name') or '') orphans.append(item) last_sync = None try: meta = db.repo_metadata.find_one({'repo_name': str(repo_name)}, {'last_sync_time': 1}) if isinstance(meta, dict) and meta.get('last_sync_time'): last_sync = str(meta.get('last_sync_time')) except Exception: last_sync = None return jsonify({ 'ok': True, 'notes': orphans, 'count': len(orphans), 'last_sync_time': last_sync, # ``True`` פירושו שלא הצלחנו לקרוא את המניפסט **או** את הפתקים, # ולכן הרשימה הריקה למעלה אינה "אין יתומים" אלא "לא ידוע". 'unknown': known_paths is None or notes_failed, }) except Exception as e: try: emit_event("sticky_notes_repo_orphans_error", severity="anomaly", error=str(e)) except Exception: pass return jsonify({'ok': False, 'error': 'Failed to list repo orphans'}), 500 @sticky_notes_bp.route('/orphan-repos', methods=['GET']) @require_auth @notes_rate_limit('orphan_repos', 60) @traced("sticky_notes.orphan_repos") def list_orphan_repos(): """קומה שנייה: ריפואים שיש להם פתקים ואינם ממוררים עוד. בלי הקומה הזו פתקים של ריפו שהוסר **לא מופיעים בשום תצוגה** — לא בעץ שלו, כי אין עץ, ולא ברשימת היתומים, כי היא פר-ריפו. זה בדיוק "המצב שנעלם בשקט". """ try: _ensure_indexes() user_id = int(session['user_id']) db = get_db() try: names = db.sticky_notes.distinct('repo_name', { 'user_id': user_id, 'repo_path': {'$exists': True}, }) with_notes = {str(n) for n in names if n} if isinstance(names, (list, tuple, set)) else set() except Exception: return jsonify({'ok': False, 'error': 'Failed to list orphan repos'}), 500 mirrored = mirrored_repo_names(db) if mirrored is None: # אין רשימת מראות ⇒ אי אפשר לדעת מי נעלם. לא מדווחים על אף # ריפו כיתום על סמך שאילתה שנכשלה. return jsonify({'ok': True, 'repos': [], 'count': 0, 'unknown': True}) missing = sorted(with_notes - mirrored) repos = [] for name in missing: try: count = int(db.sticky_notes.count_documents({ 'user_id': user_id, 'repo_name': name, 'repo_path': {'$exists': True}, })) except Exception: # הריפו בטוח שקיים ברשימה (הוא הגיע מ-``distinct``), ולכן יש # לו לפחות פתק אחד. ``0`` היה נתון שגוי; ``None`` (null ב-JSON) # אומר "המספר לא ידוע" בלי להמציא אפס. count = None repos.append({'repo_name': name, 'notes': count}) return jsonify({'ok': True, 'repos': repos, 'count': len(repos), 'unknown': False}) except Exception as e: try: emit_event("sticky_notes_orphan_repos_error", severity="anomaly", error=str(e)) except Exception: pass return jsonify({'ok': False, 'error': 'Failed to list orphan repos'}), 500 @sticky_notes_bp.route('/note/<note_id>/task', methods=['POST']) @require_auth @notes_rate_limit('note_task_toggle', 300) @traced("sticky_notes.task_toggle") def toggle_note_task(note_id: str): """מסמן או מבטל צ'קבוקס בתוך פתק. **הראוט הזה קיים כדי שאפשר יהיה לאמת את הכתיבה.** האלטרנטיבה — לשלוח את התוכן המלא ב-``PUT /note/<id>`` — הופכת כל קליק לדריסת last-writer-wins של עריכות מקבילות, ובעיקר הופכת אימות לבלתי אפשרי: אפשר לאמת רק שכתבנו את מה ששלחנו. הבקשה כאן נושאת **כוונה** (מספר סידורי + מצב רצוי), וזה הדבר היחיד שניתן לאמת מול המסד. הסדר, וכל שלב בו מגן על משהו: 1. הפתק קיים ושייך למשתמש — אחרת 404. 2. ``prev_updated_at`` — אחרת 409, בדיוק כמו ב-``update_note``. 3. סידורי שאינו קיים ⇒ **409**, לא 200. התצוגה של הלקוח מיושנת. 4. כבר במצב המבוקש ⇒ 200 בלי כתיבה. אידמפוטנטי. 5. Compare-and-swap: הפילטר כולל את התוכן הקודם, כך שכותב מקביל אינו נדרס. 6. **קריאה חוזרת מהמסד** ובדיקה שהתו אכן השתנה. לא ``modified_count``, לא ``ok: true`` — אלה מדווחים על הקריאה, לא על המצב. בכל מסלול התשובה נושאת את התוכן הסמכותי, כדי שהלקוח יסתנכרן בלי סיבוב נוסף — וגם במסלול הכשל, שם זה מה שמאפשר לו להחזיר את התצוגה למה שבאמת שמור. """ try: from sticky_notes_tasks import task_state_at_index, toggle_task_at_index user_id = int(session['user_id']) db = get_db() try: oid = ObjectId(str(note_id)) except InvalidId: return jsonify({'ok': False, 'error': 'Invalid note_id'}), 400 data = request.get_json(silent=True) or {} try: index = int(data.get('index')) except Exception: return jsonify({'ok': False, 'error': 'invalid_index'}), 400 if index < 0: return jsonify({'ok': False, 'error': 'invalid_index'}), 400 checked = bool(data.get('checked')) note = db.sticky_notes.find_one({'_id': oid, 'user_id': user_id}) if not note: return jsonify({'ok': False, 'error': 'Note not found'}), 404 prev_updated_at = data.get('prev_updated_at') if prev_updated_at: try: # ``fromisoformat`` מקבל סיומת ``Z`` מ-Python 3.11, וה-CI # רץ על 3.11 ו-3.12 בלבד — אותה צורה כמו בשני # מסלולי העדכון האחרים. prev_dt = datetime.fromisoformat(str(prev_updated_at)) except Exception: prev_dt = None if prev_dt and isinstance(note.get('updated_at'), datetime) and prev_dt < note['updated_at']: return jsonify({ 'ok': False, 'error': 'Conflict', 'content': note.get('content', ''), 'updated_at': note['updated_at'].isoformat(), }), 409 original = note.get('content', '') or '' new_content, changed = toggle_task_at_index(original, index, checked) if not changed: # שתי סיבות אפשריות, ורק אחת מהן תקינה. if task_state_at_index(original, index) is None: # הסידורי אינו קיים — התצוגה של הלקוח מתארת פתק אחר. return jsonify({ 'ok': False, 'error': 'task_index_not_found', 'content': original, }), 409 # כבר במצב המבוקש. אין כתיבה, וזה בסדר גמור. return jsonify({'ok': True, 'changed': False, 'content': original}) now = datetime.now(timezone.utc) db.sticky_notes.update_one( {'_id': oid, 'user_id': user_id, 'content': original}, {'$set': {'content': new_content, 'updated_at': now}}, ) fresh = db.sticky_notes.find_one({'_id': oid, 'user_id': user_id}) applied = bool(fresh) and task_state_at_index(fresh.get('content', '') or '', index) is checked if not applied: try: emit_event( "sticky_note_task_not_applied", severity="error", user_id=int(user_id), note_id=str(note_id), ) except Exception: pass return jsonify({ 'ok': False, 'error': 'task_toggle_not_applied', 'content': (fresh.get('content', '') if fresh else original), }), 409 fresh_updated = fresh.get('updated_at') return jsonify({ 'ok': True, 'changed': True, 'content': fresh.get('content', ''), 'updated_at': fresh_updated.isoformat() if isinstance(fresh_updated, datetime) else None, }) except Exception as e: try: emit_event("sticky_notes_task_error", severity="anomaly", error=str(e)) except Exception: pass return jsonify({'ok': False, 'error': 'Failed to toggle task'}), 500 #: כמה תוצאות מוחזרות כשלא נתבקש אחרת, וכמה הכי הרבה אפשר לבקש. #: #: **התקרה אינה קוסמטית.** ``limit`` מגיע מה-URL, כלומר מחוץ לתהליך, ובלי #: חסם עליון בקשה אחת יכולה לגרור את כל מכסת המשתמש #: (:data:`~sticky_notes_target.MAX_NOTES_PER_USER`) לתוך המיון ולתוך #: התשובה. חמישים תוצאות הן יותר ממה שמישהו סורק בעין; מי שצריך יותר #: מצמצם את החיפוש. NOTE_SEARCH_DEFAULT_LIMIT = 30 NOTE_SEARCH_MAX_LIMIT = 100 #: אורך מרבי למחט החיפוש עצמה. #: #: קבוע **נפרד** מ-:data:`~sticky_notes_target.NOTE_SEARCH_PREVIEW_CHARS` #: אף ששניהם 200 היום: זה חוסם קלט שנכנס לרג'קס, וזה קובע כמה מהגוף מוצג. #: מספר משותף לשני דברים שאינם אותו דבר הוא מספר שמישהו ישנה בשביל האחד #: וישבור את השני. NOTE_SEARCH_MAX_NEEDLE = 200 def _search_limit(raw: Any) -> int: """‏``limit`` מהשאילתה, חסום משני הצדדים. ערך שאינו מספר אינו שגיאה אלא ברירת מחדל: הוא מגיע מ-URL שאפשר להקליד ביד, והפלת הבקשה על ``?limit=abc`` מחליפה חיפוש שעובד בשגיאה על פרמטר שאיש לא התכוון לשלוח. """ try: want = int(str(raw).strip()) except (TypeError, ValueError): return NOTE_SEARCH_DEFAULT_LIMIT if want < 1: return NOTE_SEARCH_DEFAULT_LIMIT return min(want, NOTE_SEARCH_MAX_LIMIT)
[תיעוד] def build_note_search_pipeline( user_id: int, needle: str, *, color_id: Optional[str], limit: int, ) -> List[Dict[str, Any]]: """הצינור שמחפש פתקים — **בנוי כאן, ונבדק כפי שנשלח**. סדר השלבים אינו קוסמטי, ושלושה מהם קיימים בגלל תקלות שכבר קרו בריפו הזה: 1. ``$match`` — הפילטר המשותף עם ה-MCP (:func:`~sticky_notes_target.note_search_filter`). **אותה פונקציה ולא העתק**: שני חיפושי פתקים שמתאימים אחרת הם שני מוצרים שמתחזים לאחד. 2. ``$addFields`` — הדירוג והתצוגה המקדימה, שניהם **לפני** שהגוף יורד, כי שניהם נגזרים ממנו. 3. ``$project`` בהחרגה — הגוף יורד **לפני** ה-``$sort``. בסדר ההפוך מונגו החזירה בפרודקשן שגיאה 292 (``QueryExceededMemoryLimitNoDiskUseAllowed``) והקוד נפל למסלול איטי; ``allowDiskUse`` אינו מציל כי Atlas מתעלם ממנו ב-Flex. מתועד ב-``tests/test_files_pipelines_drop_heavy_fields_early.py``. 4. ``$sort`` — כותרת לפני גוף, ובתוך כל קבוצה לפי עדכון אחרון. ‏``false < true`` בסדר ה-BSON, ולכן ``-1`` מעלה את פגיעות הכותרת. 5. ``$limit`` עם **שורת סנטינל** (``limit + 1``), כמו ב-MCP: שורה נוספת שחזרה היא ראיה שיש עוד, ולא ניחוש מתוך ספירה ששווה לתקרה. 6. ``$unset`` — שדה העזר של הדירוג אינו חלק מהתשובה. **‏``$substrCP`` ו-``$strLenCP``, ולעולם לא הגרסאות ב-Bytes.** אות עברית היא שני בייטים, וחיתוך בבייטים על מספר שנספר בתווים נוחת באמצע תו ומפיל את השאילתה (``Location28657``). זה ``amir-bug-patterns`` H6, והאירוע עצמו קרה כאן. **הדירוג פשוט בכוונה.** אין כאן משקלות מכוילים: ``_calculate_relevance_score`` שהתיעוד מזכיר אינה קיימת בקוד כלל, וניקוד שאיש אינו יכול להסביר הוא ניקוד שאיש לא יתחזק. פתק הוא נושא שלם, ולכן השאלה היא **איזה** פתק — ומופע בשם הוא עדות חזקה יותר ממופע בגוף. """ from sticky_notes_target import NOTE_SEARCH_PREVIEW_CHARS, note_search_filter pattern = re.escape(str(needle)) body = {"$ifNull": ["$content", ""]} return [ {"$match": note_search_filter( int(user_id), needle, search_content=True, content_needle=needle, color_id=color_id, )}, {"$addFields": { "_title_hit": {"$regexMatch": { # ``$ifNull`` כי ל-61% מהפתקים במסד אין שדה ``title`` כלל, # ו-``$regexMatch`` על ``missing`` נופל בשגיאה במקום להחזיר # ``false``. "input": {"$ifNull": ["$title", ""]}, "regex": pattern, "options": "i", }}, "preview": {"$substrCP": [body, 0, NOTE_SEARCH_PREVIEW_CHARS]}, "preview_truncated": {"$gt": [{"$strLenCP": body}, NOTE_SEARCH_PREVIEW_CHARS]}, }}, {"$project": {"content": 0}}, {"$sort": {"_title_hit": -1, "updated_at": -1}}, {"$limit": int(limit) + 1}, {"$unset": "_title_hit"}, ]
def _as_search_hit(doc: Dict[str, Any]) -> Dict[str, Any]: """שורת תוצאה — זהות, תצוגה מקדימה, ו**לעולם לא הגוף**. ``url`` מצביע על ``/note/<id>`` ואינו מורכב כאן משדות היעד. ‏``webapp/boards_ui.note_permalink`` הוא הבונה היחיד של קישור לפתק, והוא מכיר את שלושת הסוגים — כולל רביעי שיתווסף. צרכן שמרכיב URL בעצמו הוא בדיוק מה שהפונקציה ההיא נכתבה כדי לבטל. """ from sticky_notes_target import note_target_ref note_id = str(doc.get('_id')) updated = doc.get('updated_at') hit = { 'id': note_id, 'url': f'/note/{note_id}', 'title': str(doc.get('title', '') or ''), 'preview': _coerce_content_from_doc(doc.get('preview', '')), 'preview_truncated': bool(doc.get('preview_truncated')), 'color': note_color_hex(doc.get('color')), 'color_id': note_color_id(doc.get('color')), 'updated_at': updated.isoformat() if isinstance(updated, datetime) else None, } hit.update(note_target_ref(doc)) return hit @sticky_notes_bp.route('/search', methods=['GET']) @require_auth @notes_rate_limit('search', 60) @traced("sticky_notes.search") def search_notes(): """חיפוש בפתקים של המשתמש — בשם ובגוף, חוצה את שלושת היעדים. **אותה התאמה כמו ב-MCP.** שניהם עוברים דרך :func:`~sticky_notes_target.note_search_filter`, ולכן חיפוש של אותה מילה בעמוד ובסוכן מחזיר את אותה קבוצת פתקים. מה שנבדל הוא רק מה שכל צרכן **מציג**: ה-MCP מחזיר הפניות בלבד, והעמוד מוסיף תצוגה מקדימה. **הרשאות: הפתקים של המשתמש, כל שלושת הסוגים** — בדיוק כמו ``codekeeper_search_notes``, שאינו מגודר לאדמין (הגידור שם חל על ``list_repo_notes``/``create_repo_note`` בלבד). גם ``/api/sticky-notes/repo/…`` בוובאפ נושא ``@require_auth`` בלבד, כלומר גייט אדמין כאן היה מסתיר מהמשתמש פתקים שהוא כבר מושך בקריאה אחרת — גייט שאינו מגן על דבר. """ try: _ensure_indexes() user_id = int(session['user_id']) # ``.strip()`` אחרי הסניטציה, ובכוונה: ``_sanitize_text`` מסירה תווי # בקרה ולא רווחים, ולכן ``?q=%20%20`` היה עובר כמחט "לא ריקה" # ומתורגם לרג'קס שתופס כל פתק שיש בו שני רווחים — כלומר בדיוק # ה"הכול" שהבדיקה למטה נועדה למנוע. needle = _sanitize_text(request.args.get('q', ''), NOTE_SEARCH_MAX_NEEDLE).strip() if not needle: # **ולא "הכול".** שאילתה ריקה על פילטר רג'קס תופסת כל פתק, כלומר # משיבה תשובה לשאלה שלא נשאלה. אותה הכרעה כבר מקודדת # ב-``note_search_filter``, שמעלה ``ValueError`` על מחט ריקה. return jsonify({'ok': False, 'error': 'empty_query'}), 400 color_id = str(request.args.get('color', '') or '').strip().lower() if color_id: from sticky_notes_target import NOTE_COLOR_ORDER if color_id not in NOTE_COLOR_ORDER: # רשימת המזהים התקינים בתשובה, בדיוק כמו ב-``mcp_server``: # קורא שיודע מה חוקי יכול לתקן, וקורא שקיבל ``ok`` על מסנן # שלא הוחל מדווח דבר לא נכון. return jsonify({ 'ok': False, 'error': 'invalid_color', 'allowed': list(NOTE_COLOR_ORDER), }), 400 else: color_id = None limit = _search_limit(request.args.get('limit')) db = get_db() pipeline = build_note_search_pipeline(user_id, needle, color_id=color_id, limit=limit) cursor = db.sticky_notes.aggregate(pipeline) rows = list(cursor) if cursor is not None else [] truncated = len(rows) > limit if truncated: rows = rows[:limit] resp = jsonify({ 'ok': True, 'query': needle, 'color_id': color_id or '', 'count': len(rows), 'truncated': truncated, 'results': [_as_search_hit(doc) for doc in rows if isinstance(doc, dict)], }) try: resp.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate' except Exception: pass return resp except Exception as e: try: emit_event("sticky_notes_search_error", severity="anomaly", error=str(e)) except Exception: pass return jsonify({'ok': False, 'error': 'Failed to search notes'}), 500