clipsy 1.7.0__tar.gz → 1.8.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.0/src/clipsy.egg-info → clipsy-1.8.0}/PKG-INFO +9 -2
- {clipsy-1.7.0 → clipsy-1.8.0}/README.md +8 -1
- {clipsy-1.7.0 → clipsy-1.8.0}/pyproject.toml +1 -1
- {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy/__init__.py +1 -1
- {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy/__main__.py +1 -0
- {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy/app.py +9 -1
- {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy/config.py +12 -1
- {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy/models.py +2 -0
- clipsy-1.8.0/src/clipsy/monitor.py +201 -0
- {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy/storage.py +26 -27
- {clipsy-1.7.0 → clipsy-1.8.0/src/clipsy.egg-info}/PKG-INFO +9 -2
- {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy.egg-info/SOURCES.txt +2 -0
- {clipsy-1.7.0 → clipsy-1.8.0}/tests/test_app.py +36 -0
- clipsy-1.8.0/tests/test_config.py +36 -0
- clipsy-1.8.0/tests/test_main.py +159 -0
- {clipsy-1.7.0 → clipsy-1.8.0}/tests/test_monitor.py +101 -0
- {clipsy-1.7.0 → clipsy-1.8.0}/tests/test_storage.py +79 -0
- clipsy-1.7.0/src/clipsy/monitor.py +0 -153
- {clipsy-1.7.0 → clipsy-1.8.0}/LICENSE +0 -0
- {clipsy-1.7.0 → clipsy-1.8.0}/setup.cfg +0 -0
- {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy/redact.py +0 -0
- {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy/utils.py +0 -0
- {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy.egg-info/dependency_links.txt +0 -0
- {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy.egg-info/entry_points.txt +0 -0
- {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy.egg-info/requires.txt +0 -0
- {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy.egg-info/top_level.txt +0 -0
- {clipsy-1.7.0 → clipsy-1.8.0}/tests/test_redact.py +0 -0
- {clipsy-1.7.0 → clipsy-1.8.0}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clipsy
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.8.0
|
|
4
4
|
Summary: Lightweight clipboard history manager for macOS
|
|
5
5
|
Author-email: Brendan Conrad <brendan.conrad@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -59,7 +59,14 @@ A lightweight clipboard history manager for macOS. Runs as a menu bar icon — n
|
|
|
59
59
|
|
|
60
60
|
## Installation
|
|
61
61
|
|
|
62
|
-
### Via
|
|
62
|
+
### Via Homebrew (recommended)
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
brew install brencon/clipsy/clipsy
|
|
66
|
+
clipsy
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Via pipx
|
|
63
70
|
|
|
64
71
|
```bash
|
|
65
72
|
brew install pipx
|
|
@@ -28,7 +28,14 @@ A lightweight clipboard history manager for macOS. Runs as a menu bar icon — n
|
|
|
28
28
|
|
|
29
29
|
## Installation
|
|
30
30
|
|
|
31
|
-
### Via
|
|
31
|
+
### Via Homebrew (recommended)
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
brew install brencon/clipsy/clipsy
|
|
35
|
+
clipsy
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Via pipx
|
|
32
39
|
|
|
33
40
|
```bash
|
|
34
41
|
brew install pipx
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
__version__ = "1.
|
|
1
|
+
__version__ = "1.8.0"
|
|
2
2
|
__app_name__ = "Clipsy"
|
|
@@ -120,6 +120,7 @@ class ClipsyApp(rumps.App):
|
|
|
120
120
|
|
|
121
121
|
try:
|
|
122
122
|
from AppKit import NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeString
|
|
123
|
+
from Foundation import NSData
|
|
123
124
|
|
|
124
125
|
pb = NSPasteboard.generalPasteboard()
|
|
125
126
|
|
|
@@ -128,11 +129,18 @@ class ClipsyApp(rumps.App):
|
|
|
128
129
|
if entry.content_type == ContentType.TEXT and entry.text_content:
|
|
129
130
|
pb.clearContents()
|
|
130
131
|
pb.setString_forType_(entry.text_content, NSPasteboardTypeString)
|
|
132
|
+
if entry.rtf_data:
|
|
133
|
+
rtf_ns_data = NSData.dataWithBytes_length_(entry.rtf_data, len(entry.rtf_data))
|
|
134
|
+
if rtf_ns_data:
|
|
135
|
+
pb.setData_forType_(rtf_ns_data, "public.rtf")
|
|
136
|
+
if entry.html_data:
|
|
137
|
+
html_ns_data = NSData.dataWithBytes_length_(entry.html_data, len(entry.html_data))
|
|
138
|
+
if html_ns_data:
|
|
139
|
+
pb.setData_forType_(html_ns_data, "public.html")
|
|
131
140
|
self._monitor.sync_change_count()
|
|
132
141
|
copied = True
|
|
133
142
|
|
|
134
143
|
elif entry.content_type == ContentType.IMAGE and entry.image_path:
|
|
135
|
-
from Foundation import NSData
|
|
136
144
|
img_data = NSData.dataWithContentsOfFile_(entry.image_path)
|
|
137
145
|
if img_data:
|
|
138
146
|
pb.clearContents()
|
|
@@ -11,6 +11,17 @@ 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.)
|
|
@@ -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
|
|
|
@@ -241,6 +236,8 @@ class StorageManager:
|
|
|
241
236
|
thumbnail_path = row["thumbnail_path"] if "thumbnail_path" in keys else None
|
|
242
237
|
is_sensitive = bool(row["is_sensitive"]) if "is_sensitive" in keys else False
|
|
243
238
|
masked_preview = row["masked_preview"] if "masked_preview" in keys else None
|
|
239
|
+
rtf_data = bytes(row["rtf_data"]) if "rtf_data" in keys and row["rtf_data"] is not None else None
|
|
240
|
+
html_data = bytes(row["html_data"]) if "html_data" in keys and row["html_data"] is not None else None
|
|
244
241
|
return ClipboardEntry(
|
|
245
242
|
id=row["id"],
|
|
246
243
|
content_type=ContentType(row["content_type"]),
|
|
@@ -255,4 +252,6 @@ class StorageManager:
|
|
|
255
252
|
thumbnail_path=thumbnail_path,
|
|
256
253
|
is_sensitive=is_sensitive,
|
|
257
254
|
masked_preview=masked_preview,
|
|
255
|
+
rtf_data=rtf_data,
|
|
256
|
+
html_data=html_data,
|
|
258
257
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clipsy
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.8.0
|
|
4
4
|
Summary: Lightweight clipboard history manager for macOS
|
|
5
5
|
Author-email: Brendan Conrad <brendan.conrad@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -59,7 +59,14 @@ A lightweight clipboard history manager for macOS. Runs as a menu bar icon — n
|
|
|
59
59
|
|
|
60
60
|
## Installation
|
|
61
61
|
|
|
62
|
-
### Via
|
|
62
|
+
### Via Homebrew (recommended)
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
brew install brencon/clipsy/clipsy
|
|
66
|
+
clipsy
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Via pipx
|
|
63
70
|
|
|
64
71
|
```bash
|
|
65
72
|
brew install pipx
|
|
@@ -202,3 +202,39 @@ class TestMenuEntryMapping:
|
|
|
202
202
|
|
|
203
203
|
assert "old_key" not in entry_ids
|
|
204
204
|
assert len(entry_ids) == 1
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class TestRichTextRestoration:
|
|
208
|
+
"""Test that RTF/HTML data round-trips through storage for restoration."""
|
|
209
|
+
|
|
210
|
+
def test_text_entry_with_rtf_data_available(self, storage, make_entry):
|
|
211
|
+
rtf_bytes = b"{\\rtf1\\ansi Hello \\b World\\b0}"
|
|
212
|
+
entry_id = storage.add_entry(make_entry("Hello World", rtf_data=rtf_bytes))
|
|
213
|
+
entry = storage.get_entry(entry_id)
|
|
214
|
+
assert entry.rtf_data == rtf_bytes
|
|
215
|
+
assert entry.text_content == "Hello World"
|
|
216
|
+
assert entry.content_type == ContentType.TEXT
|
|
217
|
+
|
|
218
|
+
def test_text_entry_with_html_data_available(self, storage, make_entry):
|
|
219
|
+
html_bytes = b"<p>Hello <b>World</b></p>"
|
|
220
|
+
entry_id = storage.add_entry(make_entry("Hello World", html_data=html_bytes, content_hash="html_h"))
|
|
221
|
+
entry = storage.get_entry(entry_id)
|
|
222
|
+
assert entry.html_data == html_bytes
|
|
223
|
+
assert entry.text_content == "Hello World"
|
|
224
|
+
|
|
225
|
+
def test_text_entry_with_both_formats(self, storage, make_entry):
|
|
226
|
+
rtf_bytes = b"{\\rtf1\\ansi Hello}"
|
|
227
|
+
html_bytes = b"<p>Hello</p>"
|
|
228
|
+
entry_id = storage.add_entry(
|
|
229
|
+
make_entry("Hello", rtf_data=rtf_bytes, html_data=html_bytes)
|
|
230
|
+
)
|
|
231
|
+
entry = storage.get_entry(entry_id)
|
|
232
|
+
assert entry.rtf_data == rtf_bytes
|
|
233
|
+
assert entry.html_data == html_bytes
|
|
234
|
+
|
|
235
|
+
def test_plain_text_entry_has_no_rich_data(self, storage, make_entry):
|
|
236
|
+
entry_id = storage.add_entry(make_entry("plain text"))
|
|
237
|
+
entry = storage.get_entry(entry_id)
|
|
238
|
+
assert entry.rtf_data is None
|
|
239
|
+
assert entry.html_data is None
|
|
240
|
+
assert entry.text_content == "plain text"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from unittest.mock import patch
|
|
3
|
+
|
|
4
|
+
from clipsy.config import _parse_menu_display_count
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestParseMenuDisplayCount:
|
|
8
|
+
def test_default_when_not_set(self):
|
|
9
|
+
env = os.environ.copy()
|
|
10
|
+
env.pop("CLIPSY_MENU_DISPLAY_COUNT", None)
|
|
11
|
+
with patch.dict("os.environ", env, clear=True):
|
|
12
|
+
assert _parse_menu_display_count() == 10
|
|
13
|
+
|
|
14
|
+
def test_valid_value(self):
|
|
15
|
+
with patch.dict("os.environ", {"CLIPSY_MENU_DISPLAY_COUNT": "20"}):
|
|
16
|
+
assert _parse_menu_display_count() == 20
|
|
17
|
+
|
|
18
|
+
def test_clamped_below_minimum(self):
|
|
19
|
+
with patch.dict("os.environ", {"CLIPSY_MENU_DISPLAY_COUNT": "2"}):
|
|
20
|
+
assert _parse_menu_display_count() == 5
|
|
21
|
+
|
|
22
|
+
def test_clamped_above_maximum(self):
|
|
23
|
+
with patch.dict("os.environ", {"CLIPSY_MENU_DISPLAY_COUNT": "100"}):
|
|
24
|
+
assert _parse_menu_display_count() == 50
|
|
25
|
+
|
|
26
|
+
def test_invalid_non_integer(self):
|
|
27
|
+
with patch.dict("os.environ", {"CLIPSY_MENU_DISPLAY_COUNT": "abc"}):
|
|
28
|
+
assert _parse_menu_display_count() == 10
|
|
29
|
+
|
|
30
|
+
def test_boundary_minimum(self):
|
|
31
|
+
with patch.dict("os.environ", {"CLIPSY_MENU_DISPLAY_COUNT": "5"}):
|
|
32
|
+
assert _parse_menu_display_count() == 5
|
|
33
|
+
|
|
34
|
+
def test_boundary_maximum(self):
|
|
35
|
+
with patch.dict("os.environ", {"CLIPSY_MENU_DISPLAY_COUNT": "50"}):
|
|
36
|
+
assert _parse_menu_display_count() == 50
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Tests for __main__.py CLI and LaunchAgent functions."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from clipsy.__main__ import (
|
|
8
|
+
PLIST_NAME,
|
|
9
|
+
check_status,
|
|
10
|
+
create_plist,
|
|
11
|
+
get_clipsy_path,
|
|
12
|
+
install_launchagent,
|
|
13
|
+
main,
|
|
14
|
+
uninstall_launchagent,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestCreatePlist:
|
|
19
|
+
def test_contains_label(self):
|
|
20
|
+
plist = create_plist("/usr/local/bin/clipsy")
|
|
21
|
+
assert "<string>com.clipsy.app</string>" in plist
|
|
22
|
+
|
|
23
|
+
def test_contains_clipsy_path(self):
|
|
24
|
+
plist = create_plist("/opt/homebrew/bin/clipsy")
|
|
25
|
+
assert "<string>/opt/homebrew/bin/clipsy</string>" in plist
|
|
26
|
+
|
|
27
|
+
def test_contains_run_argument(self):
|
|
28
|
+
plist = create_plist("/usr/local/bin/clipsy")
|
|
29
|
+
assert "<string>run</string>" in plist
|
|
30
|
+
|
|
31
|
+
def test_contains_keep_alive(self):
|
|
32
|
+
plist = create_plist("/usr/local/bin/clipsy")
|
|
33
|
+
assert "<key>KeepAlive</key>" in plist
|
|
34
|
+
assert "<true/>" in plist
|
|
35
|
+
|
|
36
|
+
def test_contains_log_path(self):
|
|
37
|
+
plist = create_plist("/usr/local/bin/clipsy")
|
|
38
|
+
assert "clipsy.log</string>" in plist
|
|
39
|
+
|
|
40
|
+
def test_valid_xml(self):
|
|
41
|
+
plist = create_plist("/usr/local/bin/clipsy")
|
|
42
|
+
assert plist.startswith("<?xml version=")
|
|
43
|
+
assert "</plist>" in plist
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestGetClipsyPath:
|
|
47
|
+
@patch("shutil.which", return_value="/usr/local/bin/clipsy")
|
|
48
|
+
def test_returns_which_path(self, _mock_which):
|
|
49
|
+
assert get_clipsy_path() == "/usr/local/bin/clipsy"
|
|
50
|
+
|
|
51
|
+
@patch("shutil.which", return_value=None)
|
|
52
|
+
def test_falls_back_to_python_module(self, _mock_which):
|
|
53
|
+
path = get_clipsy_path()
|
|
54
|
+
assert "-m clipsy" in path
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestInstallLaunchAgent:
|
|
58
|
+
@patch("clipsy.__main__.subprocess.run")
|
|
59
|
+
@patch("clipsy.__main__.PLIST_PATH")
|
|
60
|
+
@patch("clipsy.__main__.LAUNCHAGENT_DIR")
|
|
61
|
+
@patch("clipsy.__main__.ensure_dirs")
|
|
62
|
+
@patch("clipsy.__main__.get_clipsy_path", return_value="/usr/local/bin/clipsy")
|
|
63
|
+
def test_install_success(self, _mock_path, _mock_dirs, mock_la_dir, mock_plist, mock_run):
|
|
64
|
+
mock_plist.exists.return_value = False
|
|
65
|
+
mock_run.return_value = MagicMock(returncode=0)
|
|
66
|
+
assert install_launchagent() == 0
|
|
67
|
+
|
|
68
|
+
@patch("clipsy.__main__.subprocess.run")
|
|
69
|
+
@patch("clipsy.__main__.PLIST_PATH")
|
|
70
|
+
@patch("clipsy.__main__.LAUNCHAGENT_DIR")
|
|
71
|
+
@patch("clipsy.__main__.ensure_dirs")
|
|
72
|
+
@patch("clipsy.__main__.get_clipsy_path", return_value="/usr/local/bin/clipsy")
|
|
73
|
+
def test_install_failure(self, _mock_path, _mock_dirs, mock_la_dir, mock_plist, mock_run):
|
|
74
|
+
mock_plist.exists.return_value = False
|
|
75
|
+
mock_run.return_value = MagicMock(returncode=1, stderr="load failed")
|
|
76
|
+
assert install_launchagent() == 1
|
|
77
|
+
|
|
78
|
+
@patch("clipsy.__main__.subprocess.run")
|
|
79
|
+
@patch("clipsy.__main__.PLIST_PATH")
|
|
80
|
+
@patch("clipsy.__main__.LAUNCHAGENT_DIR")
|
|
81
|
+
@patch("clipsy.__main__.ensure_dirs")
|
|
82
|
+
@patch("clipsy.__main__.get_clipsy_path", return_value="/usr/local/bin/clipsy")
|
|
83
|
+
def test_install_unloads_existing(self, _mock_path, _mock_dirs, mock_la_dir, mock_plist, mock_run):
|
|
84
|
+
mock_plist.exists.return_value = True
|
|
85
|
+
mock_run.return_value = MagicMock(returncode=0)
|
|
86
|
+
install_launchagent()
|
|
87
|
+
# First call should be unload, second should be load
|
|
88
|
+
assert mock_run.call_count == 2
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TestUninstallLaunchAgent:
|
|
92
|
+
@patch("clipsy.__main__.subprocess.run")
|
|
93
|
+
@patch("clipsy.__main__.PLIST_PATH")
|
|
94
|
+
def test_uninstall_not_installed(self, mock_plist, _mock_run):
|
|
95
|
+
mock_plist.exists.return_value = False
|
|
96
|
+
assert uninstall_launchagent() == 0
|
|
97
|
+
|
|
98
|
+
@patch("clipsy.__main__.subprocess.run")
|
|
99
|
+
@patch("clipsy.__main__.PLIST_PATH")
|
|
100
|
+
def test_uninstall_success(self, mock_plist, mock_run):
|
|
101
|
+
mock_plist.exists.return_value = True
|
|
102
|
+
mock_run.return_value = MagicMock(returncode=0)
|
|
103
|
+
assert uninstall_launchagent() == 0
|
|
104
|
+
mock_plist.unlink.assert_called_once()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class TestCheckStatus:
|
|
108
|
+
@patch("clipsy.__main__.subprocess.run")
|
|
109
|
+
@patch("clipsy.__main__.PLIST_PATH")
|
|
110
|
+
def test_running(self, mock_plist, mock_run):
|
|
111
|
+
mock_plist.exists.return_value = True
|
|
112
|
+
mock_run.return_value = MagicMock(returncode=0)
|
|
113
|
+
assert check_status() == 0
|
|
114
|
+
|
|
115
|
+
@patch("clipsy.__main__.subprocess.run")
|
|
116
|
+
@patch("clipsy.__main__.PLIST_PATH")
|
|
117
|
+
def test_not_running_installed(self, mock_plist, mock_run):
|
|
118
|
+
mock_plist.exists.return_value = True
|
|
119
|
+
mock_run.return_value = MagicMock(returncode=1)
|
|
120
|
+
assert check_status() == 1
|
|
121
|
+
|
|
122
|
+
@patch("clipsy.__main__.subprocess.run")
|
|
123
|
+
@patch("clipsy.__main__.PLIST_PATH")
|
|
124
|
+
def test_not_running_not_installed(self, mock_plist, mock_run):
|
|
125
|
+
mock_plist.exists.return_value = False
|
|
126
|
+
mock_run.return_value = MagicMock(returncode=1)
|
|
127
|
+
assert check_status() == 1
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TestCLIParsing:
|
|
131
|
+
@patch("clipsy.__main__.install_launchagent", return_value=0)
|
|
132
|
+
def test_default_installs(self, mock_install):
|
|
133
|
+
with patch("sys.argv", ["clipsy"]):
|
|
134
|
+
with pytest.raises(SystemExit) as exc:
|
|
135
|
+
main()
|
|
136
|
+
assert exc.value.code == 0
|
|
137
|
+
mock_install.assert_called_once()
|
|
138
|
+
|
|
139
|
+
@patch("clipsy.__main__.uninstall_launchagent", return_value=0)
|
|
140
|
+
def test_uninstall_command(self, mock_uninstall):
|
|
141
|
+
with patch("sys.argv", ["clipsy", "uninstall"]):
|
|
142
|
+
with pytest.raises(SystemExit) as exc:
|
|
143
|
+
main()
|
|
144
|
+
assert exc.value.code == 0
|
|
145
|
+
mock_uninstall.assert_called_once()
|
|
146
|
+
|
|
147
|
+
@patch("clipsy.__main__.check_status", return_value=0)
|
|
148
|
+
def test_status_command(self, mock_status):
|
|
149
|
+
with patch("sys.argv", ["clipsy", "status"]):
|
|
150
|
+
with pytest.raises(SystemExit) as exc:
|
|
151
|
+
main()
|
|
152
|
+
assert exc.value.code == 0
|
|
153
|
+
mock_status.assert_called_once()
|
|
154
|
+
|
|
155
|
+
@patch("clipsy.__main__.run_app")
|
|
156
|
+
def test_run_command(self, mock_run):
|
|
157
|
+
with patch("sys.argv", ["clipsy", "run"]):
|
|
158
|
+
main()
|
|
159
|
+
mock_run.assert_called_once()
|
|
@@ -369,3 +369,104 @@ class TestThumbnailGeneration:
|
|
|
369
369
|
entries = storage.get_recent()
|
|
370
370
|
assert len(entries) == 1
|
|
371
371
|
assert entries[0].thumbnail_path == str(thumb_path)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class TestRichTextClipboard:
|
|
375
|
+
def test_rtf_data_captured(self, monitor, mock_pasteboard, storage):
|
|
376
|
+
rtf_bytes = b"{\\rtf1\\ansi Hello \\b World\\b0}"
|
|
377
|
+
mock_pasteboard.changeCount.return_value = 1
|
|
378
|
+
mock_pasteboard.types.return_value = ["public.utf8-plain-text", "public.rtf"]
|
|
379
|
+
mock_pasteboard.stringForType_.return_value = "Hello World"
|
|
380
|
+
mock_pasteboard.dataForType_.return_value = rtf_bytes
|
|
381
|
+
|
|
382
|
+
with (
|
|
383
|
+
patch("clipsy.monitor.NSPasteboardTypeString", "public.utf8-plain-text"),
|
|
384
|
+
patch("clipsy.monitor.NSPasteboardTypeRTF", "public.rtf"),
|
|
385
|
+
patch("clipsy.monitor.NSPasteboardTypeHTML", "public.html"),
|
|
386
|
+
):
|
|
387
|
+
assert monitor.check_clipboard() is True
|
|
388
|
+
|
|
389
|
+
entries = storage.get_recent()
|
|
390
|
+
assert len(entries) == 1
|
|
391
|
+
assert entries[0].text_content == "Hello World"
|
|
392
|
+
assert entries[0].rtf_data == rtf_bytes
|
|
393
|
+
|
|
394
|
+
def test_html_data_captured(self, monitor, mock_pasteboard, storage):
|
|
395
|
+
html_bytes = b"<p>Hello <b>World</b></p>"
|
|
396
|
+
mock_pasteboard.changeCount.return_value = 1
|
|
397
|
+
mock_pasteboard.types.return_value = ["public.utf8-plain-text", "public.html"]
|
|
398
|
+
mock_pasteboard.stringForType_.return_value = "Hello World"
|
|
399
|
+
mock_pasteboard.dataForType_.return_value = html_bytes
|
|
400
|
+
|
|
401
|
+
with (
|
|
402
|
+
patch("clipsy.monitor.NSPasteboardTypeString", "public.utf8-plain-text"),
|
|
403
|
+
patch("clipsy.monitor.NSPasteboardTypeRTF", "public.rtf"),
|
|
404
|
+
patch("clipsy.monitor.NSPasteboardTypeHTML", "public.html"),
|
|
405
|
+
):
|
|
406
|
+
assert monitor.check_clipboard() is True
|
|
407
|
+
|
|
408
|
+
entries = storage.get_recent()
|
|
409
|
+
assert len(entries) == 1
|
|
410
|
+
assert entries[0].html_data == html_bytes
|
|
411
|
+
|
|
412
|
+
def test_both_rtf_and_html_captured(self, monitor, mock_pasteboard, storage):
|
|
413
|
+
rtf_bytes = b"{\\rtf1\\ansi Hello}"
|
|
414
|
+
html_bytes = b"<p>Hello</p>"
|
|
415
|
+
mock_pasteboard.changeCount.return_value = 1
|
|
416
|
+
mock_pasteboard.types.return_value = ["public.utf8-plain-text", "public.rtf", "public.html"]
|
|
417
|
+
mock_pasteboard.stringForType_.return_value = "Hello"
|
|
418
|
+
|
|
419
|
+
def data_for_type(type_str):
|
|
420
|
+
if type_str == "public.rtf":
|
|
421
|
+
return rtf_bytes
|
|
422
|
+
if type_str == "public.html":
|
|
423
|
+
return html_bytes
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
mock_pasteboard.dataForType_ = data_for_type
|
|
427
|
+
|
|
428
|
+
with (
|
|
429
|
+
patch("clipsy.monitor.NSPasteboardTypeString", "public.utf8-plain-text"),
|
|
430
|
+
patch("clipsy.monitor.NSPasteboardTypeRTF", "public.rtf"),
|
|
431
|
+
patch("clipsy.monitor.NSPasteboardTypeHTML", "public.html"),
|
|
432
|
+
):
|
|
433
|
+
assert monitor.check_clipboard() is True
|
|
434
|
+
|
|
435
|
+
entries = storage.get_recent()
|
|
436
|
+
assert len(entries) == 1
|
|
437
|
+
assert entries[0].rtf_data == rtf_bytes
|
|
438
|
+
assert entries[0].html_data == html_bytes
|
|
439
|
+
|
|
440
|
+
def test_plain_text_without_rtf(self, monitor, mock_pasteboard, storage):
|
|
441
|
+
mock_pasteboard.changeCount.return_value = 1
|
|
442
|
+
mock_pasteboard.types.return_value = ["public.utf8-plain-text"]
|
|
443
|
+
mock_pasteboard.stringForType_.return_value = "plain text"
|
|
444
|
+
|
|
445
|
+
with (
|
|
446
|
+
patch("clipsy.monitor.NSPasteboardTypeString", "public.utf8-plain-text"),
|
|
447
|
+
patch("clipsy.monitor.NSPasteboardTypeRTF", "public.rtf"),
|
|
448
|
+
patch("clipsy.monitor.NSPasteboardTypeHTML", "public.html"),
|
|
449
|
+
):
|
|
450
|
+
assert monitor.check_clipboard() is True
|
|
451
|
+
|
|
452
|
+
entries = storage.get_recent()
|
|
453
|
+
assert len(entries) == 1
|
|
454
|
+
assert entries[0].rtf_data is None
|
|
455
|
+
assert entries[0].html_data is None
|
|
456
|
+
|
|
457
|
+
def test_rtf_data_for_type_returns_none(self, monitor, mock_pasteboard, storage):
|
|
458
|
+
mock_pasteboard.changeCount.return_value = 1
|
|
459
|
+
mock_pasteboard.types.return_value = ["public.utf8-plain-text", "public.rtf"]
|
|
460
|
+
mock_pasteboard.stringForType_.return_value = "Hello"
|
|
461
|
+
mock_pasteboard.dataForType_.return_value = None
|
|
462
|
+
|
|
463
|
+
with (
|
|
464
|
+
patch("clipsy.monitor.NSPasteboardTypeString", "public.utf8-plain-text"),
|
|
465
|
+
patch("clipsy.monitor.NSPasteboardTypeRTF", "public.rtf"),
|
|
466
|
+
patch("clipsy.monitor.NSPasteboardTypeHTML", "public.html"),
|
|
467
|
+
):
|
|
468
|
+
assert monitor.check_clipboard() is True
|
|
469
|
+
|
|
470
|
+
entries = storage.get_recent()
|
|
471
|
+
assert len(entries) == 1
|
|
472
|
+
assert entries[0].rtf_data is None
|
|
@@ -346,3 +346,82 @@ class TestFileCleanup:
|
|
|
346
346
|
# Check that old files were deleted
|
|
347
347
|
remaining_files = list(tmp_path.glob("*.png"))
|
|
348
348
|
assert len(remaining_files) == 4 # 2 images + 2 thumbnails
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class TestRichTextEntries:
|
|
352
|
+
def test_store_and_retrieve_rtf_data(self, storage, make_entry):
|
|
353
|
+
rtf_bytes = b"{\\rtf1\\ansi Hello \\b World\\b0}"
|
|
354
|
+
entry = make_entry("Hello World", rtf_data=rtf_bytes)
|
|
355
|
+
entry_id = storage.add_entry(entry)
|
|
356
|
+
retrieved = storage.get_entry(entry_id)
|
|
357
|
+
assert retrieved.rtf_data == rtf_bytes
|
|
358
|
+
assert retrieved.text_content == "Hello World"
|
|
359
|
+
|
|
360
|
+
def test_store_and_retrieve_html_data(self, storage, make_entry):
|
|
361
|
+
html_bytes = b"<p>Hello <b>World</b></p>"
|
|
362
|
+
entry = make_entry("Hello World", html_data=html_bytes, content_hash="html_hash")
|
|
363
|
+
entry_id = storage.add_entry(entry)
|
|
364
|
+
retrieved = storage.get_entry(entry_id)
|
|
365
|
+
assert retrieved.html_data == html_bytes
|
|
366
|
+
|
|
367
|
+
def test_store_both_rtf_and_html(self, storage, make_entry):
|
|
368
|
+
rtf_bytes = b"{\\rtf1\\ansi Hello \\b World\\b0}"
|
|
369
|
+
html_bytes = b"<p>Hello <b>World</b></p>"
|
|
370
|
+
entry = make_entry("Hello World", rtf_data=rtf_bytes, html_data=html_bytes)
|
|
371
|
+
entry_id = storage.add_entry(entry)
|
|
372
|
+
retrieved = storage.get_entry(entry_id)
|
|
373
|
+
assert retrieved.rtf_data == rtf_bytes
|
|
374
|
+
assert retrieved.html_data == html_bytes
|
|
375
|
+
|
|
376
|
+
def test_entry_without_rtf_data(self, storage, make_entry):
|
|
377
|
+
entry = make_entry("plain text only")
|
|
378
|
+
entry_id = storage.add_entry(entry)
|
|
379
|
+
retrieved = storage.get_entry(entry_id)
|
|
380
|
+
assert retrieved.rtf_data is None
|
|
381
|
+
assert retrieved.html_data is None
|
|
382
|
+
|
|
383
|
+
def test_search_still_uses_text_content(self, storage, make_entry):
|
|
384
|
+
rtf_bytes = b"{\\rtf1\\ansi Hello}"
|
|
385
|
+
entry = make_entry("Hello World", rtf_data=rtf_bytes)
|
|
386
|
+
storage.add_entry(entry)
|
|
387
|
+
results = storage.search("Hello")
|
|
388
|
+
assert len(results) == 1
|
|
389
|
+
assert results[0].rtf_data == rtf_bytes
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class TestRichTextMigration:
|
|
393
|
+
def test_migrate_adds_rtf_and_html_columns(self, tmp_path):
|
|
394
|
+
"""Test that migration adds rtf_data and html_data to old databases."""
|
|
395
|
+
import sqlite3
|
|
396
|
+
from clipsy.storage import StorageManager
|
|
397
|
+
|
|
398
|
+
db_file = tmp_path / "old_db_no_rtf.sqlite"
|
|
399
|
+
conn = sqlite3.connect(str(db_file))
|
|
400
|
+
conn.executescript("""
|
|
401
|
+
CREATE TABLE clipboard_entries (
|
|
402
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
403
|
+
content_type TEXT NOT NULL,
|
|
404
|
+
text_content TEXT,
|
|
405
|
+
image_path TEXT,
|
|
406
|
+
preview TEXT NOT NULL,
|
|
407
|
+
content_hash TEXT NOT NULL,
|
|
408
|
+
byte_size INTEGER NOT NULL DEFAULT 0,
|
|
409
|
+
created_at TEXT NOT NULL,
|
|
410
|
+
pinned INTEGER NOT NULL DEFAULT 0,
|
|
411
|
+
source_app TEXT,
|
|
412
|
+
thumbnail_path TEXT,
|
|
413
|
+
is_sensitive INTEGER NOT NULL DEFAULT 0,
|
|
414
|
+
masked_preview TEXT
|
|
415
|
+
);
|
|
416
|
+
""")
|
|
417
|
+
conn.commit()
|
|
418
|
+
conn.close()
|
|
419
|
+
|
|
420
|
+
mgr = StorageManager(db_path=str(db_file))
|
|
421
|
+
|
|
422
|
+
cursor = mgr._conn.execute("PRAGMA table_info(clipboard_entries)")
|
|
423
|
+
columns = {row[1] for row in cursor.fetchall()}
|
|
424
|
+
assert "rtf_data" in columns
|
|
425
|
+
assert "html_data" in columns
|
|
426
|
+
|
|
427
|
+
mgr.close()
|
|
@@ -1,153 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|