Tools: DotGhostBoard v1.3.0 "Wraith" โ€” Tags, Collections, Multi-Select & Export ๐Ÿ‘ป (2026)

Tools: DotGhostBoard v1.3.0 "Wraith" โ€” Tags, Collections, Multi-Select & Export ๐Ÿ‘ป (2026)

โฌ‡๏ธ Download DotGhostBoard v1.3.0 "Wraith"

What Is DotGhostBoard?

v1.3.0 "Wraith" โ€” What's New at a Glance

W001 โ€” Tag System: Database Schema & CRUD

The Design Decision: Comma-Separated vs. Relational

Migration (Zero Downtime)

Core Tag CRUD

The Four-Pattern LIKE Query

W002 โ€” Tag Input Widget on Item Cards

TagChip โ€” Color Palette by Rotation

Tag Update Flow (Signal Chain)

W003 โ€” Combined Text + Tag Search

W004 โ€” Collections: Database Schema & CRUD

W005 โ€” Collections Sidebar Panel

W006 โ€” Multi-Select Cards (Ctrl+Click & Shift+Click)

W007 โ€” Bulk Actions Toolbar

W008 โ€” Export to .txt / .json

W009 โ€” Tag Autocomplete & Global Tag Manager

Autocomplete

Global Tag Manager

W010 โ€” Unit Tests (20+ new tests)

Key Lessons from Wraith

What's Next โ€” v1.4.0 Ideas

Project Links A deep dive into every feature shipped in the Wraith release of my open-source PyQt6 clipboard manager โ€” with real code, design decisions, and lessons learned. DotGhostBoard is a local-first clipboard manager built with Python + PyQt6. It watches your clipboard in the background, stores every copied text/image/video in a local SQLite database, and surfaces everything in a dark-neon dashboard โ€” with pinning, drag-and-drop reordering, full-text search, and now (v1.3.0) a complete tagging + collections system. All 10 tasks shipped. Completion: 100% โœ… I chose to store tags as a comma-separated TEXT column (#tag1,#tag2) rather than a separate join table. For a local clipboard manager with hundreds (not millions) of items, this avoids schema complexity while still being fully searchable with LIKE patterns. The migration wraps every ALTER TABLE in a try/except โ€” so existing databases upgrade silently: Tags are normalized to lowercase and always prefixed with #. The add_tag function is idempotent โ€” calling it twice never creates duplicates: The trickiest part: querying a comma-separated column. A tag can sit at four positions โ€” exact match, start, middle, or end: โš ๏ธ Pitfall: A naive WHERE tags LIKE '%#py%' would match #python when searching for #py. The four explicit patterns prevent this false-positive completely. Each card now has a TagInputRow at the bottom: existing tags render as colored chips, and an inline QLineEdit lets you type a new tag and press Enter. Every tag gets a consistent background/foreground color pair. The class rotates through 6 palettes and memoizes the assignment per tag string: The card never touches the DB directly. It emits a signal, the Dashboard writes to DB, then calls back to update the UI. This keeps the widget layer clean: The search bar now understands a mixed query like "python #code" โ€” text on the left, tag filter on the right: The storage layer's search_items was extended to accept tag_filter: Collections are a proper relational table. Each clipboard_item gets a nullable collection_id FK. NULL = "Uncategorized". get_collections() returns item counts via a LEFT JOIN โ€” handy for displaying "๐Ÿ“ Work (12)" in the sidebar: A QListWidget on the left side of the dashboard shows all collections. It supports: The drag-to-collection handler decodes the card's MIME data and calls storage.move_to_collection(): The Dashboard tracks a _selected_ids: set[int]. The card emits sig_clicked(item_id, modifiers) and the Dashboard interprets the modifier keys: The card's visual selection state is toggled via Qt properties + stylesheet: When len(selected_ids) >= 2, a floating bar slides up from the bottom of the window: Bulk delete always shows a confirmation dialog and skips pinned items: Bulk tag iterates every selected card and calls storage.add_tag(), then updates each card's chip row: Export supports two formats. The storage layer handles serialization; the Dashboard handles the file-save dialog: Example .json output: The TagInputRow builds a QCompleter from storage.get_all_tags() every time a new tag is added, keeping suggestions fresh: A dedicated TagManagerDialog (accessible from โš™ Settings โ†’ Manage Tags) lets you rename or delete a tag globally across all items in one click. rename_tag fetches every item carrying the old tag and rewrites its tag list: All v1.3.0 features are covered by tests/test_storage_v130.py. The test file redirects storage to a temp database so it never touches your real data: Some notable edge-case tests: 1. Comma-separated tags in SQLite are fine at this scale.

The four-pattern LIKE query is a bit verbose but perfectly readable and fast for local data. I'd switch to a join table if items ever exceeded ~50k. 2. Always normalize early.Tag normalization (strip().lower(), add #) happens at the storage layer, not the UI. This means every input path โ€” bulk tag, single card, search bar, tag manager โ€” produces consistent results automatically. 3. Signal chains keep UI and storage decoupled.The "emit up, write, callback down" pattern for tags means the widget layer has zero DB knowledge. Swapping SQLite for a different backend would only require touching storage.py. 4. Idempotent migrations are a gift to your future self.

Wrapping every ALTER TABLE in try/except means users upgrading from v1.0, v1.1, or v1.2 all get a seamless first launch with no migration scripts to run. Thanks for reading! If you found this breakdown useful, drop a โค๏ธ or share your own clipboard manager stories in the comments. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

$ def init_db(): with _db() as conn: # Main table already existed from v1.0 conn.execute(""" CREATE TABLE IF NOT EXISTS clipboard_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, content TEXT NOT NULL, preview TEXT DEFAULT NULL, is_pinned INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) """) # v1.3.0: add tags column to existing DBs try: conn.execute("ALTER TABLE clipboard_items ADD COLUMN tags TEXT DEFAULT ''") except Exception: pass # column already exists โ€” safe to ignore # v1.3.0: collections table conn.execute(""" CREATE TABLE IF NOT EXISTS collections ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) """) try: conn.execute( "ALTER TABLE clipboard_items ADD COLUMN " "collection_id INTEGER DEFAULT NULL REFERENCES collections(id)" ) except Exception: pass def init_db(): with _db() as conn: # Main table already existed from v1.0 conn.execute(""" CREATE TABLE IF NOT EXISTS clipboard_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, content TEXT NOT NULL, preview TEXT DEFAULT NULL, is_pinned INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) """) # v1.3.0: add tags column to existing DBs try: conn.execute("ALTER TABLE clipboard_items ADD COLUMN tags TEXT DEFAULT ''") except Exception: pass # column already exists โ€” safe to ignore # v1.3.0: collections table conn.execute(""" CREATE TABLE IF NOT EXISTS collections ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) """) try: conn.execute( "ALTER TABLE clipboard_items ADD COLUMN " "collection_id INTEGER DEFAULT NULL REFERENCES collections(id)" ) except Exception: pass def init_db(): with _db() as conn: # Main table already existed from v1.0 conn.execute(""" CREATE TABLE IF NOT EXISTS clipboard_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, content TEXT NOT NULL, preview TEXT DEFAULT NULL, is_pinned INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) """) # v1.3.0: add tags column to existing DBs try: conn.execute("ALTER TABLE clipboard_items ADD COLUMN tags TEXT DEFAULT ''") except Exception: pass # column already exists โ€” safe to ignore # v1.3.0: collections table conn.execute(""" CREATE TABLE IF NOT EXISTS collections ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) """) try: conn.execute( "ALTER TABLE clipboard_items ADD COLUMN " "collection_id INTEGER DEFAULT NULL REFERENCES collections(id)" ) except Exception: pass def _parse_tags(raw: str) -> list[str]: return [t.strip() for t in raw.split(",") if t.strip()] def _serialize_tags(tags: list[str]) -> str: return ",".join(tags) def add_tag(item_id: int, tag: str) -> list[str]: tag = tag.strip().lower() if not tag.startswith("#"): tag = f"#{tag}" current = get_tags(item_id) if tag in current: return current # already there โ€” nothing to do updated = current + [tag] now = datetime.now().isoformat() with _db() as conn: conn.execute( "UPDATE clipboard_items SET tags = ?, updated_at = ? WHERE id = ?", (_serialize_tags(updated), now, item_id), ) return updated def remove_tag(item_id: int, tag: str) -> list[str]: tag = tag.strip().lower() if not tag.startswith("#"): tag = f"#{tag}" current = get_tags(item_id) if tag not in current: return current updated = [t for t in current if t != tag] now = datetime.now().isoformat() with _db() as conn: conn.execute( "UPDATE clipboard_items SET tags = ?, updated_at = ? WHERE id = ?", (_serialize_tags(updated), now, item_id), ) return updated def _parse_tags(raw: str) -> list[str]: return [t.strip() for t in raw.split(",") if t.strip()] def _serialize_tags(tags: list[str]) -> str: return ",".join(tags) def add_tag(item_id: int, tag: str) -> list[str]: tag = tag.strip().lower() if not tag.startswith("#"): tag = f"#{tag}" current = get_tags(item_id) if tag in current: return current # already there โ€” nothing to do updated = current + [tag] now = datetime.now().isoformat() with _db() as conn: conn.execute( "UPDATE clipboard_items SET tags = ?, updated_at = ? WHERE id = ?", (_serialize_tags(updated), now, item_id), ) return updated def remove_tag(item_id: int, tag: str) -> list[str]: tag = tag.strip().lower() if not tag.startswith("#"): tag = f"#{tag}" current = get_tags(item_id) if tag not in current: return current updated = [t for t in current if t != tag] now = datetime.now().isoformat() with _db() as conn: conn.execute( "UPDATE clipboard_items SET tags = ?, updated_at = ? WHERE id = ?", (_serialize_tags(updated), now, item_id), ) return updated def _parse_tags(raw: str) -> list[str]: return [t.strip() for t in raw.split(",") if t.strip()] def _serialize_tags(tags: list[str]) -> str: return ",".join(tags) def add_tag(item_id: int, tag: str) -> list[str]: tag = tag.strip().lower() if not tag.startswith("#"): tag = f"#{tag}" current = get_tags(item_id) if tag in current: return current # already there โ€” nothing to do updated = current + [tag] now = datetime.now().isoformat() with _db() as conn: conn.execute( "UPDATE clipboard_items SET tags = ?, updated_at = ? WHERE id = ?", (_serialize_tags(updated), now, item_id), ) return updated def remove_tag(item_id: int, tag: str) -> list[str]: tag = tag.strip().lower() if not tag.startswith("#"): tag = f"#{tag}" current = get_tags(item_id) if tag not in current: return current updated = [t for t in current if t != tag] now = datetime.now().isoformat() with _db() as conn: conn.execute( "UPDATE clipboard_items SET tags = ?, updated_at = ? WHERE id = ?", (_serialize_tags(updated), now, item_id), ) return updated def get_items_by_tag(tag: str) -> list[dict]: tag = tag.strip().lower() if not tag.startswith("#"): tag = f"#{tag}" with _db() as conn: cursor = conn.execute( """ SELECT * FROM clipboard_items WHERE tags = ? -- exact: only tag OR tags LIKE ? -- -weight: 500;">start: "#tag,..." OR tags LIKE ? -- middle: "...#tag,..." OR tags LIKE ? -- end: "...#tag" ORDER BY is_pinned DESC, created_at DESC """, (tag, f"{tag},%", f"%,{tag},%", f"%,{tag}"), ) return [dict(row) for row in cursor.fetchall()] def get_items_by_tag(tag: str) -> list[dict]: tag = tag.strip().lower() if not tag.startswith("#"): tag = f"#{tag}" with _db() as conn: cursor = conn.execute( """ SELECT * FROM clipboard_items WHERE tags = ? -- exact: only tag OR tags LIKE ? -- -weight: 500;">start: "#tag,..." OR tags LIKE ? -- middle: "...#tag,..." OR tags LIKE ? -- end: "...#tag" ORDER BY is_pinned DESC, created_at DESC """, (tag, f"{tag},%", f"%,{tag},%", f"%,{tag}"), ) return [dict(row) for row in cursor.fetchall()] def get_items_by_tag(tag: str) -> list[dict]: tag = tag.strip().lower() if not tag.startswith("#"): tag = f"#{tag}" with _db() as conn: cursor = conn.execute( """ SELECT * FROM clipboard_items WHERE tags = ? -- exact: only tag OR tags LIKE ? -- -weight: 500;">start: "#tag,..." OR tags LIKE ? -- middle: "...#tag,..." OR tags LIKE ? -- end: "...#tag" ORDER BY is_pinned DESC, created_at DESC """, (tag, f"{tag},%", f"%,{tag},%", f"%,{tag}"), ) return [dict(row) for row in cursor.fetchall()] class TagChip(QFrame): _COLORS = [ ("#1e3a5f", "#4a9eff"), # blue ("#1e3d2f", "#4adf8a"), # green ("#3d1e3f", "#cc66ff"), # purple ("#3d2e1e", "#ffaa44"), # amber ("#3d1e1e", "#ff6666"), # red ("#1e3d3d", "#44ddcc"), # teal ] _color_map: dict[str, tuple] = {} _color_idx: int = 0 @classmethod def _color_for(cls, tag: str) -> tuple[str, str]: if tag not in cls._color_map: palette = cls._COLORS[cls._color_idx % len(cls._COLORS)] cls._color_map[tag] = palette cls._color_idx += 1 return cls._color_map[tag] class TagChip(QFrame): _COLORS = [ ("#1e3a5f", "#4a9eff"), # blue ("#1e3d2f", "#4adf8a"), # green ("#3d1e3f", "#cc66ff"), # purple ("#3d2e1e", "#ffaa44"), # amber ("#3d1e1e", "#ff6666"), # red ("#1e3d3d", "#44ddcc"), # teal ] _color_map: dict[str, tuple] = {} _color_idx: int = 0 @classmethod def _color_for(cls, tag: str) -> tuple[str, str]: if tag not in cls._color_map: palette = cls._COLORS[cls._color_idx % len(cls._COLORS)] cls._color_map[tag] = palette cls._color_idx += 1 return cls._color_map[tag] class TagChip(QFrame): _COLORS = [ ("#1e3a5f", "#4a9eff"), # blue ("#1e3d2f", "#4adf8a"), # green ("#3d1e3f", "#cc66ff"), # purple ("#3d2e1e", "#ffaa44"), # amber ("#3d1e1e", "#ff6666"), # red ("#1e3d3d", "#44ddcc"), # teal ] _color_map: dict[str, tuple] = {} _color_idx: int = 0 @classmethod def _color_for(cls, tag: str) -> tuple[str, str]: if tag not in cls._color_map: palette = cls._COLORS[cls._color_idx % len(cls._COLORS)] cls._color_map[tag] = palette cls._color_idx += 1 return cls._color_map[tag] TagInputRow.sig_tag_added โ†’ ItemCard.sig_tag_added (item_id, tag) โ†’ Dashboard._on_tag_added() โ†’ storage.add_tag() โ† DB write โ†’ card.on_tag_added(tag) โ† UI confirm TagInputRow.sig_tag_added โ†’ ItemCard.sig_tag_added (item_id, tag) โ†’ Dashboard._on_tag_added() โ†’ storage.add_tag() โ† DB write โ†’ card.on_tag_added(tag) โ† UI confirm TagInputRow.sig_tag_added โ†’ ItemCard.sig_tag_added (item_id, tag) โ†’ Dashboard._on_tag_added() โ†’ storage.add_tag() โ† DB write โ†’ card.on_tag_added(tag) โ† UI confirm # In ItemCard.__init__: self._tag_row.sig_tag_added.connect( lambda tag: self.sig_tag_added.emit(self.item_id, tag) ) # In Dashboard: def _on_tag_added(self, item_id: int, tag: str): updated = storage.add_tag(item_id, tag) card = self._cards.get(item_id) if card and tag in updated: card.on_tag_added(tag) self.statusBar().showMessage(f"Tag added: {tag}") # In ItemCard.__init__: self._tag_row.sig_tag_added.connect( lambda tag: self.sig_tag_added.emit(self.item_id, tag) ) # In Dashboard: def _on_tag_added(self, item_id: int, tag: str): updated = storage.add_tag(item_id, tag) card = self._cards.get(item_id) if card and tag in updated: card.on_tag_added(tag) self.statusBar().showMessage(f"Tag added: {tag}") # In ItemCard.__init__: self._tag_row.sig_tag_added.connect( lambda tag: self.sig_tag_added.emit(self.item_id, tag) ) # In Dashboard: def _on_tag_added(self, item_id: int, tag: str): updated = storage.add_tag(item_id, tag) card = self._cards.get(item_id) if card and tag in updated: card.on_tag_added(tag) self.statusBar().showMessage(f"Tag added: {tag}") def _on_search(self, query: str): raw = query.strip() if not raw: for card in self._cards.values(): card.setVisible(True) return tokens = raw.split() tag_tokens = [t for t in tokens if t.startswith("#")] text_tokens = [t for t in tokens if not t.startswith("#")] text_query = " ".join(text_tokens) tag_filter = tag_tokens[0] if tag_tokens else None if tag_filter and not text_query: # Tag-only: works on all item types (images, video, text) result_ids = {r["id"] for r in storage.get_items_by_tag(tag_filter)} else: # Text search with optional tag filter result_ids = { r["id"] for r in storage.search_items(text_query, tag_filter) } for item_id, card in self._cards.items(): card.setVisible(item_id in result_ids) def _on_search(self, query: str): raw = query.strip() if not raw: for card in self._cards.values(): card.setVisible(True) return tokens = raw.split() tag_tokens = [t for t in tokens if t.startswith("#")] text_tokens = [t for t in tokens if not t.startswith("#")] text_query = " ".join(text_tokens) tag_filter = tag_tokens[0] if tag_tokens else None if tag_filter and not text_query: # Tag-only: works on all item types (images, video, text) result_ids = {r["id"] for r in storage.get_items_by_tag(tag_filter)} else: # Text search with optional tag filter result_ids = { r["id"] for r in storage.search_items(text_query, tag_filter) } for item_id, card in self._cards.items(): card.setVisible(item_id in result_ids) def _on_search(self, query: str): raw = query.strip() if not raw: for card in self._cards.values(): card.setVisible(True) return tokens = raw.split() tag_tokens = [t for t in tokens if t.startswith("#")] text_tokens = [t for t in tokens if not t.startswith("#")] text_query = " ".join(text_tokens) tag_filter = tag_tokens[0] if tag_tokens else None if tag_filter and not text_query: # Tag-only: works on all item types (images, video, text) result_ids = {r["id"] for r in storage.get_items_by_tag(tag_filter)} else: # Text search with optional tag filter result_ids = { r["id"] for r in storage.search_items(text_query, tag_filter) } for item_id, card in self._cards.items(): card.setVisible(item_id in result_ids) def search_items(query: str, tag_filter: str | None = None) -> list[dict]: if tag_filter: tag = tag_filter.strip().lower() if not tag.startswith("#"): tag = f"#{tag}" sql = """ SELECT * FROM clipboard_items WHERE type = 'text' AND content LIKE :query AND ( tags = :tag OR tags LIKE :tag_start OR tags LIKE :tag_mid OR tags LIKE :tag_end ) ORDER BY is_pinned DESC, created_at DESC """ params = { "query": f"%{query}%", "tag": tag, "tag_start": f"{tag},%", "tag_mid": f"%,{tag},%", "tag_end": f"%,{tag}", } with _db() as conn: cursor = conn.execute(sql, params) return [dict(row) for row in cursor.fetchall()] # Simple path โ€” no tag filter with _db() as conn: cursor = conn.execute(""" SELECT * FROM clipboard_items WHERE type = 'text' AND content LIKE ? ORDER BY is_pinned DESC, created_at DESC """, (f"%{query}%",)) return [dict(row) for row in cursor.fetchall()] def search_items(query: str, tag_filter: str | None = None) -> list[dict]: if tag_filter: tag = tag_filter.strip().lower() if not tag.startswith("#"): tag = f"#{tag}" sql = """ SELECT * FROM clipboard_items WHERE type = 'text' AND content LIKE :query AND ( tags = :tag OR tags LIKE :tag_start OR tags LIKE :tag_mid OR tags LIKE :tag_end ) ORDER BY is_pinned DESC, created_at DESC """ params = { "query": f"%{query}%", "tag": tag, "tag_start": f"{tag},%", "tag_mid": f"%,{tag},%", "tag_end": f"%,{tag}", } with _db() as conn: cursor = conn.execute(sql, params) return [dict(row) for row in cursor.fetchall()] # Simple path โ€” no tag filter with _db() as conn: cursor = conn.execute(""" SELECT * FROM clipboard_items WHERE type = 'text' AND content LIKE ? ORDER BY is_pinned DESC, created_at DESC """, (f"%{query}%",)) return [dict(row) for row in cursor.fetchall()] def search_items(query: str, tag_filter: str | None = None) -> list[dict]: if tag_filter: tag = tag_filter.strip().lower() if not tag.startswith("#"): tag = f"#{tag}" sql = """ SELECT * FROM clipboard_items WHERE type = 'text' AND content LIKE :query AND ( tags = :tag OR tags LIKE :tag_start OR tags LIKE :tag_mid OR tags LIKE :tag_end ) ORDER BY is_pinned DESC, created_at DESC """ params = { "query": f"%{query}%", "tag": tag, "tag_start": f"{tag},%", "tag_mid": f"%,{tag},%", "tag_end": f"%,{tag}", } with _db() as conn: cursor = conn.execute(sql, params) return [dict(row) for row in cursor.fetchall()] # Simple path โ€” no tag filter with _db() as conn: cursor = conn.execute(""" SELECT * FROM clipboard_items WHERE type = 'text' AND content LIKE ? ORDER BY is_pinned DESC, created_at DESC """, (f"%{query}%",)) return [dict(row) for row in cursor.fetchall()] def create_collection(name: str) -> int: name = name.strip() if not name: raise ValueError("Collection name cannot be empty") now = datetime.now().isoformat() with _db() as conn: cursor = conn.execute( "INSERT OR IGNORE INTO collections (name, created_at, updated_at) VALUES (?, ?, ?)", (name, now, now), ) if cursor.lastrowid: return cursor.lastrowid # Already exists โ€” return existing ID row = conn.execute( "SELECT id FROM collections WHERE name = ?", (name,) ).fetchone() return row["id"] if row else 0 def delete_collection(collection_id: int) -> bool: with _db() as conn: # Unlink items first โ€” they become "Uncategorized" conn.execute( "UPDATE clipboard_items SET collection_id = NULL WHERE collection_id = ?", (collection_id,), ) conn.execute("DELETE FROM collections WHERE id = ?", (collection_id,)) return True def create_collection(name: str) -> int: name = name.strip() if not name: raise ValueError("Collection name cannot be empty") now = datetime.now().isoformat() with _db() as conn: cursor = conn.execute( "INSERT OR IGNORE INTO collections (name, created_at, updated_at) VALUES (?, ?, ?)", (name, now, now), ) if cursor.lastrowid: return cursor.lastrowid # Already exists โ€” return existing ID row = conn.execute( "SELECT id FROM collections WHERE name = ?", (name,) ).fetchone() return row["id"] if row else 0 def delete_collection(collection_id: int) -> bool: with _db() as conn: # Unlink items first โ€” they become "Uncategorized" conn.execute( "UPDATE clipboard_items SET collection_id = NULL WHERE collection_id = ?", (collection_id,), ) conn.execute("DELETE FROM collections WHERE id = ?", (collection_id,)) return True def create_collection(name: str) -> int: name = name.strip() if not name: raise ValueError("Collection name cannot be empty") now = datetime.now().isoformat() with _db() as conn: cursor = conn.execute( "INSERT OR IGNORE INTO collections (name, created_at, updated_at) VALUES (?, ?, ?)", (name, now, now), ) if cursor.lastrowid: return cursor.lastrowid # Already exists โ€” return existing ID row = conn.execute( "SELECT id FROM collections WHERE name = ?", (name,) ).fetchone() return row["id"] if row else 0 def delete_collection(collection_id: int) -> bool: with _db() as conn: # Unlink items first โ€” they become "Uncategorized" conn.execute( "UPDATE clipboard_items SET collection_id = NULL WHERE collection_id = ?", (collection_id,), ) conn.execute("DELETE FROM collections WHERE id = ?", (collection_id,)) return True def get_collections() -> list[dict]: with _db() as conn: rows = conn.execute(""" SELECT c.id, c.name, c.created_at, COUNT(ci.id) AS item_count FROM collections c LEFT JOIN clipboard_items ci ON ci.collection_id = c.id GROUP BY c.id ORDER BY c.name """).fetchall() return [dict(r) for r in rows] def get_collections() -> list[dict]: with _db() as conn: rows = conn.execute(""" SELECT c.id, c.name, c.created_at, COUNT(ci.id) AS item_count FROM collections c LEFT JOIN clipboard_items ci ON ci.collection_id = c.id GROUP BY c.id ORDER BY c.name """).fetchall() return [dict(r) for r in rows] def get_collections() -> list[dict]: with _db() as conn: rows = conn.execute(""" SELECT c.id, c.name, c.created_at, COUNT(ci.id) AS item_count FROM collections c LEFT JOIN clipboard_items ci ON ci.collection_id = c.id GROUP BY c.id ORDER BY c.name """).fetchall() return [dict(r) for r in rows] def _refresh_sidebar(self): self.collections_list.blockSignals(True) self.collections_list.clear() all_item = QListWidgetItem("โ– All Items") all_item.setData(Qt.ItemDataRole.UserRole, None) self.collections_list.addItem(all_item) for c in storage.get_collections(): item = QListWidgetItem(f"๐Ÿ“ {c['name']} ({c['item_count']})") item.setData(Qt.ItemDataRole.UserRole, c['id']) self.collections_list.addItem(item) if not self.collections_list.selectedItems(): self.collections_list.item(0).setSelected(True) self.collections_list.blockSignals(False) def _refresh_sidebar(self): self.collections_list.blockSignals(True) self.collections_list.clear() all_item = QListWidgetItem("โ– All Items") all_item.setData(Qt.ItemDataRole.UserRole, None) self.collections_list.addItem(all_item) for c in storage.get_collections(): item = QListWidgetItem(f"๐Ÿ“ {c['name']} ({c['item_count']})") item.setData(Qt.ItemDataRole.UserRole, c['id']) self.collections_list.addItem(item) if not self.collections_list.selectedItems(): self.collections_list.item(0).setSelected(True) self.collections_list.blockSignals(False) def _refresh_sidebar(self): self.collections_list.blockSignals(True) self.collections_list.clear() all_item = QListWidgetItem("โ– All Items") all_item.setData(Qt.ItemDataRole.UserRole, None) self.collections_list.addItem(all_item) for c in storage.get_collections(): item = QListWidgetItem(f"๐Ÿ“ {c['name']} ({c['item_count']})") item.setData(Qt.ItemDataRole.UserRole, c['id']) self.collections_list.addItem(item) if not self.collections_list.selectedItems(): self.collections_list.item(0).setSelected(True) self.collections_list.blockSignals(False) def _sidebar_drop_event(self, event): if not event.mimeData().hasFormat("application/x-dotghost-card-id"): return dragged_id = int( event.mimeData().data("application/x-dotghost-card-id").data().decode() ) item = self.collections_list.itemAt(event.position().toPoint()) if not item: return target_coll_id = item.data(Qt.ItemDataRole.UserRole) storage.move_to_collection(dragged_id, target_coll_id) if target_coll_id != self.active_collection_id: self._remove_card(dragged_id) # -weight: 500;">remove from current view self._refresh_sidebar() self.statusBar().showMessage("Card moved to collection โœ“") event.acceptProposedAction() def _sidebar_drop_event(self, event): if not event.mimeData().hasFormat("application/x-dotghost-card-id"): return dragged_id = int( event.mimeData().data("application/x-dotghost-card-id").data().decode() ) item = self.collections_list.itemAt(event.position().toPoint()) if not item: return target_coll_id = item.data(Qt.ItemDataRole.UserRole) storage.move_to_collection(dragged_id, target_coll_id) if target_coll_id != self.active_collection_id: self._remove_card(dragged_id) # -weight: 500;">remove from current view self._refresh_sidebar() self.statusBar().showMessage("Card moved to collection โœ“") event.acceptProposedAction() def _sidebar_drop_event(self, event): if not event.mimeData().hasFormat("application/x-dotghost-card-id"): return dragged_id = int( event.mimeData().data("application/x-dotghost-card-id").data().decode() ) item = self.collections_list.itemAt(event.position().toPoint()) if not item: return target_coll_id = item.data(Qt.ItemDataRole.UserRole) storage.move_to_collection(dragged_id, target_coll_id) if target_coll_id != self.active_collection_id: self._remove_card(dragged_id) # -weight: 500;">remove from current view self._refresh_sidebar() self.statusBar().showMessage("Card moved to collection โœ“") event.acceptProposedAction() def _on_card_clicked(self, item_id: int, modifiers): cards = self._visible_cards() card_ids = [c.item_id for c in cards] if modifiers & Qt.KeyboardModifier.ShiftModifier and self._last_clicked_id in card_ids: # Select a range a = card_ids.index(self._last_clicked_id) b = card_ids.index(item_id) lo, hi = min(a, b), max(a, b) for iid in card_ids[lo : hi + 1]: self._selected_ids.add(iid) self._cards[iid].set_selected(True) elif modifiers & Qt.KeyboardModifier.ControlModifier: # Toggle single card if item_id in self._selected_ids: self._selected_ids.discard(item_id) self._cards[item_id].set_selected(False) else: self._selected_ids.add(item_id) self._cards[item_id].set_selected(True) self._last_clicked_id = item_id else: # Plain click โ€” clear everything self._clear_selection() self._last_clicked_id = item_id self._update_bulk_bar() def _on_card_clicked(self, item_id: int, modifiers): cards = self._visible_cards() card_ids = [c.item_id for c in cards] if modifiers & Qt.KeyboardModifier.ShiftModifier and self._last_clicked_id in card_ids: # Select a range a = card_ids.index(self._last_clicked_id) b = card_ids.index(item_id) lo, hi = min(a, b), max(a, b) for iid in card_ids[lo : hi + 1]: self._selected_ids.add(iid) self._cards[iid].set_selected(True) elif modifiers & Qt.KeyboardModifier.ControlModifier: # Toggle single card if item_id in self._selected_ids: self._selected_ids.discard(item_id) self._cards[item_id].set_selected(False) else: self._selected_ids.add(item_id) self._cards[item_id].set_selected(True) self._last_clicked_id = item_id else: # Plain click โ€” clear everything self._clear_selection() self._last_clicked_id = item_id self._update_bulk_bar() def _on_card_clicked(self, item_id: int, modifiers): cards = self._visible_cards() card_ids = [c.item_id for c in cards] if modifiers & Qt.KeyboardModifier.ShiftModifier and self._last_clicked_id in card_ids: # Select a range a = card_ids.index(self._last_clicked_id) b = card_ids.index(item_id) lo, hi = min(a, b), max(a, b) for iid in card_ids[lo : hi + 1]: self._selected_ids.add(iid) self._cards[iid].set_selected(True) elif modifiers & Qt.KeyboardModifier.ControlModifier: # Toggle single card if item_id in self._selected_ids: self._selected_ids.discard(item_id) self._cards[item_id].set_selected(False) else: self._selected_ids.add(item_id) self._cards[item_id].set_selected(True) self._last_clicked_id = item_id else: # Plain click โ€” clear everything self._clear_selection() self._last_clicked_id = item_id self._update_bulk_bar() def set_selected(self, selected: bool): self.setProperty("selected", str(selected).lower()) self.style().unpolish(self) self.style().polish(self) if selected: self._check_overlay.show() # neon green โœ“ badge else: self._check_overlay.hide() def set_selected(self, selected: bool): self.setProperty("selected", str(selected).lower()) self.style().unpolish(self) self.style().polish(self) if selected: self._check_overlay.show() # neon green โœ“ badge else: self._check_overlay.hide() def set_selected(self, selected: bool): self.setProperty("selected", str(selected).lower()) self.style().unpolish(self) self.style().polish(self) if selected: self._check_overlay.show() # neon green โœ“ badge else: self._check_overlay.hide() [ 3 selected ] [ ๐Ÿ“ Pin All ] [ ๐Ÿ“Œ Unpin All ] [ ๐Ÿท Add Tag ] [ ๐Ÿ“ค Export ] [ โœ• Delete All ] [ โœ• Cancel ] [ 3 selected ] [ ๐Ÿ“ Pin All ] [ ๐Ÿ“Œ Unpin All ] [ ๐Ÿท Add Tag ] [ ๐Ÿ“ค Export ] [ โœ• Delete All ] [ โœ• Cancel ] [ 3 selected ] [ ๐Ÿ“ Pin All ] [ ๐Ÿ“Œ Unpin All ] [ ๐Ÿท Add Tag ] [ ๐Ÿ“ค Export ] [ โœ• Delete All ] [ โœ• Cancel ] def _bulk_delete(self): count = len(self._selected_ids) reply = QMessageBox.question( self, "Delete Selected", f"Delete {count} selected item(s)?\nPinned items will be skipped.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply != QMessageBox.StandardButton.Yes: return deleted = 0 for iid in list(self._selected_ids): if storage.delete_item(iid): # returns False for pinned items self._remove_card(iid) deleted += 1 self._selected_ids.clear() self._update_bulk_bar() self._refresh_stats() self.statusBar().showMessage(f"Deleted {deleted} item(s) โœ“") def _bulk_delete(self): count = len(self._selected_ids) reply = QMessageBox.question( self, "Delete Selected", f"Delete {count} selected item(s)?\nPinned items will be skipped.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply != QMessageBox.StandardButton.Yes: return deleted = 0 for iid in list(self._selected_ids): if storage.delete_item(iid): # returns False for pinned items self._remove_card(iid) deleted += 1 self._selected_ids.clear() self._update_bulk_bar() self._refresh_stats() self.statusBar().showMessage(f"Deleted {deleted} item(s) โœ“") def _bulk_delete(self): count = len(self._selected_ids) reply = QMessageBox.question( self, "Delete Selected", f"Delete {count} selected item(s)?\nPinned items will be skipped.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply != QMessageBox.StandardButton.Yes: return deleted = 0 for iid in list(self._selected_ids): if storage.delete_item(iid): # returns False for pinned items self._remove_card(iid) deleted += 1 self._selected_ids.clear() self._update_bulk_bar() self._refresh_stats() self.statusBar().showMessage(f"Deleted {deleted} item(s) โœ“") def _bulk_add_tag(self): tag, ok = QInputDialog.getText( self, "Add Tag", "Tag to add to all selected items:", QLineEdit.EchoMode.Normal, "#" ) if not ok or not tag.strip(): return tag = tag.strip().lower() tag = tag if tag.startswith("#") else f"#{tag}" for iid in list(self._selected_ids): updated = storage.add_tag(iid, tag) card = self._cards.get(iid) if card and tag in updated: card.on_tag_added(tag) self.statusBar().showMessage(f"Tag {tag} added to {len(self._selected_ids)} items โœ“") def _bulk_add_tag(self): tag, ok = QInputDialog.getText( self, "Add Tag", "Tag to add to all selected items:", QLineEdit.EchoMode.Normal, "#" ) if not ok or not tag.strip(): return tag = tag.strip().lower() tag = tag if tag.startswith("#") else f"#{tag}" for iid in list(self._selected_ids): updated = storage.add_tag(iid, tag) card = self._cards.get(iid) if card and tag in updated: card.on_tag_added(tag) self.statusBar().showMessage(f"Tag {tag} added to {len(self._selected_ids)} items โœ“") def _bulk_add_tag(self): tag, ok = QInputDialog.getText( self, "Add Tag", "Tag to add to all selected items:", QLineEdit.EchoMode.Normal, "#" ) if not ok or not tag.strip(): return tag = tag.strip().lower() tag = tag if tag.startswith("#") else f"#{tag}" for iid in list(self._selected_ids): updated = storage.add_tag(iid, tag) card = self._cards.get(iid) if card and tag in updated: card.on_tag_added(tag) self.statusBar().showMessage(f"Tag {tag} added to {len(self._selected_ids)} items โœ“") def export_items(item_ids: list[int], fmt: str) -> str: import json as _json from datetime import datetime as _dt if fmt == "json": rows = [] for iid in item_ids: item = get_item_by_id(iid) if item: rows.append({ "id": item["id"], "type": item["type"], "content": item["content"], "created_at": item["created_at"], "tags": get_tags(item["id"]), # returns list[str] }) return _json.dumps(rows, indent=2, ensure_ascii=False) else: # txt lines = [] for iid in item_ids: item = get_item_by_id(iid) if not item: continue ts = item.get("created_at", "") try: ts = _dt.fromisoformat(ts).strftime("%Y-%m-%d %H:%M") except Exception: pass lines.append(f"[{ts}] ({item['type'].upper()})") lines.append(item["content"]) tags = get_tags(item["id"]) if tags: lines.append("Tags: " + ", ".join(tags)) lines.append("โ”€" * 48) return "\n".join(lines) def export_items(item_ids: list[int], fmt: str) -> str: import json as _json from datetime import datetime as _dt if fmt == "json": rows = [] for iid in item_ids: item = get_item_by_id(iid) if item: rows.append({ "id": item["id"], "type": item["type"], "content": item["content"], "created_at": item["created_at"], "tags": get_tags(item["id"]), # returns list[str] }) return _json.dumps(rows, indent=2, ensure_ascii=False) else: # txt lines = [] for iid in item_ids: item = get_item_by_id(iid) if not item: continue ts = item.get("created_at", "") try: ts = _dt.fromisoformat(ts).strftime("%Y-%m-%d %H:%M") except Exception: pass lines.append(f"[{ts}] ({item['type'].upper()})") lines.append(item["content"]) tags = get_tags(item["id"]) if tags: lines.append("Tags: " + ", ".join(tags)) lines.append("โ”€" * 48) return "\n".join(lines) def export_items(item_ids: list[int], fmt: str) -> str: import json as _json from datetime import datetime as _dt if fmt == "json": rows = [] for iid in item_ids: item = get_item_by_id(iid) if item: rows.append({ "id": item["id"], "type": item["type"], "content": item["content"], "created_at": item["created_at"], "tags": get_tags(item["id"]), # returns list[str] }) return _json.dumps(rows, indent=2, ensure_ascii=False) else: # txt lines = [] for iid in item_ids: item = get_item_by_id(iid) if not item: continue ts = item.get("created_at", "") try: ts = _dt.fromisoformat(ts).strftime("%Y-%m-%d %H:%M") except Exception: pass lines.append(f"[{ts}] ({item['type'].upper()})") lines.append(item["content"]) tags = get_tags(item["id"]) if tags: lines.append("Tags: " + ", ".join(tags)) lines.append("โ”€" * 48) return "\n".join(lines) [ { "id": 42, "type": "text", "content": "-weight: 500;">pip -weight: 500;">install dotghostboard", "created_at": "2026-03-25T14:22:01.123456", "tags": ["#code", "#python"] } ] [ { "id": 42, "type": "text", "content": "-weight: 500;">pip -weight: 500;">install dotghostboard", "created_at": "2026-03-25T14:22:01.123456", "tags": ["#code", "#python"] } ] [ { "id": 42, "type": "text", "content": "-weight: 500;">pip -weight: 500;">install dotghostboard", "created_at": "2026-03-25T14:22:01.123456", "tags": ["#code", "#python"] } ] [2026-03-25 14:22] (TEXT) -weight: 500;">pip -weight: 500;">install dotghostboard Tags: #code, #python โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ [2026-03-25 14:22] (TEXT) -weight: 500;">pip -weight: 500;">install dotghostboard Tags: #code, #python โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ [2026-03-25 14:22] (TEXT) -weight: 500;">pip -weight: 500;">install dotghostboard Tags: #code, #python โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _refresh_completer(self): all_tags = storage.get_all_tags() model = QStringListModel(all_tags) completer = QCompleter(model, self._input) completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) completer.setFilterMode(Qt.MatchFlag.MatchContains) self._input.setCompleter(completer) def _refresh_completer(self): all_tags = storage.get_all_tags() model = QStringListModel(all_tags) completer = QCompleter(model, self._input) completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) completer.setFilterMode(Qt.MatchFlag.MatchContains) self._input.setCompleter(completer) def _refresh_completer(self): all_tags = storage.get_all_tags() model = QStringListModel(all_tags) completer = QCompleter(model, self._input) completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) completer.setFilterMode(Qt.MatchFlag.MatchContains) self._input.setCompleter(completer) def rename_tag(old_tag: str, new_tag: str) -> int: old_tag = old_tag.strip().lower() new_tag = new_tag.strip().lower() if not old_tag.startswith("#"): old_tag = f"#{old_tag}" if not new_tag.startswith("#"): new_tag = f"#{new_tag}" if old_tag == new_tag: return 0 updated = 0 items = get_items_by_tag(old_tag) for item in items: tags = get_tags(item["id"]) if old_tag in tags: tags = [new_tag if t == old_tag else t for t in tags] now = datetime.now().isoformat() with _db() as conn: conn.execute( "UPDATE clipboard_items SET tags = ?, updated_at = ? WHERE id = ?", (_serialize_tags(tags), now, item["id"]), ) updated += 1 return updated def rename_tag(old_tag: str, new_tag: str) -> int: old_tag = old_tag.strip().lower() new_tag = new_tag.strip().lower() if not old_tag.startswith("#"): old_tag = f"#{old_tag}" if not new_tag.startswith("#"): new_tag = f"#{new_tag}" if old_tag == new_tag: return 0 updated = 0 items = get_items_by_tag(old_tag) for item in items: tags = get_tags(item["id"]) if old_tag in tags: tags = [new_tag if t == old_tag else t for t in tags] now = datetime.now().isoformat() with _db() as conn: conn.execute( "UPDATE clipboard_items SET tags = ?, updated_at = ? WHERE id = ?", (_serialize_tags(tags), now, item["id"]), ) updated += 1 return updated def rename_tag(old_tag: str, new_tag: str) -> int: old_tag = old_tag.strip().lower() new_tag = new_tag.strip().lower() if not old_tag.startswith("#"): old_tag = f"#{old_tag}" if not new_tag.startswith("#"): new_tag = f"#{new_tag}" if old_tag == new_tag: return 0 updated = 0 items = get_items_by_tag(old_tag) for item in items: tags = get_tags(item["id"]) if old_tag in tags: tags = [new_tag if t == old_tag else t for t in tags] now = datetime.now().isoformat() with _db() as conn: conn.execute( "UPDATE clipboard_items SET tags = ?, updated_at = ? WHERE id = ?", (_serialize_tags(tags), now, item["id"]), ) updated += 1 return updated import tempfile import core.storage as storage _tmp = tempfile.NamedTemporaryFile(suffix="_v130.db", delete=False) _tmp.close() storage.DB_PATH = _tmp.name storage.THUMB_DIR = tempfile.mkdtemp(prefix="ghost_thumb_v130_") storage.CAPTURES_DIR = tempfile.mkdtemp(prefix="ghost_cap_v130_") @pytest.fixture(autouse=True) def fresh_db(): storage.init_db() yield from core.storage import _db with _db() as conn: conn.execute("DELETE FROM clipboard_items") conn.execute("DELETE FROM collections") import tempfile import core.storage as storage _tmp = tempfile.NamedTemporaryFile(suffix="_v130.db", delete=False) _tmp.close() storage.DB_PATH = _tmp.name storage.THUMB_DIR = tempfile.mkdtemp(prefix="ghost_thumb_v130_") storage.CAPTURES_DIR = tempfile.mkdtemp(prefix="ghost_cap_v130_") @pytest.fixture(autouse=True) def fresh_db(): storage.init_db() yield from core.storage import _db with _db() as conn: conn.execute("DELETE FROM clipboard_items") conn.execute("DELETE FROM collections") import tempfile import core.storage as storage _tmp = tempfile.NamedTemporaryFile(suffix="_v130.db", delete=False) _tmp.close() storage.DB_PATH = _tmp.name storage.THUMB_DIR = tempfile.mkdtemp(prefix="ghost_thumb_v130_") storage.CAPTURES_DIR = tempfile.mkdtemp(prefix="ghost_cap_v130_") @pytest.fixture(autouse=True) def fresh_db(): storage.init_db() yield from core.storage import _db with _db() as conn: conn.execute("DELETE FROM clipboard_items") conn.execute("DELETE FROM collections") def test_no_false_positive_on_partial_tag_name(self): """#py should NOT match items tagged only with #python.""" iid = storage.add_item("text", "Test") storage.add_tag(iid, "#python") results = storage.search_items("", tag_filter="#py") assert not any(r["id"] == iid for r in results) def test_tag_at_start_middle_end_of_list(self): iid = storage.add_item("text", "Positions") storage.add_tag(iid, "#first") storage.add_tag(iid, "#middle") storage.add_tag(iid, "#last") for tag in ("#first", "#middle", "#last"): results = storage.get_items_by_tag(tag) assert any(r["id"] == iid for r in results), f"{tag} not found" def test_items_become_uncategorized(self): cid = storage.create_collection("Temp") iid = storage.add_item("text", "Orphan item") storage.move_to_collection(iid, cid) storage.delete_collection(cid) item = storage.get_item_by_id(iid) assert item["collection_id"] is None def test_no_false_positive_on_partial_tag_name(self): """#py should NOT match items tagged only with #python.""" iid = storage.add_item("text", "Test") storage.add_tag(iid, "#python") results = storage.search_items("", tag_filter="#py") assert not any(r["id"] == iid for r in results) def test_tag_at_start_middle_end_of_list(self): iid = storage.add_item("text", "Positions") storage.add_tag(iid, "#first") storage.add_tag(iid, "#middle") storage.add_tag(iid, "#last") for tag in ("#first", "#middle", "#last"): results = storage.get_items_by_tag(tag) assert any(r["id"] == iid for r in results), f"{tag} not found" def test_items_become_uncategorized(self): cid = storage.create_collection("Temp") iid = storage.add_item("text", "Orphan item") storage.move_to_collection(iid, cid) storage.delete_collection(cid) item = storage.get_item_by_id(iid) assert item["collection_id"] is None def test_no_false_positive_on_partial_tag_name(self): """#py should NOT match items tagged only with #python.""" iid = storage.add_item("text", "Test") storage.add_tag(iid, "#python") results = storage.search_items("", tag_filter="#py") assert not any(r["id"] == iid for r in results) def test_tag_at_start_middle_end_of_list(self): iid = storage.add_item("text", "Positions") storage.add_tag(iid, "#first") storage.add_tag(iid, "#middle") storage.add_tag(iid, "#last") for tag in ("#first", "#middle", "#last"): results = storage.get_items_by_tag(tag) assert any(r["id"] == iid for r in results), f"{tag} not found" def test_items_become_uncategorized(self): cid = storage.create_collection("Temp") iid = storage.add_item("text", "Orphan item") storage.move_to_collection(iid, cid) storage.delete_collection(cid) item = storage.get_item_by_id(iid) assert item["collection_id"] is None pytest tests/test_storage_v130.py -v pytest tests/test_storage_v130.py -v pytest tests/test_storage_v130.py -v - Click to filter cards - Right-click to rename or delete - + button to create a new collection - Drag a card onto a collection name to move it - [ ] Encrypted items (sensitive clipboard content) - [ ] Cloud sync via a local-network peer - [ ] Plugin API for custom item types - [ ] Light theme ๐ŸŒž - ๐Ÿ–ฅ๏ธ OpenDesktop - ๐Ÿ Stack: Python 3.11 ยท PyQt6 ยท SQLite ยท pytest