clipsy 1.7.1__tar.gz → 1.9.0__tar.gz
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-1.7.1/src/clipsy.egg-info → clipsy-1.9.0}/PKG-INFO +29 -3
- {clipsy-1.7.1 → clipsy-1.9.0}/README.md +28 -2
- {clipsy-1.7.1 → clipsy-1.9.0}/pyproject.toml +1 -1
- {clipsy-1.7.1 → clipsy-1.9.0}/src/clipsy/__init__.py +1 -1
- {clipsy-1.7.1 → clipsy-1.9.0}/src/clipsy/app.py +63 -4
- {clipsy-1.7.1 → clipsy-1.9.0}/src/clipsy/config.py +13 -1
- {clipsy-1.7.1 → clipsy-1.9.0}/src/clipsy/models.py +2 -0
- clipsy-1.9.0/src/clipsy/monitor.py +201 -0
- {clipsy-1.7.1 → clipsy-1.9.0}/src/clipsy/storage.py +41 -27
- {clipsy-1.7.1 → clipsy-1.9.0/src/clipsy.egg-info}/PKG-INFO +29 -3
- {clipsy-1.7.1 → clipsy-1.9.0}/src/clipsy.egg-info/SOURCES.txt +2 -0
- {clipsy-1.7.1 → clipsy-1.9.0}/tests/test_app.py +113 -0
- clipsy-1.9.0/tests/test_config.py +36 -0
- clipsy-1.9.0/tests/test_main.py +159 -0
- {clipsy-1.7.1 → clipsy-1.9.0}/tests/test_monitor.py +168 -0
- {clipsy-1.7.1 → clipsy-1.9.0}/tests/test_storage.py +147 -0
- {clipsy-1.7.1 → clipsy-1.9.0}/tests/test_utils.py +63 -0
- clipsy-1.7.1/src/clipsy/monitor.py +0 -153
- {clipsy-1.7.1 → clipsy-1.9.0}/LICENSE +0 -0
- {clipsy-1.7.1 → clipsy-1.9.0}/setup.cfg +0 -0
- {clipsy-1.7.1 → clipsy-1.9.0}/src/clipsy/__main__.py +0 -0
- {clipsy-1.7.1 → clipsy-1.9.0}/src/clipsy/redact.py +0 -0
- {clipsy-1.7.1 → clipsy-1.9.0}/src/clipsy/utils.py +0 -0
- {clipsy-1.7.1 → clipsy-1.9.0}/src/clipsy.egg-info/dependency_links.txt +0 -0
- {clipsy-1.7.1 → clipsy-1.9.0}/src/clipsy.egg-info/entry_points.txt +0 -0
- {clipsy-1.7.1 → clipsy-1.9.0}/src/clipsy.egg-info/requires.txt +0 -0
- {clipsy-1.7.1 → clipsy-1.9.0}/src/clipsy.egg-info/top_level.txt +0 -0
- {clipsy-1.7.1 → clipsy-1.9.0}/tests/test_redact.py +0 -0
|
@@ -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/`:
|
|
@@ -15,6 +15,8 @@ A lightweight clipboard history manager for macOS. Runs as a menu bar icon — n
|
|
|
15
15
|
- **Image thumbnails** — Visual previews for copied images in the menu
|
|
16
16
|
- **Sensitive data masking** — Auto-detects API keys, passwords, SSNs, credit cards, private keys, and tokens; displays masked previews with 🔒 icon
|
|
17
17
|
- **Search** — Full-text search across all clipboard entries (SQLite FTS5)
|
|
18
|
+
- **Rich text preservation** — Preserves RTF and HTML formatting when re-copying from history (e.g., bold, italic, links from web pages)
|
|
19
|
+
- **Pin favorites** — Option-click to pin up to 5 frequently-used snippets (sensitive data cannot be pinned)
|
|
18
20
|
- **Click to re-copy** — Click any entry in the menu to put it back on your clipboard
|
|
19
21
|
- **Deduplication** — Copying the same content twice bumps it to the top instead of creating a duplicate
|
|
20
22
|
- **Auto-purge** — Keeps the most recent 500 entries, automatically cleans up old ones
|
|
@@ -28,7 +30,14 @@ A lightweight clipboard history manager for macOS. Runs as a menu bar icon — n
|
|
|
28
30
|
|
|
29
31
|
## Installation
|
|
30
32
|
|
|
31
|
-
### Via
|
|
33
|
+
### Via Homebrew (recommended)
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
brew install brencon/clipsy/clipsy
|
|
37
|
+
clipsy
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Via pipx
|
|
32
41
|
|
|
33
42
|
```bash
|
|
34
43
|
brew install pipx
|
|
@@ -65,11 +74,15 @@ Then just use your Mac normally. Every time you copy something, it shows up in t
|
|
|
65
74
|
├── ──────────────────
|
|
66
75
|
├── Search...
|
|
67
76
|
├── ──────────────────
|
|
77
|
+
├── 📌 Pinned ►
|
|
78
|
+
│ ├── "my-api-endpoint.com/v1..."
|
|
79
|
+
│ └── "SELECT * FROM users..."
|
|
80
|
+
├── ──────────────────
|
|
68
81
|
├── "Meeting notes for Q3 plan..."
|
|
69
82
|
├── "https://github.com/example..."
|
|
70
83
|
├── 🔒 "password=••••••••"
|
|
71
84
|
├── [thumbnail] "[Image: 1920x1080]"
|
|
72
|
-
├── ... (up to 10 items)
|
|
85
|
+
├── ... (up to 10 items, configurable)
|
|
73
86
|
├── ──────────────────
|
|
74
87
|
├── Clear History
|
|
75
88
|
├── ──────────────────
|
|
@@ -78,6 +91,8 @@ Then just use your Mac normally. Every time you copy something, it shows up in t
|
|
|
78
91
|
└── Quit Clipsy
|
|
79
92
|
```
|
|
80
93
|
|
|
94
|
+
**Tip:** Hold **Option (⌥)** while clicking an entry to pin/unpin it.
|
|
95
|
+
|
|
81
96
|
## Commands
|
|
82
97
|
|
|
83
98
|
```bash
|
|
@@ -87,6 +102,17 @@ clipsy uninstall # Remove from login items
|
|
|
87
102
|
clipsy run # Run in foreground (for debugging)
|
|
88
103
|
```
|
|
89
104
|
|
|
105
|
+
## Configuration
|
|
106
|
+
|
|
107
|
+
| Variable | Default | Range | Description |
|
|
108
|
+
|----------|---------|-------|-------------|
|
|
109
|
+
| `CLIPSY_MENU_DISPLAY_COUNT` | `10` | 5–50 | Number of entries shown in the menu |
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# Example: show 20 entries in the menu
|
|
113
|
+
export CLIPSY_MENU_DISPLAY_COUNT=20
|
|
114
|
+
```
|
|
115
|
+
|
|
90
116
|
## Data Storage
|
|
91
117
|
|
|
92
118
|
All data is stored in `~/.local/share/clipsy/`:
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
__version__ = "1.
|
|
1
|
+
__version__ = "1.9.0"
|
|
2
2
|
__app_name__ = "Clipsy"
|
|
@@ -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:",
|
|
@@ -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
|
|
@@ -0,0 +1,201 @@
|
|
|
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
|
+
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
|
+
|
|
18
|
+
from clipsy.config import IMAGE_DIR, MAX_IMAGE_SIZE, MAX_TEXT_SIZE, PREVIEW_LENGTH, REDACT_SENSITIVE, THUMBNAIL_SIZE
|
|
19
|
+
from clipsy.models import ClipboardEntry, ContentType
|
|
20
|
+
from clipsy.redact import detect_sensitive, mask_text
|
|
21
|
+
from clipsy.storage import StorageManager
|
|
22
|
+
from clipsy.utils import compute_hash, create_thumbnail, ensure_dirs, get_image_dimensions, truncate_text
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ClipboardMonitor:
|
|
28
|
+
def __init__(self, storage: StorageManager, on_change: Callable[[], None] | None = None):
|
|
29
|
+
self._storage = storage
|
|
30
|
+
self._on_change = on_change
|
|
31
|
+
self._pasteboard = NSPasteboard.generalPasteboard()
|
|
32
|
+
self._last_change_count = self._pasteboard.changeCount()
|
|
33
|
+
ensure_dirs()
|
|
34
|
+
|
|
35
|
+
def check_clipboard(self) -> bool:
|
|
36
|
+
current_count = self._pasteboard.changeCount()
|
|
37
|
+
if current_count == self._last_change_count:
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
self._last_change_count = current_count
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
entry = self._read_clipboard()
|
|
44
|
+
if entry is None:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
existing = self._storage.find_by_hash(entry.content_hash)
|
|
48
|
+
if existing:
|
|
49
|
+
self._storage.update_timestamp(existing.id)
|
|
50
|
+
else:
|
|
51
|
+
self._storage.add_entry(entry)
|
|
52
|
+
self._storage.purge_old()
|
|
53
|
+
|
|
54
|
+
if self._on_change:
|
|
55
|
+
self._on_change()
|
|
56
|
+
return True
|
|
57
|
+
except Exception:
|
|
58
|
+
logger.exception("Error reading clipboard")
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
def sync_change_count(self) -> None:
|
|
62
|
+
self._last_change_count = self._pasteboard.changeCount()
|
|
63
|
+
|
|
64
|
+
def _read_clipboard(self) -> ClipboardEntry | None:
|
|
65
|
+
types = self._pasteboard.types()
|
|
66
|
+
if types is None:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
if NSPasteboardTypeString in types:
|
|
70
|
+
entry = self._read_text()
|
|
71
|
+
if entry:
|
|
72
|
+
return entry
|
|
73
|
+
|
|
74
|
+
for img_type in (NSPasteboardTypePNG, NSPasteboardTypeTIFF):
|
|
75
|
+
if img_type in types:
|
|
76
|
+
entry = self._read_image(img_type)
|
|
77
|
+
if entry:
|
|
78
|
+
return entry
|
|
79
|
+
|
|
80
|
+
if NSFilenamesPboardType in types:
|
|
81
|
+
return self._read_files()
|
|
82
|
+
|
|
83
|
+
return None
|
|
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
|
+
|
|
186
|
+
def _save_image(self, img_bytes: bytes, content_hash: str, is_png: bool) -> tuple[Path, Path | None]:
|
|
187
|
+
ext = ".png" if is_png else ".tiff"
|
|
188
|
+
filename = content_hash[:12] + ext
|
|
189
|
+
path = IMAGE_DIR / filename
|
|
190
|
+
if not path.exists():
|
|
191
|
+
path.write_bytes(img_bytes)
|
|
192
|
+
|
|
193
|
+
# Generate thumbnail
|
|
194
|
+
thumb_filename = content_hash[:12] + "_thumb.png"
|
|
195
|
+
thumb_path = IMAGE_DIR / thumb_filename
|
|
196
|
+
if not thumb_path.exists():
|
|
197
|
+
if create_thumbnail(str(path), str(thumb_path), THUMBNAIL_SIZE):
|
|
198
|
+
return path, thumb_path
|
|
199
|
+
else:
|
|
200
|
+
return path, None
|
|
201
|
+
return path, thumb_path
|
|
@@ -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
|
)
|