Clipboard Manager

Keep a private history of everything you copy—text, images, and file links. Search, favorite, and paste in two clicks. No tracking. Free forever.

Download for Windows (.exe)

Source Code


#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os, sys, time, json, sqlite3, hashlib, base64, re
from pathlib import Path
from urllib.parse import urlparse, urlunparse, parse_qsl, urlencode
from PyQt5.QtCore import Qt, QTimer, QSize, QByteArray, QMimeData, QUrl
from PyQt5.QtGui import QClipboard, QIcon, QKeySequence, QImage, QPixmap, QCursor
from PyQt5.QtWidgets import (
    QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem,
    QHeaderView, QLineEdit, QPushButton, QMessageBox, QFileDialog, QLabel, QSystemTrayIcon, QMenu,
    QSplitter, QCheckBox, QDialog, QListWidget, QListWidgetItem, QAbstractItemView
)
APP_DIR   = Path.home() / ".clipboard_manager"
DB_PATH   = APP_DIR / "clipboard.db"
PREVIEW_CHARS = 80
RECENT_LIMIT  = 500
TRAY_RECENTS  = 10
APP_DIR.mkdir(parents=True, exist_ok=True)
try:
    import keyboard as kb
    HAS_KB = True
except Exception:
    HAS_KB = False
def now_ts() -> int:
    return int(time.time())
def sha256_bytes(b: bytes) -> str:
    return hashlib.sha256(b).hexdigest()
def sha256_text(s: str) -> str:
    return hashlib.sha256(s.encode("utf-8", "ignore")).hexdigest()
def clean_url(u: str) -> str:
    try:
        p = urlparse(u)
        qs = [(k, v) for k, v in parse_qsl(p.query, keep_blank_values=True)
              if not k.lower().startswith(("utm_", "gclid", "fbclid", "mc_eid", "mc_cid"))]
        new_q = urlencode(qs)
        return urlunparse((p.scheme, p.netloc, p.path, p.params, new_q, ""))  # drop fragment
    except Exception:
        return u
def slugify(s: str) -> str:
    s = re.sub(r"[^\w\s-]", "", s, flags=re.UNICODE)
    s = re.sub(r"\s+", "-", s.strip())
    return re.sub(r"-{2,}", "-", s).lower()

def to_title(s: str) -> str:
    return re.sub(r"[A-Za-z]+('[A-Za-z]+)?",
                  lambda m: m.group(0)[0].upper() + m.group(0)[1:].lower(),
                  s)
class DB:
    def __init__(self, path: Path):
        self.conn = sqlite3.connect(str(path))
        self.conn.execute("""
        CREATE TABLE IF NOT EXISTS clips (
          id     INTEGER PRIMARY KEY AUTOINCREMENT,
          type   TEXT NOT NULL,        -- Text | Image | File/URL
          text   TEXT,
          image  BLOB,
          urls   TEXT,                 -- JSON list of strings
          ts     INTEGER NOT NULL,
          fav    INTEGER NOT NULL DEFAULT 0,
          h      TEXT UNIQUE
        );
        """)
        self.conn.execute("CREATE INDEX IF NOT EXISTS idx_ts ON clips(ts DESC);")
        self.conn.commit()
    def add_text(self, text: str):
        h = "T|" + sha256_text(text)
        try:
            self.conn.execute("INSERT INTO clips(type,text,ts,fav,h) VALUES(?,?,?,?,?)",
                              ("Text", text, now_ts(), 0, h))
            self.conn.commit()
        except sqlite3.IntegrityError:
            pass
    def add_image(self, img_bytes: bytes):
        h = "I|" + sha256_bytes(img_bytes)
        try:
            self.conn.execute("INSERT INTO clips(type,image,ts,fav,h) VALUES(?,?,?,?,?)",
                              ("Image", sqlite3.Binary(img_bytes), now_ts(), 0, h))
            self.conn.commit()
        except sqlite3.IntegrityError:
            pass
    def add_urls(self, urls_list):
        payload = json.dumps(urls_list, ensure_ascii=False)
        h = "U|" + sha256_text(payload)
        try:
            self.conn.execute("INSERT INTO clips(type,urls,ts,fav,h) VALUES(?,?,?,?,?)",
                              ("File/URL", payload, now_ts(), 0, h))
            self.conn.commit()
        except sqlite3.IntegrityError:
            pass
    def toggle_fav(self, clip_id: int):
        cur = self.conn.execute("SELECT fav FROM clips WHERE id=?", (clip_id,))
        row = cur.fetchone()
        if not row: return
        newv = 0 if row[0] else 1
        self.conn.execute("UPDATE clips SET fav=? WHERE id=?", (newv, clip_id))
        self.conn.commit()
    def clear_all(self):
        self.conn.execute("DELETE FROM clips;")
        self.conn.commit()
    def export_json(self, path: Path):
        data = []
        for row in self.conn.execute("SELECT id,type,text,image,urls,ts,fav FROM clips ORDER BY ts DESC"):
            typ, txt, img, urls, ts, fav = row[1], row[2], row[3], row[4], row[5], row[6]
            item = {"type": typ, "ts": ts, "fav": bool(fav)}
            if typ == "Text":
                item["text"] = txt or ""
            elif typ == "Image" and img is not None:
                item["image_b64"] = base64.b64encode(img).decode("ascii")
            elif typ == "File/URL":
                try:
                    item["urls"] = json.loads(urls or "[]")
                except:
                    item["urls"] = []
            data.append(item)
        path.write_text(json.dumps(data, indent=2), encoding="utf-8")
    def import_json(self, path: Path):
        raw = path.read_text(encoding="utf-8")
        data = json.loads(raw)
        for it in data:
            t = it.get("type")
            if t == "Text":
                self.add_text(it.get("text",""))
            elif t == "Image" and it.get("image_b64"):
                self.add_image(base64.b64decode(it["image_b64"]))
            elif t == "File/URL":
                self.add_urls(it.get("urls", []))
    def query(self, q: str, limit=RECENT_LIMIT):
        q = (q or "").strip().lower()
        if not q:
            cur = self.conn.execute("""
                SELECT id,type,text,image,urls,ts,fav
                FROM clips
                ORDER BY fav DESC, ts DESC
                LIMIT ?;
            """, (limit,))
        else:
            cur = self.conn.execute("""
                SELECT id,type,text,image,urls,ts,fav
                FROM clips
                WHERE (LOWER(COALESCE(text,'')) LIKE ? OR LOWER(COALESCE(urls,'')) LIKE ?)
                ORDER BY fav DESC, ts DESC
                LIMIT ?;
            """, (f"%{q}%", f"%{q}%", limit))
        rows = cur.fetchall()
        return rows
    def get_by_id(self, clip_id: int):
        cur = self.conn.execute("SELECT id,type,text,image,urls,ts,fav FROM clips WHERE id=?", (clip_id,))
        return cur.fetchone()
class QuickPalette(QDialog):
    def __init__(self, db: DB, parent=None, autopaste=False):
        super().__init__(parent)
        self.setWindowTitle("Clipboard — Quick Palette")
        self.setWindowFlag(Qt.WindowStaysOnTopHint, True)
        self.resize(620, 420)
        self.db = db
        self.autopaste = autopaste
        v = QVBoxLayout(self)
        self.search = QLineEdit(self)
        self.search.setPlaceholderText("Type to filter…  ↑/↓ to navigate, Enter to copy (and paste)")
        self.list = QListWidget(self)
        self.list.setSelectionMode(QAbstractItemView.SingleSelection)
        v.addWidget(self.search); v.addWidget(self.list)
        self.search.textChanged.connect(self.refresh)
        self.search.returnPressed.connect(self.pick_current)
        self.list.itemDoubleClicked.connect(lambda _: self.pick_current())
        self.refresh("")
    def refresh(self, _):
        self.list.clear()
        q = self.search.text()
        for (id, typ, text, image, urls, ts, fav) in self.db.query(q, limit=200):
            if typ == "Text":
                disp = text if len(text) <= PREVIEW_CHARS else text[:PREVIEW_CHARS-1]+"…"
            elif typ == "Image":
                disp = "[Image]"
            else:
                try:
                    ls = json.loads(urls or "[]")
                    disp = ", ".join(ls)[:PREVIEW_CHARS]
                except:
                    disp = "[File/URL]"
            if fav: disp = "★ " + disp
            it = QListWidgetItem(disp)
            it.setData(Qt.UserRole, id)
            self.list.addItem(it)
        if self.list.count(): self.list.setCurrentRow(0)
    def keyPressEvent(self, e):
        if e.key() in (Qt.Key_Escape,):
            self.reject(); return
        super().keyPressEvent(e)
    def pick_current(self):
        it = self.list.currentItem()
        if not it: return
        clip_id = int(it.data(Qt.UserRole))
        self.copy_clip(clip_id)
        self.accept()
    def copy_clip(self, clip_id: int):
        row = self.db.get_by_id(clip_id)
        if not row: return
        _, typ, text, image, urls, *_ = row
        md = QMimeData()
        cb = QApplication.clipboard()
        if typ == "Text":
            md.setText(text or "")
        elif typ == "Image" and image is not None:
            img = QImage.fromData(image, "PNG")
            md.setImageData(img)
        elif typ == "File/URL":
            try:
                from PyQt5.QtCore import QUrl
                lst = [QUrl(u) for u in json.loads(urls or "[]")]
                md.setUrls(lst)
            except:
                md.setText(urls or "")
        if hasattr(self.parent(), "_mute_next_clip"):
            self.parent()._mute_next_clip = True
        cb.setMimeData(md, mode=QClipboard.Clipboard)
        if self.autopaste and HAS_KB:
            QTimer.singleShot(60, lambda: kb.send("ctrl+v"))
class App(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Clipboard Manager — CheapITSupport.com")
        self.resize(980, 620)
        self.db = DB(DB_PATH)
        self._mute_next_clip = False
        self._paused = False
        self.search = QLineEdit(); self.search.setPlaceholderText("Search your history…")
        self.search.textChanged.connect(self.refresh_table)
        self.btn_copy   = QPushButton("Copy Back")
        self.btn_paste  = QPushButton("Paste Here")
        self.btn_fav    = QPushButton("★ Favorite")
        self.btn_pause  = QPushButton("Pause Capture")
        self.btn_clear  = QPushButton("Clear All")
        self.btn_export = QPushButton("Export JSON")
        self.btn_import = QPushButton("Import JSON")
        for b in (self.btn_copy, self.btn_paste, self.btn_fav, self.btn_pause, self.btn_clear, self.btn_export, self.btn_import):
            b.setMinimumHeight(32)
        self.btn_copy.clicked.connect(self.copy_selected)
        self.btn_paste.clicked.connect(self.paste_selected)
        self.btn_fav.clicked.connect(self.toggle_fav_selected)
        self.btn_pause.clicked.connect(self.toggle_pause)
        self.btn_clear.clicked.connect(self.clear_all)
        self.btn_export.clicked.connect(self.export_json)
        self.btn_import.clicked.connect(self.import_json)
        self.table = QTableWidget(0, 4)
        self.table.setHorizontalHeaderLabels(["Preview", "Type", "Time", "ID"])
        self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
        self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
        self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
        self.table.setSelectionBehavior(self.table.SelectRows)
        self.table.setEditTriggers(QTableWidget.NoEditTriggers)
        self.table.doubleClicked.connect(self.copy_selected)
        self.table.hideColumn(3)
        self.preview = QLabel("Select a text item to preview + transform.")
        self.preview.setWordWrap(True)
        self.chk_upper = QCheckBox("UPPER")
        self.chk_lower = QCheckBox("lower")
        self.chk_title = QCheckBox("Title Case")
        self.chk_plain = QCheckBox("Paste as Plain Text (strip formatting)")
        self.chk_slug  = QCheckBox("Slugify")
        self.chk_utm   = QCheckBox("Strip URL Tracking (utm_*, gclid, fbclid)")
        self.chk_spaces= QCheckBox("Collapse spaces/newlines")
        self.btn_transform_copy = QPushButton("Copy Transformed")
        self.btn_transform_copy.clicked.connect(self.copy_transformed)
        right = QVBoxLayout()
        right.addWidget(self.preview)
        for w in (self.chk_upper, self.chk_lower, self.chk_title, self.chk_plain, self.chk_slug, self.chk_utm, self.chk_spaces):
            right.addWidget(w)
        right.addStretch(1)
        right.addWidget(self.btn_transform_copy)
        left = QVBoxLayout()
        topbar = QHBoxLayout()
        topbar.addWidget(self.search, 1)
        for b in (self.btn_copy, self.btn_paste, self.btn_fav, self.btn_pause, self.btn_export, self.btn_import, self.btn_clear):
            topbar.addWidget(b)
        left.addLayout(topbar)
        left.addWidget(self.table)
        split = QSplitter()
        lw = QWidget(); lw.setLayout(left)
        rw = QWidget(); rw.setLayout(right)
        split.addWidget(lw); split.addWidget(rw)
        split.setSizes([700, 280])
        root = QVBoxLayout(); root.addWidget(split)
        central = QWidget(); central.setLayout(root)
        self.setCentralWidget(central)
        self.clip = QApplication.clipboard()
        self.clip.dataChanged.connect(self.on_clip_changed)
        self.tray = QSystemTrayIcon(self)
        self.tray.setIcon(QIcon.fromTheme("edit-paste"))
        m = QMenu()
        act_show = m.addAction("Show")
        act_show.triggered.connect(self.show)
        m.addSeparator()
        self.tray_recent_root = m.addMenu("Recent")
        m.addSeparator()
        act_quick = m.addAction("Quick Palette (Ctrl+Shift+V)")
        act_quick.triggered.connect(self.open_quick_palette)
        m.addSeparator()
        act_quit = m.addAction("Quit")
        act_quit.triggered.connect(QApplication.quit)
        self.tray.setContextMenu(m)
        self.tray.show()
        if HAS_KB:
            try:
                kb.add_hotkey("ctrl+shift+v", lambda: self.open_quick_palette(autopaste=True))
            except Exception:
                pass
        self.refresh_table()
        self.refresh_tray_recent()
    def on_clip_changed(self):
        if self._paused:
            return
        if self._mute_next_clip:
            self._mute_next_clip = False
            return
        md = self.clip.mimeData()
        try:
            if md.hasText():
                text = md.text()
                if text: self.db.add_text(text)
            elif md.hasImage():
                img = self.clip.image()
                if isinstance(img, QImage) and not img.isNull():
                    ba = QByteArray()
                    img.save(ba, "PNG")
                    self.db.add_image(bytes(ba))
            elif md.hasUrls():
                urls = [u.toString() for u in md.urls()]
                if urls: self.db.add_urls(urls)
            else:
                return
            self.refresh_table()
            self.refresh_tray_recent()
        except Exception:
            pass
    def current_clip_id(self):
        row = self.table.currentRow()
        if row < 0: return None
        return int(self.table.item(row, 3).text())
    def copy_selected(self):
        cid = self.current_clip_id()
        if cid is None: return
        self.copy_by_id(cid, autopaste=False)
    def paste_selected(self):
        cid = self.current_clip_id()
        if cid is None: return
        self.copy_by_id(cid, autopaste=True)
    def copy_by_id(self, cid: int, autopaste=False):
        row = self.db.get_by_id(cid)
        if not row: return
        _, typ, text, image, urls, *_ = row
        md = QMimeData()
        if typ == "Text":
            md.setText(text or "")
        elif typ == "Image" and image is not None:
            img = QImage.fromData(image, "PNG")
            md.setImageData(img)
        elif typ == "File/URL":
            try:
                from PyQt5.QtCore import QUrl
                lst = [QUrl(u) for u in json.loads(urls or "[]")]
                md.setUrls(lst)
            except:
                md.setText(urls or "")
        self._mute_next_clip = True
        QApplication.clipboard().setMimeData(md)
        if autopaste and HAS_KB:
            QTimer.singleShot(60, lambda: kb.send("ctrl+v"))
    def refresh_table(self):
        q = self.search.text()
        rows = self.db.query(q, limit=RECENT_LIMIT)
        self.table.setRowCount(0)
        for (id, typ, text, image, urls, ts, fav) in rows:
            row = self.table.rowCount()
            self.table.insertRow(row)
            if typ == "Text":
                disp = text if len(text) <= PREVIEW_CHARS else text[:PREVIEW_CHARS-1]+"…"
            elif typ == "Image":
                disp = "[Image] (Double-click to copy)"
            else:
                try:
                    ls = json.loads(urls or "[]")
                    disp = ", ".join(ls)[:PREVIEW_CHARS]
                except:
                    disp = "[File/URL]"
            if fav: disp = "★ " + disp
            self.table.setItem(row, 0, QTableWidgetItem(disp))
            self.table.setItem(row, 1, QTableWidgetItem(typ))
            tstr = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts))
            self.table.setItem(row, 2, QTableWidgetItem(tstr))
            self.table.setItem(row, 3, QTableWidgetItem(str(id)))
        self.update_transform_preview()
    def refresh_tray_recent(self):
        self.tray_recent_root.clear()
        rows = self.db.query("", limit=TRAY_RECENTS)
        for (id, typ, text, image, urls, ts, fav) in rows[:TRAY_RECENTS]:
            if typ == "Text":
                name = text.strip().replace("\n"," ")[:40] or "(empty)"
            elif typ == "Image":
                name = "[Image]"
            else:
                try:
                    ls = json.loads(urls or "[]")
                    name = (", ".join(ls))[:40]
                except:
                    name = "[File/URL]"
            if fav: name = "★ " + name
            act = self.tray_recent_root.addAction(name)
            act.triggered.connect(lambda _, _id=id: self.copy_by_id(_id, autopaste=True))
    def toggle_fav_selected(self):
        cid = self.current_clip_id()
        if cid is None: return
        self.db.toggle_fav(cid)
        self.refresh_table()
        self.refresh_tray_recent()
    def toggle_pause(self):
        self._paused = not self._paused
        self.btn_pause.setText("Resume Capture" if self._paused else "Pause Capture")
        self.tray.showMessage("Clipboard Manager",
                              "Capture paused" if self._paused else "Capture resumed",
                              QSystemTrayIcon.Information, 1500)
    def export_json(self):
        fn, _ = QFileDialog.getSaveFileName(self, "Export Clipboard", "", "JSON (*.json)")
        if not fn: return
        try:
            self.db.export_json(Path(fn))
            QMessageBox.information(self, "Export", "Export complete.")
        except Exception as e:
            QMessageBox.critical(self, "Export failed", str(e))
    def import_json(self):
        fn, _ = QFileDialog.getOpenFileName(self, "Import Clipboard", "", "JSON (*.json)")
        if not fn: return
        try:
            self.db.import_json(Path(fn))
            self.refresh_table(); self.refresh_tray_recent()
            QMessageBox.information(self, "Import", "Import complete.")
        except Exception as e:
            QMessageBox.critical(self, "Import failed", str(e))
    def clear_all(self):
        if QMessageBox.question(self, "Clear History", "Delete ALL clips?") == QMessageBox.Yes:
            self.db.clear_all()
            self.refresh_table(); self.refresh_tray_recent()
    def selected_text(self):
        cid = self.current_clip_id()
        if cid is None: return None
        row = self.db.get_by_id(cid)
        if not row: return None
        _, typ, text, image, urls, *_ = row
        return text if typ == "Text" else None
    def update_transform_preview(self):
        txt = self.selected_text()
        if txt is None:
            self.preview.setText("Select a text item to preview + transform.")
        else:
            show = txt if len(txt) <= 300 else txt[:300] + "…"
            self.preview.setText(show)
    def copy_transformed(self):
        txt = self.selected_text()
        if txt is None: return
        out = txt
        if self.chk_spaces.isChecked():
            out = re.sub(r"[ \t]+", " ", out)
            out = re.sub(r"\s*\n\s*", "\n", out).strip()
        if self.chk_utm.isChecked():
            def repl(m): return clean_url(m.group(0))
            out = re.sub(r"https?://\S+", repl, out)
        if self.chk_slug.isChecked():
            out = slugify(out)
        if self.chk_upper.isChecked(): out = out.upper()
        if self.chk_lower.isChecked(): out = out.lower()
        if self.chk_title.isChecked(): out = to_title(out)
        self._mute_next_clip = True
        QApplication.clipboard().setText(out)
        if HAS_KB:
            QTimer.singleShot(60, lambda: kb.send("ctrl+v"))
    def open_quick_palette(self, autopaste=False):
        dlg = QuickPalette(self.db, parent=self, autopaste=autopaste)
        g = dlg.frameGeometry()
        g.moveCenter(QCursor.pos())
        dlg.move(g.topLeft())
        dlg.exec_()
    def keyPressEvent(self, e):
        if e.matches(QKeySequence.Find):
            self.search.setFocus(); return
        if e.key() == Qt.Key_Delete:
            return
        super().keyPressEvent(e)
    def selectionChanged(self):
        self.update_transform_preview()
if __name__ == "__main__":
    app = QApplication(sys.argv)
    app.setApplicationName("Clipboard Manager")
    w = App()
    w.show()
    w.table.itemSelectionChanged.connect(w.update_transform_preview)
    sys.exit(app.exec_())
          
Version 1.0.0 Updated Sep 07, 2025 Size ~36 MB Free • Offline • No tracking

Why you’ll like it

Instant search

Find any past copy in milliseconds. Favorites (★) stay on top.

Quick Palette

Ctrl+Shift+V opens a type-to-filter palette. Press Enter to paste.

Tray recents

Top 10 recent clips in the tray menu—paste in two clicks.

Privacy first

Works offline. Pause capture anytime. Clear all with one click.

Smart text tools

Paste as plain text, strip URL tracking, UPPER/Title case, slugify, and more.

Import/Export

Share snippets with your team as JSON backups—no accounts required.

Preview: how it looks

★ "Quarterly report boilerplate..."
"db.backup --prune --upload"
"https://example.com/contact?utm_source=newsletter" → cleaned
[Image] Screenshot 2025-09-07 11:14
C:\Users\you\Desktop\roadmap.xlsx

How it works

1
Install

Download and run. Uses your user profile; no admin required.

2
Copy as usual

Text, images, and file links are captured automatically in the background.

3
Paste smarter

Open the Quick Palette (Ctrl+Shift+V), type to filter, press Enter to paste—done.

FAQ

Does it send my clipboard to the internet?

No. It never makes network requests. All data stays on your machine.

Can I pause capture?

Yes. Use the “Pause Capture” button or tray menu while screensharing or entering sensitive data.

Where are clips stored?

In your user profile folder (SQLite database). Delete the app folder to remove all data.

Will images and files copy back correctly?

Yes—images are stored and can be copied back; file/URL lists paste as OS file links or URLs.

Can I build it myself?

Yes. Requires Python 3 + PyQt5. Optional keyboard for global hotkeys. Build as a single EXE with: pyinstaller --onefile --noconsole --icon NONE clipboard_manager_plus.py

Download options

Installer (.exe)

Source Code


#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os, sys, time, json, sqlite3, hashlib, base64, re
from pathlib import Path
from urllib.parse import urlparse, urlunparse, parse_qsl, urlencode
from PyQt5.QtCore import Qt, QTimer, QSize, QByteArray, QMimeData, QUrl
from PyQt5.QtGui import QClipboard, QIcon, QKeySequence, QImage, QPixmap, QCursor
from PyQt5.QtWidgets import (
    QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem,
    QHeaderView, QLineEdit, QPushButton, QMessageBox, QFileDialog, QLabel, QSystemTrayIcon, QMenu,
    QSplitter, QCheckBox, QDialog, QListWidget, QListWidgetItem, QAbstractItemView
)
APP_DIR   = Path.home() / ".clipboard_manager"
DB_PATH   = APP_DIR / "clipboard.db"
PREVIEW_CHARS = 80
RECENT_LIMIT  = 500
TRAY_RECENTS  = 10
APP_DIR.mkdir(parents=True, exist_ok=True)
try:
    import keyboard as kb
    HAS_KB = True
except Exception:
    HAS_KB = False
def now_ts() -> int:
    return int(time.time())
def sha256_bytes(b: bytes) -> str:
    return hashlib.sha256(b).hexdigest()
def sha256_text(s: str) -> str:
    return hashlib.sha256(s.encode("utf-8", "ignore")).hexdigest()
def clean_url(u: str) -> str:
    try:
        p = urlparse(u)
        qs = [(k, v) for k, v in parse_qsl(p.query, keep_blank_values=True)
              if not k.lower().startswith(("utm_", "gclid", "fbclid", "mc_eid", "mc_cid"))]
        new_q = urlencode(qs)
        return urlunparse((p.scheme, p.netloc, p.path, p.params, new_q, ""))  # drop fragment
    except Exception:
        return u
def slugify(s: str) -> str:
    s = re.sub(r"[^\w\s-]", "", s, flags=re.UNICODE)
    s = re.sub(r"\s+", "-", s.strip())
    return re.sub(r"-{2,}", "-", s).lower()

def to_title(s: str) -> str:
    return re.sub(r"[A-Za-z]+('[A-Za-z]+)?",
                  lambda m: m.group(0)[0].upper() + m.group(0)[1:].lower(),
                  s)
class DB:
    def __init__(self, path: Path):
        self.conn = sqlite3.connect(str(path))
        self.conn.execute("""
        CREATE TABLE IF NOT EXISTS clips (
          id     INTEGER PRIMARY KEY AUTOINCREMENT,
          type   TEXT NOT NULL,        -- Text | Image | File/URL
          text   TEXT,
          image  BLOB,
          urls   TEXT,                 -- JSON list of strings
          ts     INTEGER NOT NULL,
          fav    INTEGER NOT NULL DEFAULT 0,
          h      TEXT UNIQUE
        );
        """)
        self.conn.execute("CREATE INDEX IF NOT EXISTS idx_ts ON clips(ts DESC);")
        self.conn.commit()
    def add_text(self, text: str):
        h = "T|" + sha256_text(text)
        try:
            self.conn.execute("INSERT INTO clips(type,text,ts,fav,h) VALUES(?,?,?,?,?)",
                              ("Text", text, now_ts(), 0, h))
            self.conn.commit()
        except sqlite3.IntegrityError:
            pass
    def add_image(self, img_bytes: bytes):
        h = "I|" + sha256_bytes(img_bytes)
        try:
            self.conn.execute("INSERT INTO clips(type,image,ts,fav,h) VALUES(?,?,?,?,?)",
                              ("Image", sqlite3.Binary(img_bytes), now_ts(), 0, h))
            self.conn.commit()
        except sqlite3.IntegrityError:
            pass
    def add_urls(self, urls_list):
        payload = json.dumps(urls_list, ensure_ascii=False)
        h = "U|" + sha256_text(payload)
        try:
            self.conn.execute("INSERT INTO clips(type,urls,ts,fav,h) VALUES(?,?,?,?,?)",
                              ("File/URL", payload, now_ts(), 0, h))
            self.conn.commit()
        except sqlite3.IntegrityError:
            pass
    def toggle_fav(self, clip_id: int):
        cur = self.conn.execute("SELECT fav FROM clips WHERE id=?", (clip_id,))
        row = cur.fetchone()
        if not row: return
        newv = 0 if row[0] else 1
        self.conn.execute("UPDATE clips SET fav=? WHERE id=?", (newv, clip_id))
        self.conn.commit()
    def clear_all(self):
        self.conn.execute("DELETE FROM clips;")
        self.conn.commit()
    def export_json(self, path: Path):
        data = []
        for row in self.conn.execute("SELECT id,type,text,image,urls,ts,fav FROM clips ORDER BY ts DESC"):
            typ, txt, img, urls, ts, fav = row[1], row[2], row[3], row[4], row[5], row[6]
            item = {"type": typ, "ts": ts, "fav": bool(fav)}
            if typ == "Text":
                item["text"] = txt or ""
            elif typ == "Image" and img is not None:
                item["image_b64"] = base64.b64encode(img).decode("ascii")
            elif typ == "File/URL":
                try:
                    item["urls"] = json.loads(urls or "[]")
                except:
                    item["urls"] = []
            data.append(item)
        path.write_text(json.dumps(data, indent=2), encoding="utf-8")
    def import_json(self, path: Path):
        raw = path.read_text(encoding="utf-8")
        data = json.loads(raw)
        for it in data:
            t = it.get("type")
            if t == "Text":
                self.add_text(it.get("text",""))
            elif t == "Image" and it.get("image_b64"):
                self.add_image(base64.b64decode(it["image_b64"]))
            elif t == "File/URL":
                self.add_urls(it.get("urls", []))
    def query(self, q: str, limit=RECENT_LIMIT):
        q = (q or "").strip().lower()
        if not q:
            cur = self.conn.execute("""
                SELECT id,type,text,image,urls,ts,fav
                FROM clips
                ORDER BY fav DESC, ts DESC
                LIMIT ?;
            """, (limit,))
        else:
            cur = self.conn.execute("""
                SELECT id,type,text,image,urls,ts,fav
                FROM clips
                WHERE (LOWER(COALESCE(text,'')) LIKE ? OR LOWER(COALESCE(urls,'')) LIKE ?)
                ORDER BY fav DESC, ts DESC
                LIMIT ?;
            """, (f"%{q}%", f"%{q}%", limit))
        rows = cur.fetchall()
        return rows
    def get_by_id(self, clip_id: int):
        cur = self.conn.execute("SELECT id,type,text,image,urls,ts,fav FROM clips WHERE id=?", (clip_id,))
        return cur.fetchone()
class QuickPalette(QDialog):
    def __init__(self, db: DB, parent=None, autopaste=False):
        super().__init__(parent)
        self.setWindowTitle("Clipboard — Quick Palette")
        self.setWindowFlag(Qt.WindowStaysOnTopHint, True)
        self.resize(620, 420)
        self.db = db
        self.autopaste = autopaste
        v = QVBoxLayout(self)
        self.search = QLineEdit(self)
        self.search.setPlaceholderText("Type to filter…  ↑/↓ to navigate, Enter to copy (and paste)")
        self.list = QListWidget(self)
        self.list.setSelectionMode(QAbstractItemView.SingleSelection)
        v.addWidget(self.search); v.addWidget(self.list)
        self.search.textChanged.connect(self.refresh)
        self.search.returnPressed.connect(self.pick_current)
        self.list.itemDoubleClicked.connect(lambda _: self.pick_current())
        self.refresh("")
    def refresh(self, _):
        self.list.clear()
        q = self.search.text()
        for (id, typ, text, image, urls, ts, fav) in self.db.query(q, limit=200):
            if typ == "Text":
                disp = text if len(text) <= PREVIEW_CHARS else text[:PREVIEW_CHARS-1]+"…"
            elif typ == "Image":
                disp = "[Image]"
            else:
                try:
                    ls = json.loads(urls or "[]")
                    disp = ", ".join(ls)[:PREVIEW_CHARS]
                except:
                    disp = "[File/URL]"
            if fav: disp = "★ " + disp
            it = QListWidgetItem(disp)
            it.setData(Qt.UserRole, id)
            self.list.addItem(it)
        if self.list.count(): self.list.setCurrentRow(0)
    def keyPressEvent(self, e):
        if e.key() in (Qt.Key_Escape,):
            self.reject(); return
        super().keyPressEvent(e)
    def pick_current(self):
        it = self.list.currentItem()
        if not it: return
        clip_id = int(it.data(Qt.UserRole))
        self.copy_clip(clip_id)
        self.accept()
    def copy_clip(self, clip_id: int):
        row = self.db.get_by_id(clip_id)
        if not row: return
        _, typ, text, image, urls, *_ = row
        md = QMimeData()
        cb = QApplication.clipboard()
        if typ == "Text":
            md.setText(text or "")
        elif typ == "Image" and image is not None:
            img = QImage.fromData(image, "PNG")
            md.setImageData(img)
        elif typ == "File/URL":
            try:
                from PyQt5.QtCore import QUrl
                lst = [QUrl(u) for u in json.loads(urls or "[]")]
                md.setUrls(lst)
            except:
                md.setText(urls or "")
        if hasattr(self.parent(), "_mute_next_clip"):
            self.parent()._mute_next_clip = True
        cb.setMimeData(md, mode=QClipboard.Clipboard)
        if self.autopaste and HAS_KB:
            QTimer.singleShot(60, lambda: kb.send("ctrl+v"))
class App(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Clipboard Manager — CheapITSupport.com")
        self.resize(980, 620)
        self.db = DB(DB_PATH)
        self._mute_next_clip = False
        self._paused = False
        self.search = QLineEdit(); self.search.setPlaceholderText("Search your history…")
        self.search.textChanged.connect(self.refresh_table)
        self.btn_copy   = QPushButton("Copy Back")
        self.btn_paste  = QPushButton("Paste Here")
        self.btn_fav    = QPushButton("★ Favorite")
        self.btn_pause  = QPushButton("Pause Capture")
        self.btn_clear  = QPushButton("Clear All")
        self.btn_export = QPushButton("Export JSON")
        self.btn_import = QPushButton("Import JSON")
        for b in (self.btn_copy, self.btn_paste, self.btn_fav, self.btn_pause, self.btn_clear, self.btn_export, self.btn_import):
            b.setMinimumHeight(32)
        self.btn_copy.clicked.connect(self.copy_selected)
        self.btn_paste.clicked.connect(self.paste_selected)
        self.btn_fav.clicked.connect(self.toggle_fav_selected)
        self.btn_pause.clicked.connect(self.toggle_pause)
        self.btn_clear.clicked.connect(self.clear_all)
        self.btn_export.clicked.connect(self.export_json)
        self.btn_import.clicked.connect(self.import_json)
        self.table = QTableWidget(0, 4)
        self.table.setHorizontalHeaderLabels(["Preview", "Type", "Time", "ID"])
        self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
        self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
        self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
        self.table.setSelectionBehavior(self.table.SelectRows)
        self.table.setEditTriggers(QTableWidget.NoEditTriggers)
        self.table.doubleClicked.connect(self.copy_selected)
        self.table.hideColumn(3)
        self.preview = QLabel("Select a text item to preview + transform.")
        self.preview.setWordWrap(True)
        self.chk_upper = QCheckBox("UPPER")
        self.chk_lower = QCheckBox("lower")
        self.chk_title = QCheckBox("Title Case")
        self.chk_plain = QCheckBox("Paste as Plain Text (strip formatting)")
        self.chk_slug  = QCheckBox("Slugify")
        self.chk_utm   = QCheckBox("Strip URL Tracking (utm_*, gclid, fbclid)")
        self.chk_spaces= QCheckBox("Collapse spaces/newlines")
        self.btn_transform_copy = QPushButton("Copy Transformed")
        self.btn_transform_copy.clicked.connect(self.copy_transformed)
        right = QVBoxLayout()
        right.addWidget(self.preview)
        for w in (self.chk_upper, self.chk_lower, self.chk_title, self.chk_plain, self.chk_slug, self.chk_utm, self.chk_spaces):
            right.addWidget(w)
        right.addStretch(1)
        right.addWidget(self.btn_transform_copy)
        left = QVBoxLayout()
        topbar = QHBoxLayout()
        topbar.addWidget(self.search, 1)
        for b in (self.btn_copy, self.btn_paste, self.btn_fav, self.btn_pause, self.btn_export, self.btn_import, self.btn_clear):
            topbar.addWidget(b)
        left.addLayout(topbar)
        left.addWidget(self.table)
        split = QSplitter()
        lw = QWidget(); lw.setLayout(left)
        rw = QWidget(); rw.setLayout(right)
        split.addWidget(lw); split.addWidget(rw)
        split.setSizes([700, 280])
        root = QVBoxLayout(); root.addWidget(split)
        central = QWidget(); central.setLayout(root)
        self.setCentralWidget(central)
        self.clip = QApplication.clipboard()
        self.clip.dataChanged.connect(self.on_clip_changed)
        self.tray = QSystemTrayIcon(self)
        self.tray.setIcon(QIcon.fromTheme("edit-paste"))
        m = QMenu()
        act_show = m.addAction("Show")
        act_show.triggered.connect(self.show)
        m.addSeparator()
        self.tray_recent_root = m.addMenu("Recent")
        m.addSeparator()
        act_quick = m.addAction("Quick Palette (Ctrl+Shift+V)")
        act_quick.triggered.connect(self.open_quick_palette)
        m.addSeparator()
        act_quit = m.addAction("Quit")
        act_quit.triggered.connect(QApplication.quit)
        self.tray.setContextMenu(m)
        self.tray.show()
        if HAS_KB:
            try:
                kb.add_hotkey("ctrl+shift+v", lambda: self.open_quick_palette(autopaste=True))
            except Exception:
                pass
        self.refresh_table()
        self.refresh_tray_recent()
    def on_clip_changed(self):
        if self._paused:
            return
        if self._mute_next_clip:
            self._mute_next_clip = False
            return
        md = self.clip.mimeData()
        try:
            if md.hasText():
                text = md.text()
                if text: self.db.add_text(text)
            elif md.hasImage():
                img = self.clip.image()
                if isinstance(img, QImage) and not img.isNull():
                    ba = QByteArray()
                    img.save(ba, "PNG")
                    self.db.add_image(bytes(ba))
            elif md.hasUrls():
                urls = [u.toString() for u in md.urls()]
                if urls: self.db.add_urls(urls)
            else:
                return
            self.refresh_table()
            self.refresh_tray_recent()
        except Exception:
            pass
    def current_clip_id(self):
        row = self.table.currentRow()
        if row < 0: return None
        return int(self.table.item(row, 3).text())
    def copy_selected(self):
        cid = self.current_clip_id()
        if cid is None: return
        self.copy_by_id(cid, autopaste=False)
    def paste_selected(self):
        cid = self.current_clip_id()
        if cid is None: return
        self.copy_by_id(cid, autopaste=True)
    def copy_by_id(self, cid: int, autopaste=False):
        row = self.db.get_by_id(cid)
        if not row: return
        _, typ, text, image, urls, *_ = row
        md = QMimeData()
        if typ == "Text":
            md.setText(text or "")
        elif typ == "Image" and image is not None:
            img = QImage.fromData(image, "PNG")
            md.setImageData(img)
        elif typ == "File/URL":
            try:
                from PyQt5.QtCore import QUrl
                lst = [QUrl(u) for u in json.loads(urls or "[]")]
                md.setUrls(lst)
            except:
                md.setText(urls or "")
        self._mute_next_clip = True
        QApplication.clipboard().setMimeData(md)
        if autopaste and HAS_KB:
            QTimer.singleShot(60, lambda: kb.send("ctrl+v"))
    def refresh_table(self):
        q = self.search.text()
        rows = self.db.query(q, limit=RECENT_LIMIT)
        self.table.setRowCount(0)
        for (id, typ, text, image, urls, ts, fav) in rows:
            row = self.table.rowCount()
            self.table.insertRow(row)
            if typ == "Text":
                disp = text if len(text) <= PREVIEW_CHARS else text[:PREVIEW_CHARS-1]+"…"
            elif typ == "Image":
                disp = "[Image] (Double-click to copy)"
            else:
                try:
                    ls = json.loads(urls or "[]")
                    disp = ", ".join(ls)[:PREVIEW_CHARS]
                except:
                    disp = "[File/URL]"
            if fav: disp = "★ " + disp
            self.table.setItem(row, 0, QTableWidgetItem(disp))
            self.table.setItem(row, 1, QTableWidgetItem(typ))
            tstr = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts))
            self.table.setItem(row, 2, QTableWidgetItem(tstr))
            self.table.setItem(row, 3, QTableWidgetItem(str(id)))
        self.update_transform_preview()
    def refresh_tray_recent(self):
        self.tray_recent_root.clear()
        rows = self.db.query("", limit=TRAY_RECENTS)
        for (id, typ, text, image, urls, ts, fav) in rows[:TRAY_RECENTS]:
            if typ == "Text":
                name = text.strip().replace("\n"," ")[:40] or "(empty)"
            elif typ == "Image":
                name = "[Image]"
            else:
                try:
                    ls = json.loads(urls or "[]")
                    name = (", ".join(ls))[:40]
                except:
                    name = "[File/URL]"
            if fav: name = "★ " + name
            act = self.tray_recent_root.addAction(name)
            act.triggered.connect(lambda _, _id=id: self.copy_by_id(_id, autopaste=True))
    def toggle_fav_selected(self):
        cid = self.current_clip_id()
        if cid is None: return
        self.db.toggle_fav(cid)
        self.refresh_table()
        self.refresh_tray_recent()
    def toggle_pause(self):
        self._paused = not self._paused
        self.btn_pause.setText("Resume Capture" if self._paused else "Pause Capture")
        self.tray.showMessage("Clipboard Manager",
                              "Capture paused" if self._paused else "Capture resumed",
                              QSystemTrayIcon.Information, 1500)
    def export_json(self):
        fn, _ = QFileDialog.getSaveFileName(self, "Export Clipboard", "", "JSON (*.json)")
        if not fn: return
        try:
            self.db.export_json(Path(fn))
            QMessageBox.information(self, "Export", "Export complete.")
        except Exception as e:
            QMessageBox.critical(self, "Export failed", str(e))
    def import_json(self):
        fn, _ = QFileDialog.getOpenFileName(self, "Import Clipboard", "", "JSON (*.json)")
        if not fn: return
        try:
            self.db.import_json(Path(fn))
            self.refresh_table(); self.refresh_tray_recent()
            QMessageBox.information(self, "Import", "Import complete.")
        except Exception as e:
            QMessageBox.critical(self, "Import failed", str(e))
    def clear_all(self):
        if QMessageBox.question(self, "Clear History", "Delete ALL clips?") == QMessageBox.Yes:
            self.db.clear_all()
            self.refresh_table(); self.refresh_tray_recent()
    def selected_text(self):
        cid = self.current_clip_id()
        if cid is None: return None
        row = self.db.get_by_id(cid)
        if not row: return None
        _, typ, text, image, urls, *_ = row
        return text if typ == "Text" else None
    def update_transform_preview(self):
        txt = self.selected_text()
        if txt is None:
            self.preview.setText("Select a text item to preview + transform.")
        else:
            show = txt if len(txt) <= 300 else txt[:300] + "…"
            self.preview.setText(show)
    def copy_transformed(self):
        txt = self.selected_text()
        if txt is None: return
        out = txt
        if self.chk_spaces.isChecked():
            out = re.sub(r"[ \t]+", " ", out)
            out = re.sub(r"\s*\n\s*", "\n", out).strip()
        if self.chk_utm.isChecked():
            def repl(m): return clean_url(m.group(0))
            out = re.sub(r"https?://\S+", repl, out)
        if self.chk_slug.isChecked():
            out = slugify(out)
        if self.chk_upper.isChecked(): out = out.upper()
        if self.chk_lower.isChecked(): out = out.lower()
        if self.chk_title.isChecked(): out = to_title(out)
        self._mute_next_clip = True
        QApplication.clipboard().setText(out)
        if HAS_KB:
            QTimer.singleShot(60, lambda: kb.send("ctrl+v"))
    def open_quick_palette(self, autopaste=False):
        dlg = QuickPalette(self.db, parent=self, autopaste=autopaste)
        g = dlg.frameGeometry()
        g.moveCenter(QCursor.pos())
        dlg.move(g.topLeft())
        dlg.exec_()
    def keyPressEvent(self, e):
        if e.matches(QKeySequence.Find):
            self.search.setFocus(); return
        if e.key() == Qt.Key_Delete:
            return
        super().keyPressEvent(e)
    def selectionChanged(self):
        self.update_transform_preview()
if __name__ == "__main__":
    app = QApplication(sys.argv)
    app.setApplicationName("Clipboard Manager")
    w = App()
    w.show()
    w.table.itemSelectionChanged.connect(w.update_transform_preview)
    sys.exit(app.exec_())
              
Version 1.0.0 Updated 2025-09-07 Size ~36 MB

Hotkeys (example)

Ctrl + Shift + V

Open Quick Palette → Enter to paste

Ctrl + Alt + P

Pause/Resume capture

Alt + ★

Toggle favorite for the active item

System requirements

  • Windows 10 or later
  • ~55 MB free space
  • No .NET required (bundled)
Prefer the tools bundle? Explore more free utilities on the home page.

← Back to Home