clipsy 1.5.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 +2 -0
- clipsy/__main__.py +26 -0
- clipsy/app.py +199 -0
- clipsy/config.py +16 -0
- clipsy/models.py +26 -0
- clipsy/monitor.py +153 -0
- clipsy/redact.py +211 -0
- clipsy/storage.py +258 -0
- clipsy/utils.py +72 -0
- clipsy-1.5.0.dist-info/METADATA +166 -0
- clipsy-1.5.0.dist-info/RECORD +15 -0
- clipsy-1.5.0.dist-info/WHEEL +5 -0
- clipsy-1.5.0.dist-info/entry_points.txt +2 -0
- clipsy-1.5.0.dist-info/licenses/LICENSE +21 -0
- clipsy-1.5.0.dist-info/top_level.txt +1 -0
clipsy/__init__.py
ADDED
clipsy/__main__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from clipsy.config import LOG_PATH
|
|
5
|
+
from clipsy.utils import ensure_dirs
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
ensure_dirs()
|
|
10
|
+
|
|
11
|
+
logging.basicConfig(
|
|
12
|
+
level=logging.INFO,
|
|
13
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
14
|
+
handlers=[
|
|
15
|
+
logging.FileHandler(LOG_PATH),
|
|
16
|
+
logging.StreamHandler(sys.stderr),
|
|
17
|
+
],
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from clipsy.app import ClipsyApp
|
|
21
|
+
app = ClipsyApp()
|
|
22
|
+
app.run()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if __name__ == "__main__":
|
|
26
|
+
main()
|
clipsy/app.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import webbrowser
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import rumps
|
|
6
|
+
|
|
7
|
+
from clipsy import __version__
|
|
8
|
+
from clipsy.config import DB_PATH, IMAGE_DIR, MENU_DISPLAY_COUNT, POLL_INTERVAL, REDACT_SENSITIVE, THUMBNAIL_SIZE
|
|
9
|
+
from clipsy.models import ClipboardEntry, ContentType
|
|
10
|
+
from clipsy.monitor import ClipboardMonitor
|
|
11
|
+
from clipsy.storage import StorageManager
|
|
12
|
+
from clipsy.utils import create_thumbnail, ensure_dirs
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
ENTRY_KEY_PREFIX = "clipsy_entry_"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ClipsyApp(rumps.App):
|
|
20
|
+
def __init__(self):
|
|
21
|
+
super().__init__("Clipsy", title="✂️", quit_button=None)
|
|
22
|
+
ensure_dirs()
|
|
23
|
+
self._storage = StorageManager(DB_PATH)
|
|
24
|
+
self._monitor = ClipboardMonitor(self._storage, on_change=self._refresh_menu)
|
|
25
|
+
self._entry_ids: dict[str, int] = {}
|
|
26
|
+
self._build_menu()
|
|
27
|
+
|
|
28
|
+
def _build_menu(self) -> None:
|
|
29
|
+
self.menu.clear()
|
|
30
|
+
self.menu = [
|
|
31
|
+
rumps.MenuItem(f"Clipsy v{__version__} - Clipboard History", callback=None),
|
|
32
|
+
None, # separator
|
|
33
|
+
rumps.MenuItem("Search...", callback=self._on_search),
|
|
34
|
+
None, # separator
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
entries = self._storage.get_recent(limit=MENU_DISPLAY_COUNT)
|
|
38
|
+
self._entry_ids.clear()
|
|
39
|
+
|
|
40
|
+
if not entries:
|
|
41
|
+
self.menu.add(rumps.MenuItem("(No clipboard history)", callback=None))
|
|
42
|
+
else:
|
|
43
|
+
for entry in entries:
|
|
44
|
+
self.menu.add(self._create_entry_menu_item(entry))
|
|
45
|
+
|
|
46
|
+
self.menu.add(None) # separator
|
|
47
|
+
self.menu.add(rumps.MenuItem("Clear History", callback=self._on_clear))
|
|
48
|
+
self.menu.add(None) # separator
|
|
49
|
+
self.menu.add(rumps.MenuItem("Support Clipsy", callback=self._on_support))
|
|
50
|
+
self.menu.add(None) # separator
|
|
51
|
+
self.menu.add(rumps.MenuItem("Quit Clipsy", callback=self._on_quit))
|
|
52
|
+
|
|
53
|
+
def _create_entry_menu_item(self, entry: ClipboardEntry) -> rumps.MenuItem:
|
|
54
|
+
"""Create a menu item for a clipboard entry."""
|
|
55
|
+
key = f"{ENTRY_KEY_PREFIX}{entry.id}"
|
|
56
|
+
self._entry_ids[key] = entry.id
|
|
57
|
+
display_text = self._get_display_preview(entry)
|
|
58
|
+
|
|
59
|
+
if entry.content_type == ContentType.IMAGE:
|
|
60
|
+
thumb_path = self._ensure_thumbnail(entry)
|
|
61
|
+
if thumb_path:
|
|
62
|
+
item = rumps.MenuItem(
|
|
63
|
+
display_text,
|
|
64
|
+
callback=self._on_entry_click,
|
|
65
|
+
icon=thumb_path,
|
|
66
|
+
dimensions=(32, 32),
|
|
67
|
+
template=False,
|
|
68
|
+
)
|
|
69
|
+
else:
|
|
70
|
+
item = rumps.MenuItem(display_text, callback=self._on_entry_click)
|
|
71
|
+
else:
|
|
72
|
+
item = rumps.MenuItem(display_text, callback=self._on_entry_click)
|
|
73
|
+
|
|
74
|
+
item._id = key
|
|
75
|
+
return item
|
|
76
|
+
|
|
77
|
+
def _get_display_preview(self, entry: ClipboardEntry) -> str:
|
|
78
|
+
"""Get the display preview for an entry, masking sensitive data if enabled."""
|
|
79
|
+
if REDACT_SENSITIVE and entry.is_sensitive and entry.masked_preview:
|
|
80
|
+
return f"🔒 {entry.masked_preview}"
|
|
81
|
+
return entry.preview
|
|
82
|
+
|
|
83
|
+
def _ensure_thumbnail(self, entry: ClipboardEntry) -> str | None:
|
|
84
|
+
"""Ensure a thumbnail exists for an image entry, generating if needed."""
|
|
85
|
+
if entry.thumbnail_path:
|
|
86
|
+
return entry.thumbnail_path
|
|
87
|
+
|
|
88
|
+
if not entry.image_path:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
# Generate thumbnail for legacy entries
|
|
92
|
+
image_path = Path(entry.image_path)
|
|
93
|
+
if not image_path.exists():
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
thumb_filename = image_path.stem + "_thumb.png"
|
|
97
|
+
thumb_path = IMAGE_DIR / thumb_filename
|
|
98
|
+
|
|
99
|
+
if thumb_path.exists() or create_thumbnail(str(image_path), str(thumb_path), THUMBNAIL_SIZE):
|
|
100
|
+
self._storage.update_thumbnail_path(entry.id, str(thumb_path))
|
|
101
|
+
return str(thumb_path)
|
|
102
|
+
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
def _refresh_menu(self) -> None:
|
|
106
|
+
self._build_menu()
|
|
107
|
+
|
|
108
|
+
@rumps.timer(POLL_INTERVAL)
|
|
109
|
+
def _poll_clipboard(self, _sender) -> None:
|
|
110
|
+
self._monitor.check_clipboard()
|
|
111
|
+
|
|
112
|
+
def _on_entry_click(self, sender) -> None:
|
|
113
|
+
entry_id = self._entry_ids.get(getattr(sender, "_id", ""))
|
|
114
|
+
if entry_id is None:
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
entry = self._storage.get_entry(entry_id)
|
|
118
|
+
if entry is None:
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
from AppKit import NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeString
|
|
123
|
+
|
|
124
|
+
pb = NSPasteboard.generalPasteboard()
|
|
125
|
+
|
|
126
|
+
copied = False
|
|
127
|
+
|
|
128
|
+
if entry.content_type == ContentType.TEXT and entry.text_content:
|
|
129
|
+
pb.clearContents()
|
|
130
|
+
pb.setString_forType_(entry.text_content, NSPasteboardTypeString)
|
|
131
|
+
self._monitor.sync_change_count()
|
|
132
|
+
copied = True
|
|
133
|
+
|
|
134
|
+
elif entry.content_type == ContentType.IMAGE and entry.image_path:
|
|
135
|
+
from Foundation import NSData
|
|
136
|
+
img_data = NSData.dataWithContentsOfFile_(entry.image_path)
|
|
137
|
+
if img_data:
|
|
138
|
+
pb.clearContents()
|
|
139
|
+
pb.setData_forType_(img_data, NSPasteboardTypePNG)
|
|
140
|
+
self._monitor.sync_change_count()
|
|
141
|
+
copied = True
|
|
142
|
+
|
|
143
|
+
elif entry.content_type == ContentType.FILE and entry.text_content:
|
|
144
|
+
pb.clearContents()
|
|
145
|
+
pb.setString_forType_(entry.text_content, NSPasteboardTypeString)
|
|
146
|
+
self._monitor.sync_change_count()
|
|
147
|
+
copied = True
|
|
148
|
+
|
|
149
|
+
if copied:
|
|
150
|
+
self._storage.update_timestamp(entry_id)
|
|
151
|
+
self._refresh_menu()
|
|
152
|
+
rumps.notification("Clipsy", "", "Copied to clipboard", sound=False)
|
|
153
|
+
except Exception:
|
|
154
|
+
logger.exception("Error copying entry to clipboard")
|
|
155
|
+
|
|
156
|
+
def _on_search(self, _sender) -> None:
|
|
157
|
+
response = rumps.Window(
|
|
158
|
+
message="Search clipboard history:",
|
|
159
|
+
title="Clipsy Search",
|
|
160
|
+
default_text="",
|
|
161
|
+
ok="Search",
|
|
162
|
+
cancel="Cancel",
|
|
163
|
+
dimensions=(300, 24),
|
|
164
|
+
).run()
|
|
165
|
+
|
|
166
|
+
if response.clicked and response.text.strip():
|
|
167
|
+
query = response.text.strip()
|
|
168
|
+
results = self._storage.search(query, limit=MENU_DISPLAY_COUNT)
|
|
169
|
+
|
|
170
|
+
if not results:
|
|
171
|
+
rumps.alert("Clipsy Search", f'No results for "{query}"')
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
self.menu.clear()
|
|
175
|
+
self._entry_ids.clear()
|
|
176
|
+
self.menu = [
|
|
177
|
+
rumps.MenuItem(f'Search: "{query}" ({len(results)} results)', callback=None),
|
|
178
|
+
None,
|
|
179
|
+
rumps.MenuItem("Show All", callback=lambda _: self._refresh_menu()),
|
|
180
|
+
None,
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
for entry in results:
|
|
184
|
+
self.menu.add(self._create_entry_menu_item(entry))
|
|
185
|
+
|
|
186
|
+
self.menu.add(None)
|
|
187
|
+
self.menu.add(rumps.MenuItem("Quit Clipsy", callback=self._on_quit))
|
|
188
|
+
|
|
189
|
+
def _on_clear(self, _sender) -> None:
|
|
190
|
+
if rumps.alert("Clipsy", "Clear all clipboard history?", ok="Clear", cancel="Cancel"):
|
|
191
|
+
self._storage.clear_all()
|
|
192
|
+
self._refresh_menu()
|
|
193
|
+
|
|
194
|
+
def _on_support(self, _sender) -> None:
|
|
195
|
+
webbrowser.open("https://github.com/sponsors/brencon")
|
|
196
|
+
|
|
197
|
+
def _on_quit(self, _sender) -> None:
|
|
198
|
+
self._storage.close()
|
|
199
|
+
rumps.quit_application()
|
clipsy/config.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
DATA_DIR = Path(os.environ.get("CLIPSY_DATA_DIR", Path.home() / ".local" / "share" / "clipsy"))
|
|
5
|
+
DB_PATH = DATA_DIR / "clipsy.db"
|
|
6
|
+
IMAGE_DIR = DATA_DIR / "images"
|
|
7
|
+
LOG_PATH = DATA_DIR / "clipsy.log"
|
|
8
|
+
|
|
9
|
+
POLL_INTERVAL = 0.5 # seconds between clipboard checks
|
|
10
|
+
MAX_ENTRIES = 500 # auto-purge threshold
|
|
11
|
+
MAX_TEXT_SIZE = 1_000_000 # 1MB text limit
|
|
12
|
+
MAX_IMAGE_SIZE = 10_000_000 # 10MB image limit
|
|
13
|
+
PREVIEW_LENGTH = 60 # characters shown in menu item
|
|
14
|
+
MENU_DISPLAY_COUNT = 10 # items shown in dropdown
|
|
15
|
+
THUMBNAIL_SIZE = (32, 32) # pixels, for menu icon display
|
|
16
|
+
REDACT_SENSITIVE = True # mask sensitive data in preview (API keys, passwords, etc.)
|
clipsy/models.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ContentType(str, Enum):
|
|
7
|
+
TEXT = "text"
|
|
8
|
+
IMAGE = "image"
|
|
9
|
+
FILE = "file"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ClipboardEntry:
|
|
14
|
+
id: int | None
|
|
15
|
+
content_type: ContentType
|
|
16
|
+
text_content: str | None
|
|
17
|
+
image_path: str | None
|
|
18
|
+
preview: str
|
|
19
|
+
content_hash: str
|
|
20
|
+
byte_size: int
|
|
21
|
+
created_at: datetime
|
|
22
|
+
pinned: bool = False
|
|
23
|
+
source_app: str | None = None
|
|
24
|
+
thumbnail_path: str | None = None
|
|
25
|
+
is_sensitive: bool = False
|
|
26
|
+
masked_preview: str | None = None
|
clipsy/monitor.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from AppKit import NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeTIFF, NSPasteboardTypeString, NSFilenamesPboardType
|
|
7
|
+
|
|
8
|
+
from clipsy.config import IMAGE_DIR, MAX_IMAGE_SIZE, MAX_TEXT_SIZE, PREVIEW_LENGTH, REDACT_SENSITIVE, THUMBNAIL_SIZE
|
|
9
|
+
from clipsy.models import ClipboardEntry, ContentType
|
|
10
|
+
from clipsy.redact import detect_sensitive, mask_text
|
|
11
|
+
from clipsy.storage import StorageManager
|
|
12
|
+
from clipsy.utils import compute_hash, create_thumbnail, ensure_dirs, get_image_dimensions, truncate_text
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ClipboardMonitor:
|
|
18
|
+
def __init__(self, storage: StorageManager, on_change: Callable[[], None] | None = None):
|
|
19
|
+
self._storage = storage
|
|
20
|
+
self._on_change = on_change
|
|
21
|
+
self._pasteboard = NSPasteboard.generalPasteboard()
|
|
22
|
+
self._last_change_count = self._pasteboard.changeCount()
|
|
23
|
+
ensure_dirs()
|
|
24
|
+
|
|
25
|
+
def check_clipboard(self) -> bool:
|
|
26
|
+
current_count = self._pasteboard.changeCount()
|
|
27
|
+
if current_count == self._last_change_count:
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
self._last_change_count = current_count
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
entry = self._read_clipboard()
|
|
34
|
+
if entry is None:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
existing = self._storage.find_by_hash(entry.content_hash)
|
|
38
|
+
if existing:
|
|
39
|
+
self._storage.update_timestamp(existing.id)
|
|
40
|
+
else:
|
|
41
|
+
self._storage.add_entry(entry)
|
|
42
|
+
self._storage.purge_old()
|
|
43
|
+
|
|
44
|
+
if self._on_change:
|
|
45
|
+
self._on_change()
|
|
46
|
+
return True
|
|
47
|
+
except Exception:
|
|
48
|
+
logger.exception("Error reading clipboard")
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
def sync_change_count(self) -> None:
|
|
52
|
+
self._last_change_count = self._pasteboard.changeCount()
|
|
53
|
+
|
|
54
|
+
def _read_clipboard(self) -> ClipboardEntry | None:
|
|
55
|
+
types = self._pasteboard.types()
|
|
56
|
+
if types is None:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
if NSPasteboardTypeString in types:
|
|
60
|
+
text = self._pasteboard.stringForType_(NSPasteboardTypeString)
|
|
61
|
+
if text:
|
|
62
|
+
text_bytes = text.encode("utf-8")
|
|
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
|
+
)
|
|
88
|
+
|
|
89
|
+
for img_type in (NSPasteboardTypePNG, NSPasteboardTypeTIFF):
|
|
90
|
+
if img_type in types:
|
|
91
|
+
data = self._pasteboard.dataForType_(img_type)
|
|
92
|
+
if data is None:
|
|
93
|
+
continue
|
|
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
|
+
)
|
|
114
|
+
|
|
115
|
+
if NSFilenamesPboardType in types:
|
|
116
|
+
filenames = self._pasteboard.propertyListForType_(NSFilenamesPboardType)
|
|
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
|
+
)
|
|
135
|
+
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
def _save_image(self, img_bytes: bytes, content_hash: str, is_png: bool) -> tuple[Path, Path | None]:
|
|
139
|
+
ext = ".png" if is_png else ".tiff"
|
|
140
|
+
filename = content_hash[:12] + ext
|
|
141
|
+
path = IMAGE_DIR / filename
|
|
142
|
+
if not path.exists():
|
|
143
|
+
path.write_bytes(img_bytes)
|
|
144
|
+
|
|
145
|
+
# Generate thumbnail
|
|
146
|
+
thumb_filename = content_hash[:12] + "_thumb.png"
|
|
147
|
+
thumb_path = IMAGE_DIR / thumb_filename
|
|
148
|
+
if not thumb_path.exists():
|
|
149
|
+
if create_thumbnail(str(path), str(thumb_path), THUMBNAIL_SIZE):
|
|
150
|
+
return path, thumb_path
|
|
151
|
+
else:
|
|
152
|
+
return path, None
|
|
153
|
+
return path, thumb_path
|
clipsy/redact.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Sensitive data detection and masking for clipboard entries."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SensitiveType(Enum):
|
|
9
|
+
"""Types of sensitive data that can be detected."""
|
|
10
|
+
|
|
11
|
+
API_KEY = "api_key"
|
|
12
|
+
PASSWORD = "password"
|
|
13
|
+
SSN = "ssn"
|
|
14
|
+
CREDIT_CARD = "credit_card"
|
|
15
|
+
PRIVATE_KEY = "private_key"
|
|
16
|
+
CERTIFICATE = "certificate"
|
|
17
|
+
TOKEN = "token"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class SensitiveMatch:
|
|
22
|
+
"""A detected sensitive data match."""
|
|
23
|
+
|
|
24
|
+
sensitive_type: SensitiveType
|
|
25
|
+
start: int
|
|
26
|
+
end: int
|
|
27
|
+
original: str
|
|
28
|
+
masked: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Pattern definitions for sensitive data detection
|
|
32
|
+
PATTERNS: dict[SensitiveType, list[re.Pattern]] = {
|
|
33
|
+
SensitiveType.API_KEY: [
|
|
34
|
+
re.compile(r"sk-[a-zA-Z0-9]{20,}", re.ASCII), # OpenAI
|
|
35
|
+
re.compile(r"sk-proj-[a-zA-Z0-9_-]{20,}", re.ASCII), # OpenAI project keys
|
|
36
|
+
re.compile(r"AKIA[A-Z0-9]{16}", re.ASCII), # AWS Access Key
|
|
37
|
+
re.compile(r"ghp_[a-zA-Z0-9]{36}", re.ASCII), # GitHub PAT
|
|
38
|
+
re.compile(r"gho_[a-zA-Z0-9]{36}", re.ASCII), # GitHub OAuth
|
|
39
|
+
re.compile(r"github_pat_[a-zA-Z0-9_]{22,}", re.ASCII), # GitHub fine-grained PAT
|
|
40
|
+
re.compile(r"xox[baprs]-[a-zA-Z0-9-]{10,}", re.ASCII), # Slack tokens
|
|
41
|
+
re.compile(r"AIza[a-zA-Z0-9_-]{35}", re.ASCII), # Google API Key
|
|
42
|
+
re.compile(r"sq0[a-z]{3}-[a-zA-Z0-9_-]{22,}", re.ASCII), # Square
|
|
43
|
+
re.compile(r"sk_live_[a-zA-Z0-9]{24,}", re.ASCII), # Stripe live
|
|
44
|
+
re.compile(r"sk_test_[a-zA-Z0-9]{24,}", re.ASCII), # Stripe test
|
|
45
|
+
re.compile(r"rk_live_[a-zA-Z0-9]{24,}", re.ASCII), # Stripe restricted
|
|
46
|
+
re.compile(r"pk_live_[a-zA-Z0-9]{24,}", re.ASCII), # Stripe publishable
|
|
47
|
+
re.compile(r"pk_test_[a-zA-Z0-9]{24,}", re.ASCII), # Stripe test publishable
|
|
48
|
+
],
|
|
49
|
+
SensitiveType.PASSWORD: [
|
|
50
|
+
re.compile(r"(?:password|passwd|pwd|pass|secret|token|api_key|apikey|auth)[=:\s]+['\"]?(\S{6,})['\"]?", re.IGNORECASE),
|
|
51
|
+
],
|
|
52
|
+
SensitiveType.SSN: [
|
|
53
|
+
re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), # 123-45-6789
|
|
54
|
+
re.compile(r"\b\d{9}\b"), # 123456789 (only if looks like SSN context)
|
|
55
|
+
],
|
|
56
|
+
SensitiveType.CREDIT_CARD: [
|
|
57
|
+
re.compile(r"\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b"), # 16 digits
|
|
58
|
+
re.compile(r"\b\d{4}[- ]?\d{6}[- ]?\d{5}\b"), # Amex format
|
|
59
|
+
],
|
|
60
|
+
SensitiveType.PRIVATE_KEY: [
|
|
61
|
+
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----", re.ASCII),
|
|
62
|
+
re.compile(r"-----BEGIN RSA PRIVATE KEY-----", re.ASCII),
|
|
63
|
+
re.compile(r"-----BEGIN EC PRIVATE KEY-----", re.ASCII),
|
|
64
|
+
re.compile(r"-----BEGIN OPENSSH PRIVATE KEY-----", re.ASCII),
|
|
65
|
+
],
|
|
66
|
+
SensitiveType.CERTIFICATE: [
|
|
67
|
+
re.compile(r"-----BEGIN CERTIFICATE-----", re.ASCII),
|
|
68
|
+
re.compile(r"-----BEGIN X509 CERTIFICATE-----", re.ASCII),
|
|
69
|
+
],
|
|
70
|
+
SensitiveType.TOKEN: [
|
|
71
|
+
re.compile(r"eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}", re.ASCII), # JWT
|
|
72
|
+
re.compile(r"Bearer\s+[a-zA-Z0-9_-]{20,}", re.ASCII), # Bearer token
|
|
73
|
+
],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _mask_value(value: str, sensitive_type: SensitiveType) -> str:
|
|
78
|
+
"""Mask a sensitive value based on its type."""
|
|
79
|
+
if sensitive_type == SensitiveType.SSN:
|
|
80
|
+
# Show last 4 digits: •••-••-6789
|
|
81
|
+
if "-" in value:
|
|
82
|
+
return "•••-••-" + value[-4:]
|
|
83
|
+
return "•••••" + value[-4:]
|
|
84
|
+
|
|
85
|
+
if sensitive_type == SensitiveType.CREDIT_CARD:
|
|
86
|
+
# Show last 4 digits: ••••-••••-••••-1234
|
|
87
|
+
digits = re.sub(r"[- ]", "", value)
|
|
88
|
+
return "••••-••••-••••-" + digits[-4:]
|
|
89
|
+
|
|
90
|
+
if sensitive_type in (SensitiveType.PRIVATE_KEY, SensitiveType.CERTIFICATE):
|
|
91
|
+
# Just indicate the type
|
|
92
|
+
if "PRIVATE KEY" in value:
|
|
93
|
+
return "[Private Key]"
|
|
94
|
+
return "[Certificate]"
|
|
95
|
+
|
|
96
|
+
if sensitive_type == SensitiveType.PASSWORD:
|
|
97
|
+
return "••••••••"
|
|
98
|
+
|
|
99
|
+
if sensitive_type == SensitiveType.TOKEN:
|
|
100
|
+
if value.startswith("Bearer "):
|
|
101
|
+
return "Bearer ••••••••"
|
|
102
|
+
# JWT - show first part only
|
|
103
|
+
if value.startswith("eyJ"):
|
|
104
|
+
return value[:10] + "••••••••"
|
|
105
|
+
return "••••••••"
|
|
106
|
+
|
|
107
|
+
# API keys - show prefix and last few chars
|
|
108
|
+
if len(value) > 12:
|
|
109
|
+
prefix_len = min(8, len(value) // 4)
|
|
110
|
+
suffix_len = 4
|
|
111
|
+
return value[:prefix_len] + "••••••••" + value[-suffix_len:]
|
|
112
|
+
|
|
113
|
+
return "••••••••"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def detect_sensitive(text: str) -> list[SensitiveMatch]:
|
|
117
|
+
"""Detect sensitive data in text.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
text: The text to scan for sensitive data.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
List of SensitiveMatch objects for each detection.
|
|
124
|
+
"""
|
|
125
|
+
matches: list[SensitiveMatch] = []
|
|
126
|
+
|
|
127
|
+
for sensitive_type, patterns in PATTERNS.items():
|
|
128
|
+
for pattern in patterns:
|
|
129
|
+
for match in pattern.finditer(text):
|
|
130
|
+
# For password patterns, we capture the password itself in group 1
|
|
131
|
+
if sensitive_type == SensitiveType.PASSWORD and match.lastindex:
|
|
132
|
+
original = match.group(1)
|
|
133
|
+
start = match.start(1)
|
|
134
|
+
end = match.end(1)
|
|
135
|
+
else:
|
|
136
|
+
original = match.group(0)
|
|
137
|
+
start = match.start()
|
|
138
|
+
end = match.end()
|
|
139
|
+
|
|
140
|
+
masked = _mask_value(original, sensitive_type)
|
|
141
|
+
matches.append(SensitiveMatch(
|
|
142
|
+
sensitive_type=sensitive_type,
|
|
143
|
+
start=start,
|
|
144
|
+
end=end,
|
|
145
|
+
original=original,
|
|
146
|
+
masked=masked,
|
|
147
|
+
))
|
|
148
|
+
|
|
149
|
+
# Sort by start position and remove overlapping matches
|
|
150
|
+
matches.sort(key=lambda m: m.start)
|
|
151
|
+
non_overlapping: list[SensitiveMatch] = []
|
|
152
|
+
last_end = -1
|
|
153
|
+
for match in matches:
|
|
154
|
+
if match.start >= last_end:
|
|
155
|
+
non_overlapping.append(match)
|
|
156
|
+
last_end = match.end
|
|
157
|
+
|
|
158
|
+
return non_overlapping
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def mask_text(text: str, matches: list[SensitiveMatch] | None = None) -> str:
|
|
162
|
+
"""Mask sensitive data in text.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
text: The original text.
|
|
166
|
+
matches: Pre-detected matches, or None to detect automatically.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Text with sensitive data masked.
|
|
170
|
+
"""
|
|
171
|
+
if matches is None:
|
|
172
|
+
matches = detect_sensitive(text)
|
|
173
|
+
|
|
174
|
+
if not matches:
|
|
175
|
+
return text
|
|
176
|
+
|
|
177
|
+
# Build masked text by replacing matched regions
|
|
178
|
+
result = []
|
|
179
|
+
last_pos = 0
|
|
180
|
+
for match in matches:
|
|
181
|
+
result.append(text[last_pos:match.start])
|
|
182
|
+
result.append(match.masked)
|
|
183
|
+
last_pos = match.end
|
|
184
|
+
result.append(text[last_pos:])
|
|
185
|
+
|
|
186
|
+
return "".join(result)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def is_sensitive(text: str) -> bool:
|
|
190
|
+
"""Check if text contains any sensitive data.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
text: The text to check.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
True if sensitive data is detected.
|
|
197
|
+
"""
|
|
198
|
+
return len(detect_sensitive(text)) > 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def get_sensitivity_summary(matches: list[SensitiveMatch]) -> str:
|
|
202
|
+
"""Get a summary of detected sensitive types.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
matches: List of sensitive matches.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Human-readable summary like "API Key, Password".
|
|
209
|
+
"""
|
|
210
|
+
types = sorted(set(m.sensitive_type.value.replace("_", " ").title() for m in matches))
|
|
211
|
+
return ", ".join(types)
|
clipsy/storage.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from clipsy.config import DB_PATH, MAX_ENTRIES
|
|
6
|
+
from clipsy.models import ClipboardEntry, ContentType
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
SCHEMA = """
|
|
10
|
+
CREATE TABLE IF NOT EXISTS clipboard_entries (
|
|
11
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
12
|
+
content_type TEXT NOT NULL CHECK(content_type IN ('text', 'image', 'file')),
|
|
13
|
+
text_content TEXT,
|
|
14
|
+
image_path TEXT,
|
|
15
|
+
preview TEXT NOT NULL,
|
|
16
|
+
content_hash TEXT NOT NULL,
|
|
17
|
+
byte_size INTEGER NOT NULL DEFAULT 0,
|
|
18
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
|
|
19
|
+
pinned INTEGER NOT NULL DEFAULT 0,
|
|
20
|
+
source_app TEXT,
|
|
21
|
+
thumbnail_path TEXT,
|
|
22
|
+
is_sensitive INTEGER NOT NULL DEFAULT 0,
|
|
23
|
+
masked_preview TEXT
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
CREATE INDEX IF NOT EXISTS idx_created_at ON clipboard_entries(created_at DESC);
|
|
27
|
+
CREATE INDEX IF NOT EXISTS idx_content_hash ON clipboard_entries(content_hash);
|
|
28
|
+
CREATE INDEX IF NOT EXISTS idx_content_type ON clipboard_entries(content_type);
|
|
29
|
+
|
|
30
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS clipboard_fts USING fts5(
|
|
31
|
+
preview,
|
|
32
|
+
text_content,
|
|
33
|
+
content='clipboard_entries',
|
|
34
|
+
content_rowid='id'
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
CREATE TRIGGER IF NOT EXISTS clipboard_ai AFTER INSERT ON clipboard_entries BEGIN
|
|
38
|
+
INSERT INTO clipboard_fts(rowid, preview, text_content)
|
|
39
|
+
VALUES (new.id, new.preview, new.text_content);
|
|
40
|
+
END;
|
|
41
|
+
|
|
42
|
+
CREATE TRIGGER IF NOT EXISTS clipboard_ad AFTER DELETE ON clipboard_entries BEGIN
|
|
43
|
+
INSERT INTO clipboard_fts(clipboard_fts, rowid, preview, text_content)
|
|
44
|
+
VALUES ('delete', old.id, old.preview, old.text_content);
|
|
45
|
+
END;
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class StorageManager:
|
|
50
|
+
def __init__(self, db_path: str | Path | None = None):
|
|
51
|
+
self._db_path = str(db_path) if db_path else str(DB_PATH)
|
|
52
|
+
self._conn = sqlite3.connect(self._db_path)
|
|
53
|
+
self._conn.row_factory = sqlite3.Row
|
|
54
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
55
|
+
self._conn.execute("PRAGMA foreign_keys=ON")
|
|
56
|
+
self.init_db()
|
|
57
|
+
|
|
58
|
+
def init_db(self) -> None:
|
|
59
|
+
self._conn.executescript(SCHEMA)
|
|
60
|
+
self._migrate_schema()
|
|
61
|
+
self._conn.commit()
|
|
62
|
+
|
|
63
|
+
def _migrate_schema(self) -> None:
|
|
64
|
+
"""Add new columns to existing databases."""
|
|
65
|
+
cursor = self._conn.execute("PRAGMA table_info(clipboard_entries)")
|
|
66
|
+
columns = {row[1] for row in cursor.fetchall()}
|
|
67
|
+
if "thumbnail_path" not in columns:
|
|
68
|
+
self._conn.execute("ALTER TABLE clipboard_entries ADD COLUMN thumbnail_path TEXT")
|
|
69
|
+
if "is_sensitive" not in columns:
|
|
70
|
+
self._conn.execute("ALTER TABLE clipboard_entries ADD COLUMN is_sensitive INTEGER NOT NULL DEFAULT 0")
|
|
71
|
+
if "masked_preview" not in columns:
|
|
72
|
+
self._conn.execute("ALTER TABLE clipboard_entries ADD COLUMN masked_preview TEXT")
|
|
73
|
+
|
|
74
|
+
def add_entry(self, entry: ClipboardEntry) -> int:
|
|
75
|
+
cursor = self._conn.execute(
|
|
76
|
+
"""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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
79
|
+
(
|
|
80
|
+
entry.content_type.value,
|
|
81
|
+
entry.text_content,
|
|
82
|
+
entry.image_path,
|
|
83
|
+
entry.preview,
|
|
84
|
+
entry.content_hash,
|
|
85
|
+
entry.byte_size,
|
|
86
|
+
entry.created_at.isoformat(),
|
|
87
|
+
int(entry.pinned),
|
|
88
|
+
entry.source_app,
|
|
89
|
+
entry.thumbnail_path,
|
|
90
|
+
int(entry.is_sensitive),
|
|
91
|
+
entry.masked_preview,
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
self._conn.commit()
|
|
95
|
+
return cursor.lastrowid
|
|
96
|
+
|
|
97
|
+
def get_recent(self, limit: int = 25) -> list[ClipboardEntry]:
|
|
98
|
+
rows = self._conn.execute(
|
|
99
|
+
"SELECT * FROM clipboard_entries ORDER BY created_at DESC LIMIT ?",
|
|
100
|
+
(limit,),
|
|
101
|
+
).fetchall()
|
|
102
|
+
return [self._row_to_entry(r) for r in rows]
|
|
103
|
+
|
|
104
|
+
def search(self, query: str, limit: int = 25) -> list[ClipboardEntry]:
|
|
105
|
+
sanitized = self._sanitize_fts_query(query)
|
|
106
|
+
if not sanitized:
|
|
107
|
+
return []
|
|
108
|
+
rows = self._conn.execute(
|
|
109
|
+
"""SELECT e.* FROM clipboard_entries e
|
|
110
|
+
JOIN clipboard_fts f ON e.id = f.rowid
|
|
111
|
+
WHERE clipboard_fts MATCH ?
|
|
112
|
+
ORDER BY e.created_at DESC
|
|
113
|
+
LIMIT ?""",
|
|
114
|
+
(sanitized, limit),
|
|
115
|
+
).fetchall()
|
|
116
|
+
return [self._row_to_entry(r) for r in rows]
|
|
117
|
+
|
|
118
|
+
def get_entry(self, entry_id: int) -> ClipboardEntry | None:
|
|
119
|
+
row = self._conn.execute(
|
|
120
|
+
"SELECT * FROM clipboard_entries WHERE id = ?", (entry_id,)
|
|
121
|
+
).fetchone()
|
|
122
|
+
return self._row_to_entry(row) if row else None
|
|
123
|
+
|
|
124
|
+
def delete_entry(self, entry_id: int) -> None:
|
|
125
|
+
entry = self.get_entry(entry_id)
|
|
126
|
+
if entry:
|
|
127
|
+
if entry.image_path:
|
|
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()
|
|
135
|
+
self._conn.execute("DELETE FROM clipboard_entries WHERE id = ?", (entry_id,))
|
|
136
|
+
self._conn.commit()
|
|
137
|
+
|
|
138
|
+
def find_by_hash(self, content_hash: str) -> ClipboardEntry | None:
|
|
139
|
+
row = self._conn.execute(
|
|
140
|
+
"SELECT * FROM clipboard_entries WHERE content_hash = ? ORDER BY created_at DESC LIMIT 1",
|
|
141
|
+
(content_hash,),
|
|
142
|
+
).fetchone()
|
|
143
|
+
return self._row_to_entry(row) if row else None
|
|
144
|
+
|
|
145
|
+
def update_timestamp(self, entry_id: int) -> None:
|
|
146
|
+
now = datetime.now().isoformat()
|
|
147
|
+
self._conn.execute(
|
|
148
|
+
"UPDATE clipboard_entries SET created_at = ? WHERE id = ?",
|
|
149
|
+
(now, entry_id),
|
|
150
|
+
)
|
|
151
|
+
self._conn.commit()
|
|
152
|
+
|
|
153
|
+
def update_thumbnail_path(self, entry_id: int, thumbnail_path: str) -> None:
|
|
154
|
+
self._conn.execute(
|
|
155
|
+
"UPDATE clipboard_entries SET thumbnail_path = ? WHERE id = ?",
|
|
156
|
+
(thumbnail_path, entry_id),
|
|
157
|
+
)
|
|
158
|
+
self._conn.commit()
|
|
159
|
+
|
|
160
|
+
def purge_old(self, keep_count: int | None = None) -> int:
|
|
161
|
+
keep = keep_count if keep_count is not None else MAX_ENTRIES
|
|
162
|
+
rows = self._conn.execute(
|
|
163
|
+
"""SELECT id, image_path, thumbnail_path FROM clipboard_entries
|
|
164
|
+
WHERE pinned = 0
|
|
165
|
+
ORDER BY created_at DESC
|
|
166
|
+
LIMIT -1 OFFSET ?""",
|
|
167
|
+
(keep,),
|
|
168
|
+
).fetchall()
|
|
169
|
+
|
|
170
|
+
deleted = 0
|
|
171
|
+
for row in rows:
|
|
172
|
+
if row["image_path"]:
|
|
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()
|
|
180
|
+
self._conn.execute("DELETE FROM clipboard_entries WHERE id = ?", (row["id"],))
|
|
181
|
+
deleted += 1
|
|
182
|
+
|
|
183
|
+
if deleted:
|
|
184
|
+
self._conn.commit()
|
|
185
|
+
return deleted
|
|
186
|
+
|
|
187
|
+
def toggle_pin(self, entry_id: int) -> bool:
|
|
188
|
+
entry = self.get_entry(entry_id)
|
|
189
|
+
if not entry:
|
|
190
|
+
return False
|
|
191
|
+
new_pinned = not entry.pinned
|
|
192
|
+
self._conn.execute(
|
|
193
|
+
"UPDATE clipboard_entries SET pinned = ? WHERE id = ?",
|
|
194
|
+
(int(new_pinned), entry_id),
|
|
195
|
+
)
|
|
196
|
+
self._conn.commit()
|
|
197
|
+
return new_pinned
|
|
198
|
+
|
|
199
|
+
def clear_all(self) -> None:
|
|
200
|
+
rows = self._conn.execute(
|
|
201
|
+
"SELECT image_path, thumbnail_path FROM clipboard_entries WHERE image_path IS NOT NULL OR thumbnail_path IS NOT NULL"
|
|
202
|
+
).fetchall()
|
|
203
|
+
for row in rows:
|
|
204
|
+
if row["image_path"]:
|
|
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()
|
|
212
|
+
self._conn.execute("DELETE FROM clipboard_entries")
|
|
213
|
+
self._conn.commit()
|
|
214
|
+
|
|
215
|
+
def count(self) -> int:
|
|
216
|
+
row = self._conn.execute("SELECT COUNT(*) as cnt FROM clipboard_entries").fetchone()
|
|
217
|
+
return row["cnt"]
|
|
218
|
+
|
|
219
|
+
def close(self) -> None:
|
|
220
|
+
self._conn.close()
|
|
221
|
+
|
|
222
|
+
def __enter__(self):
|
|
223
|
+
return self
|
|
224
|
+
|
|
225
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
226
|
+
self.close()
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _sanitize_fts_query(query: str) -> str:
|
|
231
|
+
# Quote each token to prevent FTS5 syntax errors from special chars
|
|
232
|
+
tokens = query.split()
|
|
233
|
+
if not tokens:
|
|
234
|
+
return ""
|
|
235
|
+
quoted = ['"' + token.replace('"', '""') + '"' for token in tokens]
|
|
236
|
+
return " ".join(quoted)
|
|
237
|
+
|
|
238
|
+
def _row_to_entry(self, row: sqlite3.Row) -> ClipboardEntry:
|
|
239
|
+
# Handle columns which may not exist in older databases
|
|
240
|
+
keys = row.keys()
|
|
241
|
+
thumbnail_path = row["thumbnail_path"] if "thumbnail_path" in keys else None
|
|
242
|
+
is_sensitive = bool(row["is_sensitive"]) if "is_sensitive" in keys else False
|
|
243
|
+
masked_preview = row["masked_preview"] if "masked_preview" in keys else None
|
|
244
|
+
return ClipboardEntry(
|
|
245
|
+
id=row["id"],
|
|
246
|
+
content_type=ContentType(row["content_type"]),
|
|
247
|
+
text_content=row["text_content"],
|
|
248
|
+
image_path=row["image_path"],
|
|
249
|
+
preview=row["preview"],
|
|
250
|
+
content_hash=row["content_hash"],
|
|
251
|
+
byte_size=row["byte_size"],
|
|
252
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
253
|
+
pinned=bool(row["pinned"]),
|
|
254
|
+
source_app=row["source_app"],
|
|
255
|
+
thumbnail_path=thumbnail_path,
|
|
256
|
+
is_sensitive=is_sensitive,
|
|
257
|
+
masked_preview=masked_preview,
|
|
258
|
+
)
|
clipsy/utils.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import struct
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from clipsy.config import DATA_DIR, IMAGE_DIR
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def compute_hash(data: str | bytes) -> str:
|
|
9
|
+
if isinstance(data, str):
|
|
10
|
+
data = data.encode("utf-8")
|
|
11
|
+
return hashlib.sha256(data).hexdigest()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def truncate_text(text: str, max_len: int) -> str:
|
|
15
|
+
single_line = " ".join(text.split())
|
|
16
|
+
if len(single_line) <= max_len:
|
|
17
|
+
return single_line
|
|
18
|
+
return single_line[: max_len - 3] + "..."
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def ensure_dirs() -> None:
|
|
22
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
IMAGE_DIR.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_image_dimensions(png_bytes: bytes) -> tuple[int, int]:
|
|
27
|
+
if len(png_bytes) < 24 or png_bytes[:8] != b"\x89PNG\r\n\x1a\n":
|
|
28
|
+
return (0, 0)
|
|
29
|
+
width = struct.unpack(">I", png_bytes[16:20])[0]
|
|
30
|
+
height = struct.unpack(">I", png_bytes[20:24])[0]
|
|
31
|
+
return (width, height)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def create_thumbnail(image_path: str, thumb_path: str, size: tuple[int, int] = (32, 32)) -> bool:
|
|
35
|
+
"""Create a thumbnail from an image file using native NSImage.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
image_path: Path to the source image
|
|
39
|
+
thumb_path: Path to save the thumbnail
|
|
40
|
+
size: Target size in pixels (width, height)
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
True if thumbnail was created successfully, False otherwise
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
from AppKit import NSBitmapImageRep, NSGraphicsContext, NSImage
|
|
47
|
+
|
|
48
|
+
original = NSImage.alloc().initWithContentsOfFile_(image_path)
|
|
49
|
+
if not original:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
resized = NSImage.alloc().initWithSize_(size)
|
|
53
|
+
resized.lockFocus()
|
|
54
|
+
NSGraphicsContext.currentContext().setImageInterpolation_(3) # High quality
|
|
55
|
+
original.drawInRect_(((0, 0), size))
|
|
56
|
+
resized.unlockFocus()
|
|
57
|
+
|
|
58
|
+
tiff_data = resized.TIFFRepresentation()
|
|
59
|
+
if not tiff_data:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
bitmap_rep = NSBitmapImageRep.imageRepWithData_(tiff_data)
|
|
63
|
+
if not bitmap_rep:
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
png_data = bitmap_rep.representationUsingType_properties_(4, None) # 4 = PNG
|
|
67
|
+
if not png_data:
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
return bool(png_data.writeToFile_atomically_(thumb_path, True))
|
|
71
|
+
except Exception:
|
|
72
|
+
return False
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clipsy
|
|
3
|
+
Version: 1.5.0
|
|
4
|
+
Summary: Lightweight clipboard history manager for macOS
|
|
5
|
+
Author-email: Brendan Conrad <brendan.conrad@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/brencon/clipsy
|
|
8
|
+
Project-URL: Repository, https://github.com/brencon/clipsy
|
|
9
|
+
Project-URL: Issues, https://github.com/brencon/clipsy/issues
|
|
10
|
+
Keywords: clipboard,macos,menu-bar,history,productivity
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: MacOS X
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Desktop Environment
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: rumps>=0.4.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# Clipsy
|
|
33
|
+
|
|
34
|
+
[](https://pypi.org/project/clipsy/)
|
|
35
|
+
[](https://github.com/brencon/clipsy/actions/workflows/ci.yml)
|
|
36
|
+
[](https://codecov.io/gh/brencon/clipsy)
|
|
37
|
+

|
|
38
|
+

|
|
39
|
+
[](LICENSE)
|
|
40
|
+
|
|
41
|
+
A lightweight clipboard history manager for macOS. Runs as a menu bar icon — no admin privileges, no code signing, no App Store required.
|
|
42
|
+
|
|
43
|
+
## Features
|
|
44
|
+
|
|
45
|
+
- **Clipboard history** — Automatically captures text, images, and file copies
|
|
46
|
+
- **Image thumbnails** — Visual previews for copied images in the menu
|
|
47
|
+
- **Sensitive data masking** — Auto-detects API keys, passwords, SSNs, credit cards, private keys, and tokens; displays masked previews with 🔒 icon
|
|
48
|
+
- **Search** — Full-text search across all clipboard entries (SQLite FTS5)
|
|
49
|
+
- **Click to re-copy** — Click any entry in the menu to put it back on your clipboard
|
|
50
|
+
- **Deduplication** — Copying the same content twice bumps it to the top instead of creating a duplicate
|
|
51
|
+
- **Auto-purge** — Keeps the most recent 500 entries, automatically cleans up old ones
|
|
52
|
+
- **Persistent storage** — History survives app restarts (SQLite database)
|
|
53
|
+
- **Corporate IT friendly** — Runs as a plain Python process, no `.app` bundle or Gatekeeper issues
|
|
54
|
+
|
|
55
|
+
## Requirements
|
|
56
|
+
|
|
57
|
+
- macOS
|
|
58
|
+
- Python 3.10+ (Homebrew recommended: `brew install python3`)
|
|
59
|
+
|
|
60
|
+
## Installation
|
|
61
|
+
|
|
62
|
+
### Via pip (recommended)
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install clipsy
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### From source
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
git clone https://github.com/brencon/clipsy.git
|
|
72
|
+
cd clipsy
|
|
73
|
+
python3 -m venv .venv
|
|
74
|
+
.venv/bin/pip install -e .
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Usage
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# Run clipsy (a scissors icon appears in your menu bar)
|
|
81
|
+
clipsy
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Then just use your Mac normally. Every time you copy something, it shows up in the Clipsy menu:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
[✂️ Icon]
|
|
88
|
+
├── Clipsy - Clipboard History
|
|
89
|
+
├── ──────────────────
|
|
90
|
+
├── Search...
|
|
91
|
+
├── ──────────────────
|
|
92
|
+
├── "Meeting notes for Q3 plan..."
|
|
93
|
+
├── "https://github.com/example..."
|
|
94
|
+
├── 🔒 "password=••••••••"
|
|
95
|
+
├── [thumbnail] "[Image: 1920x1080]"
|
|
96
|
+
├── ... (up to 10 items)
|
|
97
|
+
├── ──────────────────
|
|
98
|
+
├── Clear History
|
|
99
|
+
├── ──────────────────
|
|
100
|
+
├── Support Clipsy
|
|
101
|
+
├── ──────────────────
|
|
102
|
+
└── Quit Clipsy
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Auto-Start on Login
|
|
106
|
+
|
|
107
|
+
Run clipsy automatically when you log in — no terminal needed:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
# Install as a LaunchAgent
|
|
111
|
+
scripts/install_launchagent.sh install
|
|
112
|
+
|
|
113
|
+
# Check status
|
|
114
|
+
scripts/install_launchagent.sh status
|
|
115
|
+
|
|
116
|
+
# Remove auto-start
|
|
117
|
+
scripts/install_launchagent.sh uninstall
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Data Storage
|
|
121
|
+
|
|
122
|
+
All data is stored in `~/.local/share/clipsy/`:
|
|
123
|
+
|
|
124
|
+
| File | Purpose |
|
|
125
|
+
|------|---------|
|
|
126
|
+
| `clipsy.db` | SQLite database with clipboard entries |
|
|
127
|
+
| `images/` | Saved clipboard images (PNG files) |
|
|
128
|
+
| `clipsy.log` | Application log |
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
# Install with dev dependencies
|
|
134
|
+
.venv/bin/pip install -e ".[dev]"
|
|
135
|
+
|
|
136
|
+
# Run tests
|
|
137
|
+
.venv/bin/python -m pytest tests/ -v
|
|
138
|
+
|
|
139
|
+
# Run with coverage
|
|
140
|
+
.venv/bin/python -m pytest tests/ --cov=clipsy --cov-report=term-missing
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Architecture
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
NSPasteboard → monitor.py → redact.py → storage.py (SQLite) → app.py (menu bar UI)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
- **`app.py`** — `rumps.App` subclass; renders the menu bar dropdown, handles clicks and search
|
|
150
|
+
- **`monitor.py`** — Polls `NSPasteboard.changeCount()` every 0.5s; detects text, images, and file copies
|
|
151
|
+
- **`storage.py`** — SQLite with FTS5 full-text search, SHA-256 deduplication, auto-purge
|
|
152
|
+
- **`redact.py`** — Sensitive data detection and masking (API keys, passwords, SSN, credit cards, tokens)
|
|
153
|
+
- **`config.py`** — Constants, paths, limits
|
|
154
|
+
- **`models.py`** — `ClipboardEntry` dataclass, `ContentType` enum
|
|
155
|
+
- **`utils.py`** — Hashing, text truncation, PNG dimension parsing, thumbnail generation
|
|
156
|
+
|
|
157
|
+
### Dependencies
|
|
158
|
+
|
|
159
|
+
Only one external dependency:
|
|
160
|
+
|
|
161
|
+
- **`rumps`** — macOS menu bar app framework (brings `pyobjc-framework-Cocoa` transitively)
|
|
162
|
+
- **`sqlite3`** — Built into Python
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
MIT License — see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
clipsy/__init__.py,sha256=RfTNMAT90hngQkclaI0i0rdsR5s2s2IFJWzExWhvwjU,46
|
|
2
|
+
clipsy/__main__.py,sha256=vCWOKz08ZY3Uc5orHOMND0sEgBnyvD0WkD4o6b4_6Mw,493
|
|
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.5.0.dist-info/licenses/LICENSE,sha256=bZ7uVihgPnLGryi5ugUgkVrEoho0_hFAd3bVdhYXGqs,1071
|
|
11
|
+
clipsy-1.5.0.dist-info/METADATA,sha256=7lAWnXyqO69PZSLoFjOZKU1WuXaaykI0a3V-dUSRkUE,5693
|
|
12
|
+
clipsy-1.5.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
13
|
+
clipsy-1.5.0.dist-info/entry_points.txt,sha256=ISIoWU3Zj-4NEuj2pJW96LxpsvjRJJH73otv9gA9YSs,48
|
|
14
|
+
clipsy-1.5.0.dist-info/top_level.txt,sha256=trxprVJk4ZMudCshc7PD0N9iFgQO4Tq4sW5L5wLduns,7
|
|
15
|
+
clipsy-1.5.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Brendan Conrad
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
clipsy
|