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
26 changes: 24 additions & 2 deletions database/collections_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ def _ensure_indexes(self) -> None:
self.collections.create_indexes([
IndexModel([("user_id", ASCENDING), ("slug", ASCENDING)], name="user_slug_unique", unique=True),
IndexModel([("user_id", ASCENDING), ("is_active", ASCENDING), ("updated_at", DESCENDING)], name="user_active_updated"),
# תומך בסינון הרשימה לפי ארכיון (is_archived) יחד עם מיון לפי updated_at
IndexModel([("user_id", ASCENDING), ("is_active", ASCENDING), ("is_archived", ASCENDING), ("updated_at", DESCENDING)], name="user_active_archived_updated"),
# חיפוש מהיר לפי שם (למשל "שולחן עבודה") תחת user_id + is_active
IndexModel([("user_id", ASCENDING), ("is_active", ASCENDING), ("name", ASCENDING)], name="user_active_name"),
IndexModel([("user_id", ASCENDING), ("sort_order", ASCENDING)], name="user_sort_order"),
Expand Down Expand Up @@ -239,6 +241,15 @@ def _ensure_indexes(self) -> None:
)
except Exception:
pass
# backfill — אוספים קיימים ללא is_archived יקבלו False (לניקיון וליעילות אינדקס;
# התקינות מובטחת גם בלעדיו בזכות $ne:True בשאילתת הרשימה)
try:
self.collections.update_many(
{"is_archived": {"$exists": False}},
{"$set": {"is_archived": False}},
)
except Exception:
pass

def ensure_default_collections(self, user_id: int) -> bool:
"""מאבטח יצירה של אוספים מובנים עבור משתמש חדש.
Expand Down Expand Up @@ -687,6 +698,7 @@ def create_collection(
"items_count": 0,
"pinned_count": 0,
"is_active": True,
"is_archived": False,
"created_at": _now(),
"updated_at": _now(),
}
Expand Down Expand Up @@ -745,6 +757,8 @@ def update_collection(self, user_id: int, collection_id: str, **fields: Any) ->
updates["color"] = self._normalize_color(fields.get("color"))
if "is_favorite" in fields:
updates["is_favorite"] = bool(fields.get("is_favorite"))
if "is_archived" in fields:
updates["is_archived"] = bool(fields.get("is_archived"))
if "sort_order" in fields and isinstance(fields.get("sort_order"), int):
updates["sort_order"] = int(fields.get("sort_order"))
if "mode" in fields:
Expand Down Expand Up @@ -791,14 +805,21 @@ def delete_collection(self, user_id: int, collection_id: str) -> Dict[str, Any]:
emit_event("collections_delete_error", severity="error", user_id=int(user_id), error=str(e))
return {"ok": False, "error": "שגיאה במחיקת האוסף"}

def list_collections(self, user_id: int, limit: int = 100, skip: int = 0) -> Dict[str, Any]:
def list_collections(self, user_id: int, limit: int = 100, skip: int = 0, *, archived_only: bool = False, include_archived: bool = False) -> Dict[str, Any]:
try:
eff_limit = max(1, min(int(limit or 100), 500))
eff_skip = max(0, int(skip or 0))
except Exception:
eff_limit, eff_skip = 100, 0

flt = {"user_id": user_id, "is_active": True}
# ברירת מחדל: רק אוספים פעילים שאינם בארכיון. תצוגת ארכיון: רק המאורכבים.
# include_archived=True (למשל גיבוי אישי): כל האוספים הפעילים, כולל בארכיון.
# ($ne:True מכסה גם אוספים ישנים שעדיין אין בהם את השדה is_archived)
flt: Dict[str, Any] = {"user_id": user_id, "is_active": True}
if archived_only:
flt["is_archived"] = True
elif not include_archived:
flt["is_archived"] = {"$ne": True}

try:
found = self.collections.find(flt)
Expand Down Expand Up @@ -1760,6 +1781,7 @@ def _public_collection(self, d: Dict[str, Any]) -> Dict[str, Any]:
"items_count": int(d.get("items_count") or 0),
"pinned_count": int(d.get("pinned_count") or 0),
"is_active": bool(d.get("is_active", True)),
"is_archived": bool(d.get("is_archived", False)),
"created_at": (d.get("created_at").isoformat() if isinstance(d.get("created_at"), datetime) else None),
"updated_at": (d.get("updated_at").isoformat() if isinstance(d.get("updated_at"), datetime) else None),
"share": {
Expand Down
6 changes: 4 additions & 2 deletions services/personal_backup_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,8 @@ def _export_collections(self, user_id: int) -> Dict[str, Any]:
if raw_db is None:
return {"collections": [], "items": []}
mgr = CollectionsManager(raw_db)
result = mgr.list_collections(user_id, limit=500)
# כולל אוספים בארכיון — הגיבוי חייב לשמר גם אותם
result = mgr.list_collections(user_id, limit=500, include_archived=True)
collections = result.get("collections", []) if result.get("ok") else []

all_items = []
Expand Down Expand Up @@ -920,7 +921,8 @@ def _restore_collections(
# טען אוספים קיימים לבדיקת כפילות לפי שם
existing_collections = {}
try:
existing_result = mgr.list_collections(user_id, limit=500)
# כולל אוספים בארכיון — למניעת כפילות שם גם מול אוסף מאורכב קיים
existing_result = mgr.list_collections(user_id, limit=500, include_archived=True)
if existing_result.get("ok"):
for ec in existing_result.get("collections", []):
name = (ec.get("name") or "").strip()
Expand Down
169 changes: 169 additions & 0 deletions tests/test_collections_archive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"""טסט לארכיון אוספים: ארכוב/שחזור דרך update_collection + סינון ב-list_collections.

מוודא ש:
- אוסף שאורכב (is_archived=True) נעלם מהתצוגה הרגילה ומופיע רק ב-archived_only=True.
- שחזור (is_archived=False) מחזיר אותו לתצוגה הרגילה.
- אוסף "ישן" ללא השדה is_archived עדיין מופיע בתצוגה הרגילה (בזכות $ne:True).

ה-fakes כאן תומכים ב-$ne/$or/$in/$exists/$set ו-find_one_and_update (כמו
FakeCollection ב-test_collections_manager_unit), כדי לתרגל את המסלול המלא.
"""

from database.collections_manager import CollectionsManager


def _match(doc, flt):
for k, v in flt.items():
if k == "$or":
if not any(_match(doc, c) for c in v):
return False
elif isinstance(v, dict) and "$in" in v:
if doc.get(k) not in v["$in"]:
return False
elif isinstance(v, dict) and "$ne" in v:
# $ne מתקיים גם כששדה חסר (מיוצג כ-None) — חשוב לאוספים ישנים
if doc.get(k) == v["$ne"]:
return False
elif isinstance(v, dict) and "$exists" in v:
if bool(v["$exists"]) != (k in doc):
return False
else:
if str(doc.get(k)) != str(v):
return False
return True


def _apply_set(doc, upd):
if "$set" in upd:
for k, val in upd["$set"].items():
doc[k] = val


class _Result:
def __init__(self, modified=0):
self.modified_count = modified
self.matched_count = modified
self.acknowledged = True


class _Coll:
def __init__(self):
self.docs = []

def create_indexes(self, *a, **k):
return None

def create_index(self, *a, **k):
return None

def insert_one(self, doc):
if not doc.get("_id"):
doc["_id"] = f"{len(self.docs) + 1:024x}"
self.docs.append(dict(doc))
return _Result(1)

def find(self, flt, projection=None):
return [dict(d) for d in self.docs if _match(d, flt)]

def find_one(self, flt, projection=None):
for d in self.docs:
if _match(d, flt):
return dict(d)
return None

def find_one_and_update(self, flt, upd, return_document=True):
for d in self.docs:
if _match(d, flt):
_apply_set(d, upd)
return dict(d)
return None

def update_many(self, flt, upd, **k):
n = 0
for d in self.docs:
if _match(d, flt):
_apply_set(d, upd)
n += 1
return _Result(n)

def count_documents(self, flt):
return sum(1 for d in self.docs if _match(d, flt))

def aggregate(self, *a, **k):
return []


class _DB:
def __init__(self):
self.user_collections = _Coll()
self.collection_items = _Coll()
self.code_snippets = _Coll()


def _names(res):
return sorted(c["name"] for c in res["collections"])


def test_archive_hides_from_default_and_shows_in_archive_view():
mgr = CollectionsManager(_DB())
uid = 7
a = mgr.create_collection(uid, "אוסף א")
b = mgr.create_collection(uid, "אוסף ב")
assert a["ok"] and b["ok"]
cid_a = a["collection"]["id"]

# לפני ארכוב: שניהם ברשימה הרגילה, הארכיון ריק
assert _names(mgr.list_collections(uid)) == ["אוסף א", "אוסף ב"]
assert mgr.list_collections(uid, archived_only=True)["collections"] == []

# ארכוב א' דרך update_collection (אותו מסלול כמו is_favorite)
upd = mgr.update_collection(uid, cid_a, is_archived=True)
assert upd["ok"] is True
assert upd["collection"]["is_archived"] is True

# אחרי ארכוב: רק ב' ברגיל, רק א' בארכיון
assert _names(mgr.list_collections(uid)) == ["אוסף ב"]
assert _names(mgr.list_collections(uid, archived_only=True)) == ["אוסף א"]


def test_unarchive_restores_to_default():
mgr = CollectionsManager(_DB())
uid = 9
c = mgr.create_collection(uid, "לשחזר")
cid = c["collection"]["id"]
mgr.update_collection(uid, cid, is_archived=True)
assert _names(mgr.list_collections(uid)) == []

upd = mgr.update_collection(uid, cid, is_archived=False)
assert upd["ok"] is True
assert upd["collection"]["is_archived"] is False
assert _names(mgr.list_collections(uid)) == ["לשחזר"]
assert mgr.list_collections(uid, archived_only=True)["collections"] == []


def test_include_archived_returns_both():
# include_archived=True (מסלול הגיבוי האישי): גם פעילים וגם מאורכבים
mgr = CollectionsManager(_DB())
uid = 11
mgr.create_collection(uid, "פעיל")
b = mgr.create_collection(uid, "מאורכב")
mgr.update_collection(uid, b["collection"]["id"], is_archived=True)
assert _names(mgr.list_collections(uid)) == ["פעיל"]
assert _names(mgr.list_collections(uid, include_archived=True)) == ["מאורכב", "פעיל"]


def test_legacy_collection_without_field_shows_in_default():
# אוסף "ישן" שנשמר לפני הוספת is_archived (השדה חסר בכוונה)
db = _DB()
db.user_collections.docs.append(
{
"_id": "0" * 24,
"user_id": 5,
"name": "ישן",
"slug": "yashan",
"is_active": True,
}
)
mgr = CollectionsManager(db)
assert _names(mgr.list_collections(5)) == ["ישן"]
assert mgr.list_collections(5, archived_only=True)["collections"] == []
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,7 @@ curl '/api/collections/<id>/items?page=1&per_page=20&include_computed=true'
- בעתיד: ניתן להרחיב לסוגי פריטים נוספים (למשל Saved searches עצמאיים).

## 🗒️ Changelog
- 2026-07-20: ארכיון לאוספים — שדה `is_archived` (נפרד מ־`is_active`), פרמטרים `archived_only`/`include_archived` ל־`list_collections`, toggle 🗄️ "הצג ארכיון" בסיידבר, וכפתור ארכב/שחזר בכותרת האוסף. אוספים בארכיון נשמרים בגיבוי האישי (`include_archived=True`). בנוסף: אייקון התיאור בכרטיס עבר לצד שם הקובץ (עם "קפיצה" לשורת הכפתורים כשהשם ארוך).
- 2025-10-23: מיזוג תוספות מהגיסט והפיכת המסמך לקאנוני: whitelist לאייקונים, פלטת צבעים, Feature flag ורול־אאוט, TTL לקאש, הוראות הטמעה תכל'ס, סכמות DB מעודכנות.

## 📎 נספח: גיסט רפרנס
Expand Down
45 changes: 25 additions & 20 deletions webapp/collections_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,32 +376,37 @@ def list_collections():
skip = int(request.args.get('skip') or 0)
except Exception:
return jsonify({'ok': False, 'error': 'Invalid limit/skip'}), 400
# תצוגת ארכיון: מציגה רק אוספים מאורכבים (?archived=1)
archived_only = str(request.args.get('archived') or '').strip().lower() in ('1', 'true', 'yes', 'on')
mgr = get_manager()
created_workspace = False
try:
created_workspace = mgr.ensure_default_collections(user_id)
except Exception:
created_workspace = False
result = mgr.list_collections(user_id, limit=limit, skip=skip)
# אם עדיין חסר אוסף "שולחן עבודה" – נסה ליצור ולשלוף מחדש (למשתמשים קיימים)
try:
collections = result.get('collections') if isinstance(result, dict) else None
except Exception:
collections = None
has_workspace = False
if isinstance(collections, list):
# לוגיקת ברירת-המחדל של "שולחן עבודה" רלוונטית רק לתצוגה הרגילה, לא לארכיון
if not archived_only:
try:
has_workspace = any((c or {}).get('name') == 'שולחן עבודה' for c in collections)
created_workspace = mgr.ensure_default_collections(user_id)
except Exception:
has_workspace = False
if not has_workspace:
created_workspace = False
result = mgr.list_collections(user_id, limit=limit, skip=skip, archived_only=archived_only)
# אם עדיין חסר אוסף "שולחן עבודה" – נסה ליצור ולשלוף מחדש (למשתמשים קיימים)
if not archived_only:
try:
if mgr.ensure_default_collections(user_id):
created_workspace = True
result = mgr.list_collections(user_id, limit=limit, skip=skip)
collections = result.get('collections') if isinstance(result, dict) else None
collections = result.get('collections') if isinstance(result, dict) else None
except Exception:
pass
collections = None
has_workspace = False
if isinstance(collections, list):
try:
has_workspace = any((c or {}).get('name') == 'שולחן עבודה' for c in collections)
except Exception:
has_workspace = False
if not has_workspace:
try:
if mgr.ensure_default_collections(user_id):
created_workspace = True
result = mgr.list_collections(user_id, limit=limit, skip=skip, archived_only=archived_only)
collections = result.get('collections') if isinstance(result, dict) else None
except Exception:
pass
if result.get('ok'):
collections = result.get('collections') or []
for col in collections:
Expand Down
23 changes: 22 additions & 1 deletion webapp/static/css/collections.css
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,9 @@
.collections-sidebar{background:var(--glass);border:1px solid var(--glass-border);border-radius:14px;padding:0.75rem;backdrop-filter:blur(20px)}
.sidebar-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:.5rem}
.sidebar-header .title{font-weight:700}
.sidebar-header__actions{display:flex;align-items:center;gap:.35rem}
/* כפתור "הצג ארכיון" במצב פעיל (aria-pressed) — מודגש כדי לסמן שאנחנו בתצוגת ארכיון */
#toggleArchivedBtn[aria-pressed="true"],#toggleArchivedBtn.is-active{background:rgba(255,255,255,.2);border-color:rgba(255,255,255,.45);box-shadow:0 0 0 1px rgba(255,255,255,.1) inset}
.sidebar-search{margin:.5rem 0}
.sidebar-search input{width:100%;padding:.5rem .6rem;border-radius:8px;border:1px solid var(--glass-border);background:rgba(255,255,255,.1);color:var(--text-primary)}
.sidebar-list{display:flex;flex-direction:column;gap:.25rem;max-height:60vh;overflow:auto;scrollbar-width:thin}
Expand Down Expand Up @@ -644,13 +647,22 @@
flex-direction:column;
gap:.15rem
}
.collection-card__title-row{
display:flex;
align-items:center;
gap:.4rem;
min-width:0;
flex-wrap:nowrap
}
.collection-card__name{
font-weight:600;
color:var(--collections-dark-text);
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
max-width:100%
max-width:100%;
flex:0 1 auto;
min-width:0
}
.collection-card__name.is-wrapped{
white-space:normal;
Expand Down Expand Up @@ -689,9 +701,18 @@
line-height:1;
cursor:pointer;
opacity:.75;
flex:0 0 auto;
transition:opacity .2s,transform .2s
}
.desc-info:hover,.desc-info:focus-visible{opacity:1;transform:scale(1.1)}
/* Slot 2: כשאין מקום ליד השם, האייקון עובר לשורת הכפתורים — משמאל להם, רווח ~2 תווים.
שומר על מראה עדין (שקוף) גם כאן, בניגוד לכפתורי הפעולה הממוסגרים. */
.collection-card__actions .desc-info{
background:transparent;
border:0;
margin-inline-start:.5rem;
align-self:center
}
.collection-card__actions{
display:flex;
gap:.35rem;
Expand Down
Loading
Loading