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.
โฌ๏ธ Download DotGhostBoard v1.3.0 "Wraith"
| Platform | Format | Link |
|---|---|---|
| ๐ง Linux | AppImage | GitHub Releases |
| ๐ง Linux | .deb | GitHub Releases |
| ๐ช Windows | .exe | GitHub Releases |
| ๐ macOS | .dmg | GitHub Releases |
| ๐ฅ๏ธ OpenDesktop | All platforms | opendesktop.org |
What Is DotGhostBoard?
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.
v1.3.0 "Wraith" โ What's New at a Glance
| # | Feature | Category |
|---|---|---|
| W001 | Tag System โ DB schema & CRUD | Core |
| W002 | Tag Input Widget on Item Cards | UI |
| W003 | Filter by Tag in Search Bar | UI + Core |
| W004 | Collections โ DB schema & CRUD | Core |
| W005 | Collections Sidebar Panel | UI |
| W006 | Multi-Select Cards (Ctrl+Click) | UI |
| W007 | Bulk Actions Toolbar | UI |
| W008 | Export Selected Items (.txt / .json) | Core |
| W009 | Tag Autocomplete & Tag Manager | UI + Core |
| W010 | Unit Tests (20+ new tests) | Tests |
All 10 tasks shipped. Completion: 100% โ
W001 โ Tag System: Database Schema & CRUD
The Design Decision: Comma-Separated vs. Relational
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.
Migration (Zero Downtime)
The migration wraps every ALTER TABLE in a try/except โ so existing databases upgrade silently:
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
Core Tag CRUD
Tags are normalized to lowercase and always prefixed with #. The add_tag function is idempotent โ calling it twice never creates duplicates:
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
The Four-Pattern LIKE Query
The trickiest part: querying a comma-separated column. A tag can sit at four positions โ exact match, start, middle, or end:
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 ? -- 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()]
โ ๏ธ Pitfall: A naive
WHERE tags LIKE '%#py%'would match#pythonwhen searching for#py. The four explicit patterns prevent this false-positive completely.
W002 โ Tag Input Widget on Item Cards
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.
TagChip โ Color Palette by Rotation
Every tag gets a consistent background/foreground color pair. The class rotates through 6 palettes and memoizes the assignment per tag string:
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]
Tag Update Flow (Signal Chain)
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:
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}")
W003 โ Combined Text + Tag Search
The search bar now understands a mixed query like "python #code" โ text on the left, tag filter on the right:
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)
The storage layer's search_items was extended to accept tag_filter:
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()]
W004 โ Collections: Database Schema & CRUD
Collections are a proper relational table. Each clipboard_item gets a nullable collection_id FK. NULL = "Uncategorized".
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
get_collections() returns item counts via a LEFT JOIN โ handy for displaying "๐ Work (12)" in the sidebar:
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]
W005 โ Collections Sidebar Panel
A QListWidget on the left side of the dashboard shows all collections. It supports:
- 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
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)
The drag-to-collection handler decodes the card's MIME data and calls storage.move_to_collection():
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) # remove from current view
self._refresh_sidebar()
self.statusBar().showMessage("Card moved to collection โ")
event.acceptProposedAction()
W006 โ Multi-Select Cards (Ctrl+Click & Shift+Click)
The Dashboard tracks a _selected_ids: set[int]. The card emits sig_clicked(item_id, modifiers) and the Dashboard interprets the modifier keys:
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()
The card's visual selection state is toggled via Qt properties + stylesheet:
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()
W007 โ Bulk Actions Toolbar
When len(selected_ids) >= 2, a floating bar slides up from the bottom of the window:
[ 3 selected ] [ ๐ Pin All ] [ ๐ Unpin All ] [ ๐ท Add Tag ] [ ๐ค Export ] [ โ Delete All ] [ โ Cancel ]
Bulk delete always shows a confirmation dialog and skips pinned items:
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) โ")
Bulk tag iterates every selected card and calls storage.add_tag(), then updates each card's chip row:
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 โ")
W008 โ Export to .txt / .json
Export supports two formats. The storage layer handles serialization; the Dashboard handles the file-save dialog:
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)
Example .json output:
[
{
"id": 42,
"type": "text",
"content": "pip install dotghostboard",
"created_at": "2026-03-25T14:22:01.123456",
"tags": ["#code", "#python"]
}
]
Example .txt output:
[2026-03-25 14:22] (TEXT)
pip install dotghostboard
Tags: #code, #python
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
W009 โ Tag Autocomplete & Global Tag Manager
Autocomplete
The TagInputRow builds a QCompleter from storage.get_all_tags() every time a new tag is added, keeping suggestions fresh:
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)
Global Tag Manager
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:
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
W010 โ Unit Tests (20+ new tests)
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:
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")
Some notable edge-case tests:
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
Run all tests with:
pytest tests/test_storage_v130.py -v
Key Lessons from Wraith
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.
What's Next โ v1.4.0 Ideas
- [ ] Encrypted items (sensitive clipboard content)
- [ ] Cloud sync via a local-network peer
- [ ] Plugin API for custom item types
- [ ] Light theme ๐
Project Links
- ๐ GitHub
- ๐ฅ๏ธ OpenDesktop
- ๐ Stack: Python 3.11 ยท PyQt6 ยท SQLite ยท pytest
Thanks for reading! If you found this breakdown useful, drop a โค๏ธ or share your own clipboard manager stories in the comments.



Top comments (0)