clipsy 1.7.1__py3-none-any.whl → 1.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- clipsy/__init__.py +1 -1
- clipsy/app.py +63 -4
- clipsy/config.py +13 -1
- clipsy/models.py +2 -0
- clipsy/monitor.py +118 -70
- clipsy/storage.py +41 -27
- {clipsy-1.7.1.dist-info → clipsy-1.9.0.dist-info}/METADATA +29 -3
- clipsy-1.9.0.dist-info/RECORD +15 -0
- clipsy-1.7.1.dist-info/RECORD +0 -15
- {clipsy-1.7.1.dist-info → clipsy-1.9.0.dist-info}/WHEEL +0 -0
- {clipsy-1.7.1.dist-info → clipsy-1.9.0.dist-info}/entry_points.txt +0 -0
- {clipsy-1.7.1.dist-info → clipsy-1.9.0.dist-info}/licenses/LICENSE +0 -0
- {clipsy-1.7.1.dist-info → clipsy-1.9.0.dist-info}/top_level.txt +0 -0
clipsy/__init__.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
__version__ = "1.
|
|
1
|
+
__version__ = "1.9.0"
|
|
2
2
|
__app_name__ = "Clipsy"
|
clipsy/app.py
CHANGED
|
@@ -5,7 +5,7 @@ from pathlib import Path
|
|
|
5
5
|
import rumps
|
|
6
6
|
|
|
7
7
|
from clipsy import __version__
|
|
8
|
-
from clipsy.config import DB_PATH, IMAGE_DIR, MENU_DISPLAY_COUNT, POLL_INTERVAL, REDACT_SENSITIVE, THUMBNAIL_SIZE
|
|
8
|
+
from clipsy.config import DB_PATH, IMAGE_DIR, MAX_PINNED_ENTRIES, MENU_DISPLAY_COUNT, POLL_INTERVAL, REDACT_SENSITIVE, THUMBNAIL_SIZE
|
|
9
9
|
from clipsy.models import ClipboardEntry, ContentType
|
|
10
10
|
from clipsy.monitor import ClipboardMonitor
|
|
11
11
|
from clipsy.storage import StorageManager
|
|
@@ -34,10 +34,24 @@ class ClipsyApp(rumps.App):
|
|
|
34
34
|
None, # separator
|
|
35
35
|
]
|
|
36
36
|
|
|
37
|
-
entries = self._storage.get_recent(limit=MENU_DISPLAY_COUNT)
|
|
38
37
|
self._entry_ids.clear()
|
|
39
38
|
|
|
40
|
-
if
|
|
39
|
+
# Add pinned submenu if there are pinned entries
|
|
40
|
+
pinned_entries = self._storage.get_pinned()
|
|
41
|
+
if pinned_entries:
|
|
42
|
+
pinned_menu = rumps.MenuItem("📌 Pinned")
|
|
43
|
+
for entry in pinned_entries:
|
|
44
|
+
pinned_menu.add(self._create_entry_menu_item(entry))
|
|
45
|
+
pinned_menu.add(None) # separator
|
|
46
|
+
pinned_menu.add(rumps.MenuItem("Clear Pinned", callback=self._on_clear_pinned))
|
|
47
|
+
self.menu.add(pinned_menu)
|
|
48
|
+
self.menu.add(None) # separator
|
|
49
|
+
|
|
50
|
+
entries = self._storage.get_recent(limit=MENU_DISPLAY_COUNT)
|
|
51
|
+
# Filter out pinned entries from recent list
|
|
52
|
+
entries = [e for e in entries if not e.pinned]
|
|
53
|
+
|
|
54
|
+
if not entries and not pinned_entries:
|
|
41
55
|
self.menu.add(rumps.MenuItem("(No clipboard history)", callback=None))
|
|
42
56
|
else:
|
|
43
57
|
for entry in entries:
|
|
@@ -118,8 +132,20 @@ class ClipsyApp(rumps.App):
|
|
|
118
132
|
if entry is None:
|
|
119
133
|
return
|
|
120
134
|
|
|
135
|
+
# Check if Option key is held (for pin toggle)
|
|
136
|
+
try:
|
|
137
|
+
from AppKit import NSAlternateKeyMask, NSEvent
|
|
138
|
+
|
|
139
|
+
modifier_flags = NSEvent.modifierFlags()
|
|
140
|
+
if modifier_flags & NSAlternateKeyMask:
|
|
141
|
+
self._on_pin_toggle(entry)
|
|
142
|
+
return
|
|
143
|
+
except Exception:
|
|
144
|
+
pass # If we can't check modifiers, proceed with normal copy
|
|
145
|
+
|
|
121
146
|
try:
|
|
122
147
|
from AppKit import NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeString
|
|
148
|
+
from Foundation import NSData
|
|
123
149
|
|
|
124
150
|
pb = NSPasteboard.generalPasteboard()
|
|
125
151
|
|
|
@@ -128,11 +154,18 @@ class ClipsyApp(rumps.App):
|
|
|
128
154
|
if entry.content_type == ContentType.TEXT and entry.text_content:
|
|
129
155
|
pb.clearContents()
|
|
130
156
|
pb.setString_forType_(entry.text_content, NSPasteboardTypeString)
|
|
157
|
+
if entry.rtf_data:
|
|
158
|
+
rtf_ns_data = NSData.dataWithBytes_length_(entry.rtf_data, len(entry.rtf_data))
|
|
159
|
+
if rtf_ns_data:
|
|
160
|
+
pb.setData_forType_(rtf_ns_data, "public.rtf")
|
|
161
|
+
if entry.html_data:
|
|
162
|
+
html_ns_data = NSData.dataWithBytes_length_(entry.html_data, len(entry.html_data))
|
|
163
|
+
if html_ns_data:
|
|
164
|
+
pb.setData_forType_(html_ns_data, "public.html")
|
|
131
165
|
self._monitor.sync_change_count()
|
|
132
166
|
copied = True
|
|
133
167
|
|
|
134
168
|
elif entry.content_type == ContentType.IMAGE and entry.image_path:
|
|
135
|
-
from Foundation import NSData
|
|
136
169
|
img_data = NSData.dataWithContentsOfFile_(entry.image_path)
|
|
137
170
|
if img_data:
|
|
138
171
|
pb.clearContents()
|
|
@@ -153,6 +186,32 @@ class ClipsyApp(rumps.App):
|
|
|
153
186
|
except Exception:
|
|
154
187
|
logger.exception("Error copying entry to clipboard")
|
|
155
188
|
|
|
189
|
+
def _on_pin_toggle(self, entry: ClipboardEntry) -> None:
|
|
190
|
+
"""Toggle pin status for an entry."""
|
|
191
|
+
if entry.pinned:
|
|
192
|
+
# Unpin
|
|
193
|
+
self._storage.toggle_pin(entry.id)
|
|
194
|
+
rumps.notification("Clipsy", "", "Unpinned", sound=False)
|
|
195
|
+
else:
|
|
196
|
+
# Check if we can pin (not sensitive, under limit)
|
|
197
|
+
if entry.is_sensitive:
|
|
198
|
+
rumps.notification("Clipsy", "", "Cannot pin sensitive data", sound=False)
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
if self._storage.count_pinned() >= MAX_PINNED_ENTRIES:
|
|
202
|
+
rumps.notification("Clipsy", "", f"Maximum {MAX_PINNED_ENTRIES} pinned items", sound=False)
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
self._storage.toggle_pin(entry.id)
|
|
206
|
+
rumps.notification("Clipsy", "", "Pinned", sound=False)
|
|
207
|
+
|
|
208
|
+
self._refresh_menu()
|
|
209
|
+
|
|
210
|
+
def _on_clear_pinned(self, _sender) -> None:
|
|
211
|
+
"""Clear all pinned entries."""
|
|
212
|
+
self._storage.clear_pinned()
|
|
213
|
+
self._refresh_menu()
|
|
214
|
+
|
|
156
215
|
def _on_search(self, _sender) -> None:
|
|
157
216
|
response = rumps.Window(
|
|
158
217
|
message="Search clipboard history:",
|
clipsy/config.py
CHANGED
|
@@ -11,6 +11,18 @@ MAX_ENTRIES = 500 # auto-purge threshold
|
|
|
11
11
|
MAX_TEXT_SIZE = 1_000_000 # 1MB text limit
|
|
12
12
|
MAX_IMAGE_SIZE = 10_000_000 # 10MB image limit
|
|
13
13
|
PREVIEW_LENGTH = 60 # characters shown in menu item
|
|
14
|
-
|
|
14
|
+
def _parse_menu_display_count() -> int:
|
|
15
|
+
raw = os.environ.get("CLIPSY_MENU_DISPLAY_COUNT")
|
|
16
|
+
if raw is None:
|
|
17
|
+
return 10
|
|
18
|
+
try:
|
|
19
|
+
value = int(raw)
|
|
20
|
+
except ValueError:
|
|
21
|
+
return 10
|
|
22
|
+
return max(5, min(50, value))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
MENU_DISPLAY_COUNT = _parse_menu_display_count()
|
|
15
26
|
THUMBNAIL_SIZE = (32, 32) # pixels, for menu icon display
|
|
16
27
|
REDACT_SENSITIVE = True # mask sensitive data in preview (API keys, passwords, etc.)
|
|
28
|
+
MAX_PINNED_ENTRIES = 5 # maximum number of pinned entries allowed
|
clipsy/models.py
CHANGED
clipsy/monitor.py
CHANGED
|
@@ -5,6 +5,16 @@ from pathlib import Path
|
|
|
5
5
|
|
|
6
6
|
from AppKit import NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeTIFF, NSPasteboardTypeString, NSFilenamesPboardType
|
|
7
7
|
|
|
8
|
+
try:
|
|
9
|
+
from AppKit import NSPasteboardTypeRTF
|
|
10
|
+
except ImportError:
|
|
11
|
+
NSPasteboardTypeRTF = "public.rtf"
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from AppKit import NSPasteboardTypeHTML
|
|
15
|
+
except ImportError:
|
|
16
|
+
NSPasteboardTypeHTML = "public.html"
|
|
17
|
+
|
|
8
18
|
from clipsy.config import IMAGE_DIR, MAX_IMAGE_SIZE, MAX_TEXT_SIZE, PREVIEW_LENGTH, REDACT_SENSITIVE, THUMBNAIL_SIZE
|
|
9
19
|
from clipsy.models import ClipboardEntry, ContentType
|
|
10
20
|
from clipsy.redact import detect_sensitive, mask_text
|
|
@@ -57,84 +67,122 @@ class ClipboardMonitor:
|
|
|
57
67
|
return None
|
|
58
68
|
|
|
59
69
|
if NSPasteboardTypeString in types:
|
|
60
|
-
|
|
61
|
-
if
|
|
62
|
-
|
|
63
|
-
if len(text_bytes) <= MAX_TEXT_SIZE:
|
|
64
|
-
content_hash = compute_hash(text_bytes)
|
|
65
|
-
preview = truncate_text(text, PREVIEW_LENGTH)
|
|
66
|
-
|
|
67
|
-
# Detect sensitive data
|
|
68
|
-
is_sensitive = False
|
|
69
|
-
masked_preview = None
|
|
70
|
-
if REDACT_SENSITIVE:
|
|
71
|
-
matches = detect_sensitive(text)
|
|
72
|
-
if matches:
|
|
73
|
-
is_sensitive = True
|
|
74
|
-
masked_preview = truncate_text(mask_text(text, matches), PREVIEW_LENGTH)
|
|
75
|
-
|
|
76
|
-
return ClipboardEntry(
|
|
77
|
-
id=None,
|
|
78
|
-
content_type=ContentType.TEXT,
|
|
79
|
-
text_content=text,
|
|
80
|
-
image_path=None,
|
|
81
|
-
preview=preview,
|
|
82
|
-
content_hash=content_hash,
|
|
83
|
-
byte_size=len(text_bytes),
|
|
84
|
-
created_at=datetime.now(),
|
|
85
|
-
is_sensitive=is_sensitive,
|
|
86
|
-
masked_preview=masked_preview,
|
|
87
|
-
)
|
|
70
|
+
entry = self._read_text()
|
|
71
|
+
if entry:
|
|
72
|
+
return entry
|
|
88
73
|
|
|
89
74
|
for img_type in (NSPasteboardTypePNG, NSPasteboardTypeTIFF):
|
|
90
75
|
if img_type in types:
|
|
91
|
-
|
|
92
|
-
if
|
|
93
|
-
|
|
94
|
-
img_bytes = bytes(data)
|
|
95
|
-
if len(img_bytes) > MAX_IMAGE_SIZE:
|
|
96
|
-
logger.warning("Image too large (%d bytes), skipping", len(img_bytes))
|
|
97
|
-
return None
|
|
98
|
-
content_hash = compute_hash(img_bytes)
|
|
99
|
-
is_png = img_type == NSPasteboardTypePNG
|
|
100
|
-
image_path, thumbnail_path = self._save_image(img_bytes, content_hash, is_png)
|
|
101
|
-
width, height = get_image_dimensions(img_bytes)
|
|
102
|
-
preview = f"[Image: {width}x{height}]" if width > 0 else "[Image]"
|
|
103
|
-
return ClipboardEntry(
|
|
104
|
-
id=None,
|
|
105
|
-
content_type=ContentType.IMAGE,
|
|
106
|
-
text_content=None,
|
|
107
|
-
image_path=str(image_path),
|
|
108
|
-
preview=preview,
|
|
109
|
-
content_hash=content_hash,
|
|
110
|
-
byte_size=len(img_bytes),
|
|
111
|
-
created_at=datetime.now(),
|
|
112
|
-
thumbnail_path=str(thumbnail_path) if thumbnail_path else None,
|
|
113
|
-
)
|
|
76
|
+
entry = self._read_image(img_type)
|
|
77
|
+
if entry:
|
|
78
|
+
return entry
|
|
114
79
|
|
|
115
80
|
if NSFilenamesPboardType in types:
|
|
116
|
-
|
|
117
|
-
if filenames:
|
|
118
|
-
file_list = list(filenames)
|
|
119
|
-
text = "\n".join(file_list)
|
|
120
|
-
content_hash = compute_hash(text)
|
|
121
|
-
if len(file_list) == 1:
|
|
122
|
-
preview = truncate_text(Path(file_list[0]).name, PREVIEW_LENGTH)
|
|
123
|
-
else:
|
|
124
|
-
preview = truncate_text(f"{len(file_list)} files: {Path(file_list[0]).name}, ...", PREVIEW_LENGTH)
|
|
125
|
-
return ClipboardEntry(
|
|
126
|
-
id=None,
|
|
127
|
-
content_type=ContentType.FILE,
|
|
128
|
-
text_content=text,
|
|
129
|
-
image_path=None,
|
|
130
|
-
preview=preview,
|
|
131
|
-
content_hash=content_hash,
|
|
132
|
-
byte_size=len(text.encode("utf-8")),
|
|
133
|
-
created_at=datetime.now(),
|
|
134
|
-
)
|
|
81
|
+
return self._read_files()
|
|
135
82
|
|
|
136
83
|
return None
|
|
137
84
|
|
|
85
|
+
def _read_text(self) -> ClipboardEntry | None:
|
|
86
|
+
text = self._pasteboard.stringForType_(NSPasteboardTypeString)
|
|
87
|
+
if not text:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
text_bytes = text.encode("utf-8")
|
|
91
|
+
if len(text_bytes) > MAX_TEXT_SIZE:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
content_hash = compute_hash(text_bytes)
|
|
95
|
+
preview = truncate_text(text, PREVIEW_LENGTH)
|
|
96
|
+
|
|
97
|
+
rtf_data = None
|
|
98
|
+
html_data = None
|
|
99
|
+
types = self._pasteboard.types()
|
|
100
|
+
if types:
|
|
101
|
+
if NSPasteboardTypeRTF in types:
|
|
102
|
+
data = self._pasteboard.dataForType_(NSPasteboardTypeRTF)
|
|
103
|
+
if data:
|
|
104
|
+
rtf_data = bytes(data)
|
|
105
|
+
if NSPasteboardTypeHTML in types:
|
|
106
|
+
data = self._pasteboard.dataForType_(NSPasteboardTypeHTML)
|
|
107
|
+
if data:
|
|
108
|
+
html_data = bytes(data)
|
|
109
|
+
|
|
110
|
+
is_sensitive = False
|
|
111
|
+
masked_preview = None
|
|
112
|
+
if REDACT_SENSITIVE:
|
|
113
|
+
matches = detect_sensitive(text)
|
|
114
|
+
if matches:
|
|
115
|
+
is_sensitive = True
|
|
116
|
+
masked_preview = truncate_text(mask_text(text, matches), PREVIEW_LENGTH)
|
|
117
|
+
|
|
118
|
+
return ClipboardEntry(
|
|
119
|
+
id=None,
|
|
120
|
+
content_type=ContentType.TEXT,
|
|
121
|
+
text_content=text,
|
|
122
|
+
image_path=None,
|
|
123
|
+
preview=preview,
|
|
124
|
+
content_hash=content_hash,
|
|
125
|
+
byte_size=len(text_bytes),
|
|
126
|
+
created_at=datetime.now(),
|
|
127
|
+
is_sensitive=is_sensitive,
|
|
128
|
+
masked_preview=masked_preview,
|
|
129
|
+
rtf_data=rtf_data,
|
|
130
|
+
html_data=html_data,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def _read_image(self, img_type) -> ClipboardEntry | None:
|
|
134
|
+
data = self._pasteboard.dataForType_(img_type)
|
|
135
|
+
if data is None:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
img_bytes = bytes(data)
|
|
139
|
+
if len(img_bytes) > MAX_IMAGE_SIZE:
|
|
140
|
+
logger.warning("Image too large (%d bytes), skipping", len(img_bytes))
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
content_hash = compute_hash(img_bytes)
|
|
144
|
+
is_png = img_type == NSPasteboardTypePNG
|
|
145
|
+
image_path, thumbnail_path = self._save_image(img_bytes, content_hash, is_png)
|
|
146
|
+
width, height = get_image_dimensions(img_bytes)
|
|
147
|
+
preview = f"[Image: {width}x{height}]" if width > 0 else "[Image]"
|
|
148
|
+
|
|
149
|
+
return ClipboardEntry(
|
|
150
|
+
id=None,
|
|
151
|
+
content_type=ContentType.IMAGE,
|
|
152
|
+
text_content=None,
|
|
153
|
+
image_path=str(image_path),
|
|
154
|
+
preview=preview,
|
|
155
|
+
content_hash=content_hash,
|
|
156
|
+
byte_size=len(img_bytes),
|
|
157
|
+
created_at=datetime.now(),
|
|
158
|
+
thumbnail_path=str(thumbnail_path) if thumbnail_path else None,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def _read_files(self) -> ClipboardEntry | None:
|
|
162
|
+
filenames = self._pasteboard.propertyListForType_(NSFilenamesPboardType)
|
|
163
|
+
if not filenames:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
file_list = list(filenames)
|
|
167
|
+
text = "\n".join(file_list)
|
|
168
|
+
content_hash = compute_hash(text)
|
|
169
|
+
|
|
170
|
+
if len(file_list) == 1:
|
|
171
|
+
preview = truncate_text(Path(file_list[0]).name, PREVIEW_LENGTH)
|
|
172
|
+
else:
|
|
173
|
+
preview = truncate_text(f"{len(file_list)} files: {Path(file_list[0]).name}, ...", PREVIEW_LENGTH)
|
|
174
|
+
|
|
175
|
+
return ClipboardEntry(
|
|
176
|
+
id=None,
|
|
177
|
+
content_type=ContentType.FILE,
|
|
178
|
+
text_content=text,
|
|
179
|
+
image_path=None,
|
|
180
|
+
preview=preview,
|
|
181
|
+
content_hash=content_hash,
|
|
182
|
+
byte_size=len(text.encode("utf-8")),
|
|
183
|
+
created_at=datetime.now(),
|
|
184
|
+
)
|
|
185
|
+
|
|
138
186
|
def _save_image(self, img_bytes: bytes, content_hash: str, is_png: bool) -> tuple[Path, Path | None]:
|
|
139
187
|
ext = ".png" if is_png else ".tiff"
|
|
140
188
|
filename = content_hash[:12] + ext
|
clipsy/storage.py
CHANGED
|
@@ -20,7 +20,9 @@ CREATE TABLE IF NOT EXISTS clipboard_entries (
|
|
|
20
20
|
source_app TEXT,
|
|
21
21
|
thumbnail_path TEXT,
|
|
22
22
|
is_sensitive INTEGER NOT NULL DEFAULT 0,
|
|
23
|
-
masked_preview TEXT
|
|
23
|
+
masked_preview TEXT,
|
|
24
|
+
rtf_data BLOB,
|
|
25
|
+
html_data BLOB
|
|
24
26
|
);
|
|
25
27
|
|
|
26
28
|
CREATE INDEX IF NOT EXISTS idx_created_at ON clipboard_entries(created_at DESC);
|
|
@@ -70,12 +72,24 @@ class StorageManager:
|
|
|
70
72
|
self._conn.execute("ALTER TABLE clipboard_entries ADD COLUMN is_sensitive INTEGER NOT NULL DEFAULT 0")
|
|
71
73
|
if "masked_preview" not in columns:
|
|
72
74
|
self._conn.execute("ALTER TABLE clipboard_entries ADD COLUMN masked_preview TEXT")
|
|
75
|
+
if "rtf_data" not in columns:
|
|
76
|
+
self._conn.execute("ALTER TABLE clipboard_entries ADD COLUMN rtf_data BLOB")
|
|
77
|
+
if "html_data" not in columns:
|
|
78
|
+
self._conn.execute("ALTER TABLE clipboard_entries ADD COLUMN html_data BLOB")
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _delete_files(image_path: str | None, thumbnail_path: str | None) -> None:
|
|
82
|
+
for file_path in (image_path, thumbnail_path):
|
|
83
|
+
if file_path:
|
|
84
|
+
p = Path(file_path)
|
|
85
|
+
if p.exists():
|
|
86
|
+
p.unlink()
|
|
73
87
|
|
|
74
88
|
def add_entry(self, entry: ClipboardEntry) -> int:
|
|
75
89
|
cursor = self._conn.execute(
|
|
76
90
|
"""INSERT INTO clipboard_entries
|
|
77
|
-
(content_type, text_content, image_path, preview, content_hash, byte_size, created_at, pinned, source_app, thumbnail_path, is_sensitive, masked_preview)
|
|
78
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
91
|
+
(content_type, text_content, image_path, preview, content_hash, byte_size, created_at, pinned, source_app, thumbnail_path, is_sensitive, masked_preview, rtf_data, html_data)
|
|
92
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
79
93
|
(
|
|
80
94
|
entry.content_type.value,
|
|
81
95
|
entry.text_content,
|
|
@@ -89,6 +103,8 @@ class StorageManager:
|
|
|
89
103
|
entry.thumbnail_path,
|
|
90
104
|
int(entry.is_sensitive),
|
|
91
105
|
entry.masked_preview,
|
|
106
|
+
entry.rtf_data,
|
|
107
|
+
entry.html_data,
|
|
92
108
|
),
|
|
93
109
|
)
|
|
94
110
|
self._conn.commit()
|
|
@@ -124,14 +140,7 @@ class StorageManager:
|
|
|
124
140
|
def delete_entry(self, entry_id: int) -> None:
|
|
125
141
|
entry = self.get_entry(entry_id)
|
|
126
142
|
if entry:
|
|
127
|
-
|
|
128
|
-
path = Path(entry.image_path)
|
|
129
|
-
if path.exists():
|
|
130
|
-
path.unlink()
|
|
131
|
-
if entry.thumbnail_path:
|
|
132
|
-
thumb = Path(entry.thumbnail_path)
|
|
133
|
-
if thumb.exists():
|
|
134
|
-
thumb.unlink()
|
|
143
|
+
self._delete_files(entry.image_path, entry.thumbnail_path)
|
|
135
144
|
self._conn.execute("DELETE FROM clipboard_entries WHERE id = ?", (entry_id,))
|
|
136
145
|
self._conn.commit()
|
|
137
146
|
|
|
@@ -169,14 +178,7 @@ class StorageManager:
|
|
|
169
178
|
|
|
170
179
|
deleted = 0
|
|
171
180
|
for row in rows:
|
|
172
|
-
|
|
173
|
-
path = Path(row["image_path"])
|
|
174
|
-
if path.exists():
|
|
175
|
-
path.unlink()
|
|
176
|
-
if row["thumbnail_path"]:
|
|
177
|
-
thumb = Path(row["thumbnail_path"])
|
|
178
|
-
if thumb.exists():
|
|
179
|
-
thumb.unlink()
|
|
181
|
+
self._delete_files(row["image_path"], row["thumbnail_path"])
|
|
180
182
|
self._conn.execute("DELETE FROM clipboard_entries WHERE id = ?", (row["id"],))
|
|
181
183
|
deleted += 1
|
|
182
184
|
|
|
@@ -201,14 +203,7 @@ class StorageManager:
|
|
|
201
203
|
"SELECT image_path, thumbnail_path FROM clipboard_entries WHERE image_path IS NOT NULL OR thumbnail_path IS NOT NULL"
|
|
202
204
|
).fetchall()
|
|
203
205
|
for row in rows:
|
|
204
|
-
|
|
205
|
-
path = Path(row["image_path"])
|
|
206
|
-
if path.exists():
|
|
207
|
-
path.unlink()
|
|
208
|
-
if row["thumbnail_path"]:
|
|
209
|
-
thumb = Path(row["thumbnail_path"])
|
|
210
|
-
if thumb.exists():
|
|
211
|
-
thumb.unlink()
|
|
206
|
+
self._delete_files(row["image_path"], row["thumbnail_path"])
|
|
212
207
|
self._conn.execute("DELETE FROM clipboard_entries")
|
|
213
208
|
self._conn.commit()
|
|
214
209
|
|
|
@@ -216,6 +211,21 @@ class StorageManager:
|
|
|
216
211
|
row = self._conn.execute("SELECT COUNT(*) as cnt FROM clipboard_entries").fetchone()
|
|
217
212
|
return row["cnt"]
|
|
218
213
|
|
|
214
|
+
def get_pinned(self) -> list[ClipboardEntry]:
|
|
215
|
+
rows = self._conn.execute(
|
|
216
|
+
"SELECT * FROM clipboard_entries WHERE pinned = 1 ORDER BY created_at DESC"
|
|
217
|
+
).fetchall()
|
|
218
|
+
return [self._row_to_entry(r) for r in rows]
|
|
219
|
+
|
|
220
|
+
def count_pinned(self) -> int:
|
|
221
|
+
row = self._conn.execute("SELECT COUNT(*) as cnt FROM clipboard_entries WHERE pinned = 1").fetchone()
|
|
222
|
+
return row["cnt"]
|
|
223
|
+
|
|
224
|
+
def clear_pinned(self) -> None:
|
|
225
|
+
"""Unpin all pinned entries."""
|
|
226
|
+
self._conn.execute("UPDATE clipboard_entries SET pinned = 0 WHERE pinned = 1")
|
|
227
|
+
self._conn.commit()
|
|
228
|
+
|
|
219
229
|
def close(self) -> None:
|
|
220
230
|
self._conn.close()
|
|
221
231
|
|
|
@@ -241,6 +251,8 @@ class StorageManager:
|
|
|
241
251
|
thumbnail_path = row["thumbnail_path"] if "thumbnail_path" in keys else None
|
|
242
252
|
is_sensitive = bool(row["is_sensitive"]) if "is_sensitive" in keys else False
|
|
243
253
|
masked_preview = row["masked_preview"] if "masked_preview" in keys else None
|
|
254
|
+
rtf_data = bytes(row["rtf_data"]) if "rtf_data" in keys and row["rtf_data"] is not None else None
|
|
255
|
+
html_data = bytes(row["html_data"]) if "html_data" in keys and row["html_data"] is not None else None
|
|
244
256
|
return ClipboardEntry(
|
|
245
257
|
id=row["id"],
|
|
246
258
|
content_type=ContentType(row["content_type"]),
|
|
@@ -255,4 +267,6 @@ class StorageManager:
|
|
|
255
267
|
thumbnail_path=thumbnail_path,
|
|
256
268
|
is_sensitive=is_sensitive,
|
|
257
269
|
masked_preview=masked_preview,
|
|
270
|
+
rtf_data=rtf_data,
|
|
271
|
+
html_data=html_data,
|
|
258
272
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clipsy
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.9.0
|
|
4
4
|
Summary: Lightweight clipboard history manager for macOS
|
|
5
5
|
Author-email: Brendan Conrad <brendan.conrad@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -46,6 +46,8 @@ A lightweight clipboard history manager for macOS. Runs as a menu bar icon — n
|
|
|
46
46
|
- **Image thumbnails** — Visual previews for copied images in the menu
|
|
47
47
|
- **Sensitive data masking** — Auto-detects API keys, passwords, SSNs, credit cards, private keys, and tokens; displays masked previews with 🔒 icon
|
|
48
48
|
- **Search** — Full-text search across all clipboard entries (SQLite FTS5)
|
|
49
|
+
- **Rich text preservation** — Preserves RTF and HTML formatting when re-copying from history (e.g., bold, italic, links from web pages)
|
|
50
|
+
- **Pin favorites** — Option-click to pin up to 5 frequently-used snippets (sensitive data cannot be pinned)
|
|
49
51
|
- **Click to re-copy** — Click any entry in the menu to put it back on your clipboard
|
|
50
52
|
- **Deduplication** — Copying the same content twice bumps it to the top instead of creating a duplicate
|
|
51
53
|
- **Auto-purge** — Keeps the most recent 500 entries, automatically cleans up old ones
|
|
@@ -59,7 +61,14 @@ A lightweight clipboard history manager for macOS. Runs as a menu bar icon — n
|
|
|
59
61
|
|
|
60
62
|
## Installation
|
|
61
63
|
|
|
62
|
-
### Via
|
|
64
|
+
### Via Homebrew (recommended)
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
brew install brencon/clipsy/clipsy
|
|
68
|
+
clipsy
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Via pipx
|
|
63
72
|
|
|
64
73
|
```bash
|
|
65
74
|
brew install pipx
|
|
@@ -96,11 +105,15 @@ Then just use your Mac normally. Every time you copy something, it shows up in t
|
|
|
96
105
|
├── ──────────────────
|
|
97
106
|
├── Search...
|
|
98
107
|
├── ──────────────────
|
|
108
|
+
├── 📌 Pinned ►
|
|
109
|
+
│ ├── "my-api-endpoint.com/v1..."
|
|
110
|
+
│ └── "SELECT * FROM users..."
|
|
111
|
+
├── ──────────────────
|
|
99
112
|
├── "Meeting notes for Q3 plan..."
|
|
100
113
|
├── "https://github.com/example..."
|
|
101
114
|
├── 🔒 "password=••••••••"
|
|
102
115
|
├── [thumbnail] "[Image: 1920x1080]"
|
|
103
|
-
├── ... (up to 10 items)
|
|
116
|
+
├── ... (up to 10 items, configurable)
|
|
104
117
|
├── ──────────────────
|
|
105
118
|
├── Clear History
|
|
106
119
|
├── ──────────────────
|
|
@@ -109,6 +122,8 @@ Then just use your Mac normally. Every time you copy something, it shows up in t
|
|
|
109
122
|
└── Quit Clipsy
|
|
110
123
|
```
|
|
111
124
|
|
|
125
|
+
**Tip:** Hold **Option (⌥)** while clicking an entry to pin/unpin it.
|
|
126
|
+
|
|
112
127
|
## Commands
|
|
113
128
|
|
|
114
129
|
```bash
|
|
@@ -118,6 +133,17 @@ clipsy uninstall # Remove from login items
|
|
|
118
133
|
clipsy run # Run in foreground (for debugging)
|
|
119
134
|
```
|
|
120
135
|
|
|
136
|
+
## Configuration
|
|
137
|
+
|
|
138
|
+
| Variable | Default | Range | Description |
|
|
139
|
+
|----------|---------|-------|-------------|
|
|
140
|
+
| `CLIPSY_MENU_DISPLAY_COUNT` | `10` | 5–50 | Number of entries shown in the menu |
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
# Example: show 20 entries in the menu
|
|
144
|
+
export CLIPSY_MENU_DISPLAY_COUNT=20
|
|
145
|
+
```
|
|
146
|
+
|
|
121
147
|
## Data Storage
|
|
122
148
|
|
|
123
149
|
All data is stored in `~/.local/share/clipsy/`:
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
clipsy/__init__.py,sha256=AnsrZyNwA76HMWFc_Q7iDYH8Citiqm2lXLiZFzPs7uQ,46
|
|
2
|
+
clipsy/__main__.py,sha256=AzUjrEhnuVX5Mkk3QhfmAO17cyvIYOABsz7QWYmw1r8,4991
|
|
3
|
+
clipsy/app.py,sha256=scM-po0Kw1SOg_pDlMxsgbzhDQWS4BAvv5Iq64GLJEc,9861
|
|
4
|
+
clipsy/config.py,sha256=NOElM7jJ73AWQ1sf09dxg82-1nGErXd1zT63v2MPGpA,980
|
|
5
|
+
clipsy/models.py,sha256=9wuaq1t9HhaH8QYuieN7fEVM_RTYEvv6ZUCM_InOMKk,632
|
|
6
|
+
clipsy/monitor.py,sha256=PBTxK53arFm5VVCHIYlxz8eoYQrgTwWsIWNE49IE7oU,6904
|
|
7
|
+
clipsy/redact.py,sha256=EU2nq8oEDYLZ_I_9tX2bXACIF971a0V_ApKdvpN4VGY,7284
|
|
8
|
+
clipsy/storage.py,sha256=WE3EeOLcSgw-0it1qPk5rS-vDRC3-XDILaMZquBFR5Y,10659
|
|
9
|
+
clipsy/utils.py,sha256=rztT20cXYJ7633q3oLwt6Fv0Ubq9hm5bPPKDkWyvxdI,2208
|
|
10
|
+
clipsy-1.9.0.dist-info/licenses/LICENSE,sha256=bZ7uVihgPnLGryi5ugUgkVrEoho0_hFAd3bVdhYXGqs,1071
|
|
11
|
+
clipsy-1.9.0.dist-info/METADATA,sha256=tJ8uQW6-pRU2JAyAnO3dpr_ix9zFUpS6NvizCYZAD4Y,6680
|
|
12
|
+
clipsy-1.9.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
13
|
+
clipsy-1.9.0.dist-info/entry_points.txt,sha256=ISIoWU3Zj-4NEuj2pJW96LxpsvjRJJH73otv9gA9YSs,48
|
|
14
|
+
clipsy-1.9.0.dist-info/top_level.txt,sha256=trxprVJk4ZMudCshc7PD0N9iFgQO4Tq4sW5L5wLduns,7
|
|
15
|
+
clipsy-1.9.0.dist-info/RECORD,,
|
clipsy-1.7.1.dist-info/RECORD
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
clipsy/__init__.py,sha256=SiyI4J0QW-zHfQ-u7WyAEHr27xsi2W5hxIJPHCKqMq4,46
|
|
2
|
-
clipsy/__main__.py,sha256=AzUjrEhnuVX5Mkk3QhfmAO17cyvIYOABsz7QWYmw1r8,4991
|
|
3
|
-
clipsy/app.py,sha256=_IKuZgA-DvAE3KDsDN00S0aaMIxwBpiv_P1fpzwQFqU,7299
|
|
4
|
-
clipsy/config.py,sha256=E9NCsGMeT2H0YXYCq5xYwbd27VkksV4S9txZSMX0RmE,672
|
|
5
|
-
clipsy/models.py,sha256=i3KjD4Dcp7y_gTXkIBr2xZxFIIC6kHaMWmv6cSIDABM,563
|
|
6
|
-
clipsy/monitor.py,sha256=UUKZToYcZ5pzu7LfiuJ_nnE4AA-d3uLW-WdQU_XrDME,6288
|
|
7
|
-
clipsy/redact.py,sha256=EU2nq8oEDYLZ_I_9tX2bXACIF971a0V_ApKdvpN4VGY,7284
|
|
8
|
-
clipsy/storage.py,sha256=QNox2-spkVx_gMPSWqKgF_yj-SMpRJ4UAT6yd9l6rVY,9794
|
|
9
|
-
clipsy/utils.py,sha256=rztT20cXYJ7633q3oLwt6Fv0Ubq9hm5bPPKDkWyvxdI,2208
|
|
10
|
-
clipsy-1.7.1.dist-info/licenses/LICENSE,sha256=bZ7uVihgPnLGryi5ugUgkVrEoho0_hFAd3bVdhYXGqs,1071
|
|
11
|
-
clipsy-1.7.1.dist-info/METADATA,sha256=o3ccHVdbPvMkh49ZfyqM4WHnCn8YJoEolpQDJ0uj74A,5811
|
|
12
|
-
clipsy-1.7.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
13
|
-
clipsy-1.7.1.dist-info/entry_points.txt,sha256=ISIoWU3Zj-4NEuj2pJW96LxpsvjRJJH73otv9gA9YSs,48
|
|
14
|
-
clipsy-1.7.1.dist-info/top_level.txt,sha256=trxprVJk4ZMudCshc7PD0N9iFgQO4Tq4sW5L5wLduns,7
|
|
15
|
-
clipsy-1.7.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|