DEV Community

Cover image for DotGhostBoard v1.3.0 "Wraith" โ€” Tags, Collections, Multi-Select & Export ๐Ÿ‘ป
freerave
freerave

Posted on

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

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()]
Enter fullscreen mode Exit fullscreen mode

โš ๏ธ Pitfall: A naive WHERE tags LIKE '%#py%' would match #python when 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]
Enter fullscreen mode Exit fullscreen mode

Tag System Demo


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
Enter fullscreen mode Exit fullscreen mode
# 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}")
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Collections Demo


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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Multi-Select Demo


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 ]
Enter fullscreen mode Exit fullscreen mode

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) โœ“")
Enter fullscreen mode Exit fullscreen mode

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 โœ“")
Enter fullscreen mode Exit fullscreen mode

Bulk Actions Toolbar


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)
Enter fullscreen mode Exit fullscreen mode

Example .json output:

[
  {
    "id": 42,
    "type": "text",
    "content": "pip install dotghostboard",
    "created_at": "2026-03-25T14:22:01.123456",
    "tags": ["#code", "#python"]
  }
]
Enter fullscreen mode Exit fullscreen mode

Example .txt output:

[2026-03-25 14:22] (TEXT)
pip install dotghostboard
Tags: #code, #python
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Run all tests with:

pytest tests/test_storage_v130.py -v
Enter fullscreen mode Exit fullscreen mode

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)