Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/user/sticky_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,35 @@

בלוח אין עיגון לשורת מקור, כי אין טקסט להיצמד אליו — ולכן גם **בעיית הפתק המיותם אינה קיימת שם**. שום דבר לא זז מתחת לפתק על לוח.

שם, ביטול והעתקה
~~~~~~~~~~~~~~~~~
בשורת הכפתורים של כל פתק — בקובץ ובלוח כאחד — יש שדה **שם** ושני כפתורים נוספים.

השם מוצג בצד השני של שורת הכפתורים, ו**נשאר גלוי גם כשהפתק ממוזער** — כך שהשורה הריקה של הכותרת מקבלת משמעות. שם ריק פירושו "אין שם", והשדה נמחק מהמסמך.

.. important::
**שם מזהה פתק אחד בתוך לוח**, בדיוק כמו ששם קובץ מזהה קובץ אחד. שני פתקים באותו שם באותו לוח נדחים ב-``duplicate_title``. אותו שם בלוח אחר, או אצל משתמש אחר, מותר. **במצב התקין** האכיפה היא של המסד — אינדקס ייחודי-חלקי — ולא של הקוד, וכך היא גם חסינה למרוץ בין שתי בקשות מקבילות. יש מצב אחד ויחיד שבו קיים גיבוי בקוד, והוא מתואר בסוף הקטע.

**הייחודיות היא בתוך לוח בלבד.** לפתקים שיושבים על קבצים אין שם לוח, והם אינם כפופים לאילוץ — שני קבצים יכולים להחזיק פתק באותו שם, וגם אותו קובץ יכול. זה מכוון: בעולם הקבצים אותו שם פירושו גרסה נוספת, לא התנגשות.

האכיפה מוגדרת במקום אחד, ``ONE_TITLE_PER_BOARD_INDEX`` ב-``sticky_notes_target``, ונבנית בשני השירותים (הוובאפ וה-MCP). שם האינדקס נושא מספר גרסה (``one_title_per_board_v2``), וזה מה שמאפשר לשדרג אותו **בלי רגע אחד בלי ייחודיות**: מונגו אינו יודע לשנות שם של אינדקס, ולכן עדכון "במקום" מחייב להפיל ואז לבנות — ובין השניים שתי בקשות מקבילות יכולות להכניס שני שמות זהים, ואז הבנייה החדשה נכשלת והאכיפה נשארת מושבתת. עם מספור הסדר מתהפך: בונים את החדש, ורק אז מפילים את הישן.

**המצב היחיד שבו הקוד אוכף:** אם מסיבה כלשהי האינדקס לא אומת במסד, השירות אינו מסמן את האתחול כהצלחה — הבנייה תנוסה שוב, עם השהיה — ובינתיים מסלולי הכתיבה מריצים בדיקת קוד. הבדיקה הזו היא ``find_one`` לפני הכתיבה, ולכן **אינה חסינה למרוץ**: שתי בקשות מקבילות יכולות שתיהן לראות שם פנוי. היא אינה תחליף לאינדקס ואינה מתיימרת להיות; היא ההפרש בין אכיפה חלקית לבין הבטחה שאין מאחוריה כלום. במצב התקין היא אינה עולה אף שאילתה.

**ביטול פעולה** (``↶``) מחזיר את תוכן הפתק צעד אחורה בכל לחיצה. הקלדה רצופה נספרת כצעד אחד, ולכן הדבקה בטעות מבוטלת בלחיצה אחת. הכפתור מושבת כשאין מה לבטל.

.. warning::
הביטול חי ב**סשן הנוכחי בלבד** ואינו שורד רענון דף. הוא נועד למקרה שמתגלה מיד — בחירת כל הטקסט והדבקה במקום העתקה — ולא כתחליף להיסטוריית גרסאות, שאין לפתקים.

**העתקה** (``⧉``) מעתיקה את כל תוכן הפתק לקליפבורד. בדפדפן שחוסם את ההרשאה מוצג חיווי כשל, ולא לחיצה שלא קורה בה כלום.

גלילה אינסופית בלוח
~~~~~~~~~~~~~~~~~~~~
בהגדרות הלוח (גלגל השיניים) אפשר להדליק **גלילה אינסופית** — הלוח ממשיך להתרחב כלפי מטה, כך שתמיד יש לאן לגרור פתקים.

.. note::
כיבוי המצב **אינו מזיז אף פתק**. אורך הלוח נמדד תמיד עד הפתק האחרון ומתקצר רק עד שם. ההעדפה נשמרת לכל לוח בנפרד בדפדפן, ואינה חלק מנתוני הלוח.

צ'קבוקסים בפתק
~~~~~~~~~~~~~~~
שורה בצורת ``- [ ] טקסט`` מוצגת כתיבת סימון שאפשר ללחוץ עליה. הלחיצה נשמרת מיד לתוכן הפתק.
Expand Down
67 changes: 65 additions & 2 deletions mcp_server/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,21 @@

import datetime as _dt
import html
import time as _time
import logging
from typing import Any

# ``DuplicateKeyError`` נדרש כדי להבחין בין "שם תפוס" לבין תקלה אמיתית.
# אותה תבנית ייבוא עמיד שבה משתמש ``webapp/sticky_notes_api``: בסביבות
# בדיקה בלי pymongo, מחלקה מקומית שלא תיזרק לעולם עדיפה על ייבוא שמפיל
# את המודול כולו.
try: # type: ignore
from pymongo.errors import DuplicateKeyError as _DuplicateKeyError # type: ignore
except Exception: # pragma: no cover
class _DuplicateKeyError(Exception): # type: ignore
pass


logger = logging.getLogger(__name__)

_HEAVY_FIELDS = ("code", "content", "raw_data", "raw_content")
Expand All @@ -29,7 +41,7 @@
#: יושב. בפתק קובץ שניהם ריקים, ולכן התוספת אינה משנה את מסלול הקובץ.
_NOTE_FIELDS = (
"content", "color", "line_start", "anchor_text", "is_minimized",
"board_id", "mode",
"board_id", "mode", "title",
)


Expand Down Expand Up @@ -356,8 +368,39 @@ def _notes_coll(self) -> Any:
coll.create_index([("user_id", 1), ("scope_id", 1)], name="user_scope_idx")
except Exception:
logger.warning("sticky notes index creation failed (non-fatal)", exc_info=True)
# ``create_board_note`` מחזיר ``duplicate_title`` על סמך דחייה של
# המסד. אם האינדקס אינו שם — והוא נוצר עד היום רק בוובאפ — ההבטחה
# ריקה. פריסה של ה-MCP בלי הוובאפ היא בדיוק המקרה הזה.
#
# **הבנייה אינה חד-פעמית כמו השכנה שמעל.** דגל "ניסינו" שנדלק לפני
# הניסיון הופך כשל חולף אחד — נפילת רשת בעליית התהליך — לתהליך שלם
# שרץ בלי אכיפה עד שיופעל מחדש. כאן מנסים שוב, עם השהיה, עד שהאינדקס
# מאומת בקריאה חוזרת.
self._ensure_title_index(coll)
return coll

#: כמה להמתין בין ניסיונות בנייה כושלים, בשניות
_TITLE_INDEX_RETRY_SECONDS = 60.0

def _ensure_title_index(self, coll: Any) -> bool:
"""בונה ומאמת את אינדקס השם. מחזיר האם האילוץ **חי** כרגע."""
if getattr(self, "_title_index_ok", False):
return True
now = _time.monotonic()
if now < getattr(self, "_title_index_retry_at", 0.0):
Comment thread
amirbiron marked this conversation as resolved.
return False
self._title_index_retry_at = now + self._TITLE_INDEX_RETRY_SECONDS
try:
from sticky_notes_target import ensure_title_index

self._title_index_ok = bool(ensure_title_index(coll))
except Exception:
self._title_index_ok = False
logger.error("one_title_per_board index creation failed", exc_info=True)
if not self._title_index_ok:
logger.error("one_title_per_board index not confirmed — falling back to a code check")
return self._title_index_ok

def _related_file_ids(self, user_id: int, file_name: str) -> list[str]:
"""כל מזהי הגרסאות של השם הזה — לפריטת שאילתת הוובאפ (פתקי legacy בלי scope_id)."""
try:
Expand Down Expand Up @@ -556,6 +599,7 @@ def create_board_note(
content: str,
color: str,
mode: str,
title: str = "",
) -> dict[str, Any]:
"""פתק חדש על לוח.

Expand Down Expand Up @@ -610,6 +654,8 @@ def create_board_note(
# שהוא מחזיר, ולכן אי אפשר להרכיב כאן מסמך לא חוקי.
**build_note_target(board_id=canonical),
"content": content,
# שם ריק ← השדה כלל אינו נכתב, כדי שלא ייכנס לאינדקס הייחודי
**({"title": title} if title else {}),
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
"position_x": 120,
"position_y": 120,
"width": 260,
Expand All @@ -620,7 +666,24 @@ def create_board_note(
"created_at": now,
"updated_at": now,
}
res = coll.insert_one(note)
# גיבוי לאכיפה כשהאינדקס לא אומת. במצב התקין ``_ensure_title_index``
# מחזירה True מיד, ואין כאן שום שאילתה נוספת.
if title and not self._ensure_title_index(coll):
from sticky_notes_target import title_is_taken

if title_is_taken(coll, user_id=int(user_id), board_id=canonical, title=title):
return {"ok": False, "error": "duplicate_title"}

try:
res = coll.insert_one(note)
except _DuplicateKeyError:
# שם תפוס בלוח — התנגשות ולא תקלה. נתפס לפי **הטיפוס** ולא לפי
# שם המחלקה: השוואת מחרוזת הייתה נשענת על שם שהדרייבר חופשי
# לשנות, והייתה תופסת גם כל חריגה זרה שבמקרה נקראת כך. תפיסה
# לפי טיפוס מכסה גם תת-מחלקות. (חריגה **עוטפת** — כזו שמחזיקה
# DuplicateKeyError בתוכה — אינה נתפסת כאן בשום שיטה, וגם
# ההשוואה לשם לא הייתה תופסת אותה.)
return {"ok": False, "error": "duplicate_title"}
note["_id"] = getattr(res, "inserted_id", None)
return {"ok": True, "note": _as_note(note)}

Expand Down
6 changes: 5 additions & 1 deletion mcp_server/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,14 +402,17 @@ def create_board_note(
content: str,
color: str | None = None,
mode: str | None = None,
title: str | None = None,
) -> dict[str, Any]:
"""Attach a sticky note to a board (a surface that belongs to no file).

``mode`` בוחר בין ``surface`` (יושב על הלוח, ברירת המחדל) לבין
``screen`` (צף מול המסך). ``anchored`` אינו חוקי כאן — הוא דורש שורות
מקור, ובלוח אין כאלה; פתק כזה היה מחשב מיקום מול עוגן שאינו קיים.
"""
from sticky_notes_target import DEFAULT_BOARD_MODE, is_valid_board_mode, normalize_mode
from sticky_notes_target import (
DEFAULT_BOARD_MODE, is_valid_board_mode, normalize_mode, normalize_note_title,
)

bid = (board_id or "").strip()
if not _BOARD_ID_RE.match(bid):
Expand All @@ -435,6 +438,7 @@ def create_board_note(
content=clean,
color=color_s,
mode=normalize_mode(mode, DEFAULT_BOARD_MODE),
title=normalize_note_title(title),
)


Expand Down
6 changes: 4 additions & 2 deletions mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,8 +374,8 @@ def list_board_notes(ctx: Context, board_id: str) -> dict:
description=(
"Add a sticky note to a board (board_id from codekeeper_list_boards). Unlike "
"codekeeper_create_note this needs no file. mode is 'surface' (sits on the "
"board, default) or 'screen' (floats against the viewport). Requires write "
"permission."
"board, default) or 'screen' (floats against the viewport). An optional title "
"labels the note and must be unique on that board. Requires write permission."
),
annotations=_WRITE_TOOL,
)
Expand All @@ -385,6 +385,7 @@ def create_board_note(
content: str,
color: str | None = None,
mode: str | None = None,
title: str | None = None,
) -> dict:
require_write(ctx) # דחיית טוקן קריאה-בלבד לפני כל נגיעה בנתונים
return handlers.create_board_note(
Expand All @@ -394,6 +395,7 @@ def create_board_note(
content=content,
color=color,
mode=mode,
title=title,
)

@mcp.tool(
Expand Down
Loading
Loading