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.
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_())
Why you’ll like it
Find any past copy in milliseconds. Favorites (★) stay on top.
Ctrl+Shift+V
opens a type-to-filter palette. Press Enter to paste.
Top 10 recent clips in the tray menu—paste in two clicks.
Works offline. Pause capture anytime. Clear all with one click.
Paste as plain text, strip URL tracking, UPPER/Title case, slugify, and more.
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
Download and run. Uses your user profile; no admin required.
Text, images, and file links are captured automatically in the background.
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
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_())
1.0.0
Updated 2025-09-07
Size ~36 MB
Hotkeys (example)
Open Quick Palette → Enter to paste
Pause/Resume capture
Toggle favorite for the active item
System requirements
- Windows 10 or later
- ~55 MB free space
- No .NET required (bundled)