clipsy 1.7.1__py3-none-any.whl → 1.8.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 CHANGED
@@ -1,2 +1,2 @@
1
- __version__ = "1.7.1"
1
+ __version__ = "1.8.0"
2
2
  __app_name__ = "Clipsy"
clipsy/app.py CHANGED
@@ -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()
clipsy/config.py CHANGED
@@ -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
- MENU_DISPLAY_COUNT = 10 # items shown in dropdown
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.)
clipsy/models.py CHANGED
@@ -24,3 +24,5 @@ class ClipboardEntry:
24
24
  thumbnail_path: str | None = None
25
25
  is_sensitive: bool = False
26
26
  masked_preview: str | None = None
27
+ rtf_data: bytes | None = None
28
+ html_data: bytes | None = None
clipsy/monitor.py CHANGED
@@ -5,6 +5,16 @@ from pathlib import Path
5
5
 
6
6
  from AppKit import NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeTIFF, NSPasteboardTypeString, NSFilenamesPboardType
7
7
 
8
+ try:
9
+ from AppKit import NSPasteboardTypeRTF
10
+ except ImportError:
11
+ NSPasteboardTypeRTF = "public.rtf"
12
+
13
+ try:
14
+ from AppKit import NSPasteboardTypeHTML
15
+ except ImportError:
16
+ NSPasteboardTypeHTML = "public.html"
17
+
8
18
  from clipsy.config import IMAGE_DIR, MAX_IMAGE_SIZE, MAX_TEXT_SIZE, PREVIEW_LENGTH, REDACT_SENSITIVE, THUMBNAIL_SIZE
9
19
  from clipsy.models import ClipboardEntry, ContentType
10
20
  from clipsy.redact import detect_sensitive, mask_text
@@ -57,84 +67,122 @@ class ClipboardMonitor:
57
67
  return None
58
68
 
59
69
  if NSPasteboardTypeString in types:
60
- 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
- )
70
+ entry = self._read_text()
71
+ if entry:
72
+ return entry
88
73
 
89
74
  for img_type in (NSPasteboardTypePNG, NSPasteboardTypeTIFF):
90
75
  if img_type in types:
91
- 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
- )
76
+ entry = self._read_image(img_type)
77
+ if entry:
78
+ return entry
114
79
 
115
80
  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
- )
81
+ return self._read_files()
135
82
 
136
83
  return None
137
84
 
85
+ def _read_text(self) -> ClipboardEntry | None:
86
+ text = self._pasteboard.stringForType_(NSPasteboardTypeString)
87
+ if not text:
88
+ return None
89
+
90
+ text_bytes = text.encode("utf-8")
91
+ if len(text_bytes) > MAX_TEXT_SIZE:
92
+ return None
93
+
94
+ content_hash = compute_hash(text_bytes)
95
+ preview = truncate_text(text, PREVIEW_LENGTH)
96
+
97
+ rtf_data = None
98
+ html_data = None
99
+ types = self._pasteboard.types()
100
+ if types:
101
+ if NSPasteboardTypeRTF in types:
102
+ data = self._pasteboard.dataForType_(NSPasteboardTypeRTF)
103
+ if data:
104
+ rtf_data = bytes(data)
105
+ if NSPasteboardTypeHTML in types:
106
+ data = self._pasteboard.dataForType_(NSPasteboardTypeHTML)
107
+ if data:
108
+ html_data = bytes(data)
109
+
110
+ is_sensitive = False
111
+ masked_preview = None
112
+ if REDACT_SENSITIVE:
113
+ matches = detect_sensitive(text)
114
+ if matches:
115
+ is_sensitive = True
116
+ masked_preview = truncate_text(mask_text(text, matches), PREVIEW_LENGTH)
117
+
118
+ return ClipboardEntry(
119
+ id=None,
120
+ content_type=ContentType.TEXT,
121
+ text_content=text,
122
+ image_path=None,
123
+ preview=preview,
124
+ content_hash=content_hash,
125
+ byte_size=len(text_bytes),
126
+ created_at=datetime.now(),
127
+ is_sensitive=is_sensitive,
128
+ masked_preview=masked_preview,
129
+ rtf_data=rtf_data,
130
+ html_data=html_data,
131
+ )
132
+
133
+ def _read_image(self, img_type) -> ClipboardEntry | None:
134
+ data = self._pasteboard.dataForType_(img_type)
135
+ if data is None:
136
+ return None
137
+
138
+ img_bytes = bytes(data)
139
+ if len(img_bytes) > MAX_IMAGE_SIZE:
140
+ logger.warning("Image too large (%d bytes), skipping", len(img_bytes))
141
+ return None
142
+
143
+ content_hash = compute_hash(img_bytes)
144
+ is_png = img_type == NSPasteboardTypePNG
145
+ image_path, thumbnail_path = self._save_image(img_bytes, content_hash, is_png)
146
+ width, height = get_image_dimensions(img_bytes)
147
+ preview = f"[Image: {width}x{height}]" if width > 0 else "[Image]"
148
+
149
+ return ClipboardEntry(
150
+ id=None,
151
+ content_type=ContentType.IMAGE,
152
+ text_content=None,
153
+ image_path=str(image_path),
154
+ preview=preview,
155
+ content_hash=content_hash,
156
+ byte_size=len(img_bytes),
157
+ created_at=datetime.now(),
158
+ thumbnail_path=str(thumbnail_path) if thumbnail_path else None,
159
+ )
160
+
161
+ def _read_files(self) -> ClipboardEntry | None:
162
+ filenames = self._pasteboard.propertyListForType_(NSFilenamesPboardType)
163
+ if not filenames:
164
+ return None
165
+
166
+ file_list = list(filenames)
167
+ text = "\n".join(file_list)
168
+ content_hash = compute_hash(text)
169
+
170
+ if len(file_list) == 1:
171
+ preview = truncate_text(Path(file_list[0]).name, PREVIEW_LENGTH)
172
+ else:
173
+ preview = truncate_text(f"{len(file_list)} files: {Path(file_list[0]).name}, ...", PREVIEW_LENGTH)
174
+
175
+ return ClipboardEntry(
176
+ id=None,
177
+ content_type=ContentType.FILE,
178
+ text_content=text,
179
+ image_path=None,
180
+ preview=preview,
181
+ content_hash=content_hash,
182
+ byte_size=len(text.encode("utf-8")),
183
+ created_at=datetime.now(),
184
+ )
185
+
138
186
  def _save_image(self, img_bytes: bytes, content_hash: str, is_png: bool) -> tuple[Path, Path | None]:
139
187
  ext = ".png" if is_png else ".tiff"
140
188
  filename = content_hash[:12] + ext
clipsy/storage.py CHANGED
@@ -20,7 +20,9 @@ CREATE TABLE IF NOT EXISTS clipboard_entries (
20
20
  source_app TEXT,
21
21
  thumbnail_path TEXT,
22
22
  is_sensitive INTEGER NOT NULL DEFAULT 0,
23
- masked_preview TEXT
23
+ masked_preview TEXT,
24
+ rtf_data BLOB,
25
+ html_data BLOB
24
26
  );
25
27
 
26
28
  CREATE INDEX IF NOT EXISTS idx_created_at ON clipboard_entries(created_at DESC);
@@ -70,12 +72,24 @@ class StorageManager:
70
72
  self._conn.execute("ALTER TABLE clipboard_entries ADD COLUMN is_sensitive INTEGER NOT NULL DEFAULT 0")
71
73
  if "masked_preview" not in columns:
72
74
  self._conn.execute("ALTER TABLE clipboard_entries ADD COLUMN masked_preview TEXT")
75
+ if "rtf_data" not in columns:
76
+ self._conn.execute("ALTER TABLE clipboard_entries ADD COLUMN rtf_data BLOB")
77
+ if "html_data" not in columns:
78
+ self._conn.execute("ALTER TABLE clipboard_entries ADD COLUMN html_data BLOB")
79
+
80
+ @staticmethod
81
+ def _delete_files(image_path: str | None, thumbnail_path: str | None) -> None:
82
+ for file_path in (image_path, thumbnail_path):
83
+ if file_path:
84
+ p = Path(file_path)
85
+ if p.exists():
86
+ p.unlink()
73
87
 
74
88
  def add_entry(self, entry: ClipboardEntry) -> int:
75
89
  cursor = self._conn.execute(
76
90
  """INSERT INTO clipboard_entries
77
- (content_type, text_content, image_path, preview, content_hash, byte_size, created_at, pinned, source_app, thumbnail_path, is_sensitive, masked_preview)
78
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
91
+ (content_type, text_content, image_path, preview, content_hash, byte_size, created_at, pinned, source_app, thumbnail_path, is_sensitive, masked_preview, rtf_data, html_data)
92
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
79
93
  (
80
94
  entry.content_type.value,
81
95
  entry.text_content,
@@ -89,6 +103,8 @@ class StorageManager:
89
103
  entry.thumbnail_path,
90
104
  int(entry.is_sensitive),
91
105
  entry.masked_preview,
106
+ entry.rtf_data,
107
+ entry.html_data,
92
108
  ),
93
109
  )
94
110
  self._conn.commit()
@@ -124,14 +140,7 @@ class StorageManager:
124
140
  def delete_entry(self, entry_id: int) -> None:
125
141
  entry = self.get_entry(entry_id)
126
142
  if entry:
127
- 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()
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
- 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()
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
- 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()
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.7.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 pipx (recommended)
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
@@ -0,0 +1,15 @@
1
+ clipsy/__init__.py,sha256=2M96CO-qBjhCYnYgi1FZuen5WyQOvlbtLUIIPCDsES0,46
2
+ clipsy/__main__.py,sha256=AzUjrEhnuVX5Mkk3QhfmAO17cyvIYOABsz7QWYmw1r8,4991
3
+ clipsy/app.py,sha256=e987geHCfDs3_9GulTZaS2iJT-BUL_iwpNtAmt6kPW8,7786
4
+ clipsy/config.py,sha256=YetSNZkSkSOHYnCZpty6sGeG99MajbN4goL8UkzJ62A,913
5
+ clipsy/models.py,sha256=9wuaq1t9HhaH8QYuieN7fEVM_RTYEvv6ZUCM_InOMKk,632
6
+ clipsy/monitor.py,sha256=PBTxK53arFm5VVCHIYlxz8eoYQrgTwWsIWNE49IE7oU,6904
7
+ clipsy/redact.py,sha256=EU2nq8oEDYLZ_I_9tX2bXACIF971a0V_ApKdvpN4VGY,7284
8
+ clipsy/storage.py,sha256=ZcuW2uFvJM0Cd3UQEWIMSZYH2DYWM6lD6yol6VpM5io,10047
9
+ clipsy/utils.py,sha256=rztT20cXYJ7633q3oLwt6Fv0Ubq9hm5bPPKDkWyvxdI,2208
10
+ clipsy-1.8.0.dist-info/licenses/LICENSE,sha256=bZ7uVihgPnLGryi5ugUgkVrEoho0_hFAd3bVdhYXGqs,1071
11
+ clipsy-1.8.0.dist-info/METADATA,sha256=UMKIznK4wUar5eV0ic7QVLuoRdZN4fMT6AL34BbuHdE,5884
12
+ clipsy-1.8.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
13
+ clipsy-1.8.0.dist-info/entry_points.txt,sha256=ISIoWU3Zj-4NEuj2pJW96LxpsvjRJJH73otv9gA9YSs,48
14
+ clipsy-1.8.0.dist-info/top_level.txt,sha256=trxprVJk4ZMudCshc7PD0N9iFgQO4Tq4sW5L5wLduns,7
15
+ clipsy-1.8.0.dist-info/RECORD,,
@@ -1,15 +0,0 @@
1
- clipsy/__init__.py,sha256=SiyI4J0QW-zHfQ-u7WyAEHr27xsi2W5hxIJPHCKqMq4,46
2
- clipsy/__main__.py,sha256=AzUjrEhnuVX5Mkk3QhfmAO17cyvIYOABsz7QWYmw1r8,4991
3
- clipsy/app.py,sha256=_IKuZgA-DvAE3KDsDN00S0aaMIxwBpiv_P1fpzwQFqU,7299
4
- clipsy/config.py,sha256=E9NCsGMeT2H0YXYCq5xYwbd27VkksV4S9txZSMX0RmE,672
5
- clipsy/models.py,sha256=i3KjD4Dcp7y_gTXkIBr2xZxFIIC6kHaMWmv6cSIDABM,563
6
- clipsy/monitor.py,sha256=UUKZToYcZ5pzu7LfiuJ_nnE4AA-d3uLW-WdQU_XrDME,6288
7
- clipsy/redact.py,sha256=EU2nq8oEDYLZ_I_9tX2bXACIF971a0V_ApKdvpN4VGY,7284
8
- clipsy/storage.py,sha256=QNox2-spkVx_gMPSWqKgF_yj-SMpRJ4UAT6yd9l6rVY,9794
9
- clipsy/utils.py,sha256=rztT20cXYJ7633q3oLwt6Fv0Ubq9hm5bPPKDkWyvxdI,2208
10
- clipsy-1.7.1.dist-info/licenses/LICENSE,sha256=bZ7uVihgPnLGryi5ugUgkVrEoho0_hFAd3bVdhYXGqs,1071
11
- clipsy-1.7.1.dist-info/METADATA,sha256=o3ccHVdbPvMkh49ZfyqM4WHnCn8YJoEolpQDJ0uj74A,5811
12
- clipsy-1.7.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
13
- clipsy-1.7.1.dist-info/entry_points.txt,sha256=ISIoWU3Zj-4NEuj2pJW96LxpsvjRJJH73otv9gA9YSs,48
14
- clipsy-1.7.1.dist-info/top_level.txt,sha256=trxprVJk4ZMudCshc7PD0N9iFgQO4Tq4sW5L5wLduns,7
15
- clipsy-1.7.1.dist-info/RECORD,,
File without changes