clipsy 1.8.0__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.
Files changed (27) hide show
  1. {clipsy-1.8.0/src/clipsy.egg-info → clipsy-1.9.0}/PKG-INFO +21 -2
  2. {clipsy-1.8.0 → clipsy-1.9.0}/README.md +20 -1
  3. {clipsy-1.8.0 → clipsy-1.9.0}/pyproject.toml +1 -1
  4. {clipsy-1.8.0 → clipsy-1.9.0}/src/clipsy/__init__.py +1 -1
  5. {clipsy-1.8.0 → clipsy-1.9.0}/src/clipsy/app.py +54 -3
  6. {clipsy-1.8.0 → clipsy-1.9.0}/src/clipsy/config.py +1 -0
  7. {clipsy-1.8.0 → clipsy-1.9.0}/src/clipsy/storage.py +15 -0
  8. {clipsy-1.8.0 → clipsy-1.9.0/src/clipsy.egg-info}/PKG-INFO +21 -2
  9. {clipsy-1.8.0 → clipsy-1.9.0}/tests/test_app.py +77 -0
  10. {clipsy-1.8.0 → clipsy-1.9.0}/tests/test_monitor.py +67 -0
  11. {clipsy-1.8.0 → clipsy-1.9.0}/tests/test_storage.py +68 -0
  12. {clipsy-1.8.0 → clipsy-1.9.0}/tests/test_utils.py +63 -0
  13. {clipsy-1.8.0 → clipsy-1.9.0}/LICENSE +0 -0
  14. {clipsy-1.8.0 → clipsy-1.9.0}/setup.cfg +0 -0
  15. {clipsy-1.8.0 → clipsy-1.9.0}/src/clipsy/__main__.py +0 -0
  16. {clipsy-1.8.0 → clipsy-1.9.0}/src/clipsy/models.py +0 -0
  17. {clipsy-1.8.0 → clipsy-1.9.0}/src/clipsy/monitor.py +0 -0
  18. {clipsy-1.8.0 → clipsy-1.9.0}/src/clipsy/redact.py +0 -0
  19. {clipsy-1.8.0 → clipsy-1.9.0}/src/clipsy/utils.py +0 -0
  20. {clipsy-1.8.0 → clipsy-1.9.0}/src/clipsy.egg-info/SOURCES.txt +0 -0
  21. {clipsy-1.8.0 → clipsy-1.9.0}/src/clipsy.egg-info/dependency_links.txt +0 -0
  22. {clipsy-1.8.0 → clipsy-1.9.0}/src/clipsy.egg-info/entry_points.txt +0 -0
  23. {clipsy-1.8.0 → clipsy-1.9.0}/src/clipsy.egg-info/requires.txt +0 -0
  24. {clipsy-1.8.0 → clipsy-1.9.0}/src/clipsy.egg-info/top_level.txt +0 -0
  25. {clipsy-1.8.0 → clipsy-1.9.0}/tests/test_config.py +0 -0
  26. {clipsy-1.8.0 → clipsy-1.9.0}/tests/test_main.py +0 -0
  27. {clipsy-1.8.0 → 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.8.0
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
@@ -103,11 +105,15 @@ Then just use your Mac normally. Every time you copy something, it shows up in t
103
105
  ├── ──────────────────
104
106
  ├── Search...
105
107
  ├── ──────────────────
108
+ ├── 📌 Pinned ►
109
+ │ ├── "my-api-endpoint.com/v1..."
110
+ │ └── "SELECT * FROM users..."
111
+ ├── ──────────────────
106
112
  ├── "Meeting notes for Q3 plan..."
107
113
  ├── "https://github.com/example..."
108
114
  ├── 🔒 "password=••••••••"
109
115
  ├── [thumbnail] "[Image: 1920x1080]"
110
- ├── ... (up to 10 items)
116
+ ├── ... (up to 10 items, configurable)
111
117
  ├── ──────────────────
112
118
  ├── Clear History
113
119
  ├── ──────────────────
@@ -116,6 +122,8 @@ Then just use your Mac normally. Every time you copy something, it shows up in t
116
122
  └── Quit Clipsy
117
123
  ```
118
124
 
125
+ **Tip:** Hold **Option (⌥)** while clicking an entry to pin/unpin it.
126
+
119
127
  ## Commands
120
128
 
121
129
  ```bash
@@ -125,6 +133,17 @@ clipsy uninstall # Remove from login items
125
133
  clipsy run # Run in foreground (for debugging)
126
134
  ```
127
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
+
128
147
  ## Data Storage
129
148
 
130
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
@@ -72,11 +74,15 @@ Then just use your Mac normally. Every time you copy something, it shows up in t
72
74
  ├── ──────────────────
73
75
  ├── Search...
74
76
  ├── ──────────────────
77
+ ├── 📌 Pinned ►
78
+ │ ├── "my-api-endpoint.com/v1..."
79
+ │ └── "SELECT * FROM users..."
80
+ ├── ──────────────────
75
81
  ├── "Meeting notes for Q3 plan..."
76
82
  ├── "https://github.com/example..."
77
83
  ├── 🔒 "password=••••••••"
78
84
  ├── [thumbnail] "[Image: 1920x1080]"
79
- ├── ... (up to 10 items)
85
+ ├── ... (up to 10 items, configurable)
80
86
  ├── ──────────────────
81
87
  ├── Clear History
82
88
  ├── ──────────────────
@@ -85,6 +91,8 @@ Then just use your Mac normally. Every time you copy something, it shows up in t
85
91
  └── Quit Clipsy
86
92
  ```
87
93
 
94
+ **Tip:** Hold **Option (⌥)** while clicking an entry to pin/unpin it.
95
+
88
96
  ## Commands
89
97
 
90
98
  ```bash
@@ -94,6 +102,17 @@ clipsy uninstall # Remove from login items
94
102
  clipsy run # Run in foreground (for debugging)
95
103
  ```
96
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
+
97
116
  ## Data Storage
98
117
 
99
118
  All data is stored in `~/.local/share/clipsy/`:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "clipsy"
7
- version = "1.8.0"
7
+ version = "1.9.0"
8
8
  description = "Lightweight clipboard history manager for macOS"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -1,2 +1,2 @@
1
- __version__ = "1.8.0"
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 not entries:
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,6 +132,17 @@ 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
123
148
  from Foundation import NSData
@@ -161,6 +186,32 @@ class ClipsyApp(rumps.App):
161
186
  except Exception:
162
187
  logger.exception("Error copying entry to clipboard")
163
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
+
164
215
  def _on_search(self, _sender) -> None:
165
216
  response = rumps.Window(
166
217
  message="Search clipboard history:",
@@ -25,3 +25,4 @@ def _parse_menu_display_count() -> int:
25
25
  MENU_DISPLAY_COUNT = _parse_menu_display_count()
26
26
  THUMBNAIL_SIZE = (32, 32) # pixels, for menu icon display
27
27
  REDACT_SENSITIVE = True # mask sensitive data in preview (API keys, passwords, etc.)
28
+ MAX_PINNED_ENTRIES = 5 # maximum number of pinned entries allowed
@@ -211,6 +211,21 @@ class StorageManager:
211
211
  row = self._conn.execute("SELECT COUNT(*) as cnt FROM clipboard_entries").fetchone()
212
212
  return row["cnt"]
213
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
+
214
229
  def close(self) -> None:
215
230
  self._conn.close()
216
231
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clipsy
3
- Version: 1.8.0
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
@@ -103,11 +105,15 @@ Then just use your Mac normally. Every time you copy something, it shows up in t
103
105
  ├── ──────────────────
104
106
  ├── Search...
105
107
  ├── ──────────────────
108
+ ├── 📌 Pinned ►
109
+ │ ├── "my-api-endpoint.com/v1..."
110
+ │ └── "SELECT * FROM users..."
111
+ ├── ──────────────────
106
112
  ├── "Meeting notes for Q3 plan..."
107
113
  ├── "https://github.com/example..."
108
114
  ├── 🔒 "password=••••••••"
109
115
  ├── [thumbnail] "[Image: 1920x1080]"
110
- ├── ... (up to 10 items)
116
+ ├── ... (up to 10 items, configurable)
111
117
  ├── ──────────────────
112
118
  ├── Clear History
113
119
  ├── ──────────────────
@@ -116,6 +122,8 @@ Then just use your Mac normally. Every time you copy something, it shows up in t
116
122
  └── Quit Clipsy
117
123
  ```
118
124
 
125
+ **Tip:** Hold **Option (⌥)** while clicking an entry to pin/unpin it.
126
+
119
127
  ## Commands
120
128
 
121
129
  ```bash
@@ -125,6 +133,17 @@ clipsy uninstall # Remove from login items
125
133
  clipsy run # Run in foreground (for debugging)
126
134
  ```
127
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
+
128
147
  ## Data Storage
129
148
 
130
149
  All data is stored in `~/.local/share/clipsy/`:
@@ -238,3 +238,80 @@ class TestRichTextRestoration:
238
238
  assert entry.rtf_data is None
239
239
  assert entry.html_data is None
240
240
  assert entry.text_content == "plain text"
241
+
242
+
243
+ class TestPinningBehavior:
244
+ """Test pinning-related behavior at the app level."""
245
+
246
+ def test_pinned_entries_not_in_recent_list(self, storage, make_entry):
247
+ id1 = storage.add_entry(make_entry("pinned entry", content_hash="h1"))
248
+ id2 = storage.add_entry(make_entry("regular entry", content_hash="h2"))
249
+ storage.toggle_pin(id1)
250
+
251
+ recent = storage.get_recent()
252
+ pinned = storage.get_pinned()
253
+
254
+ # Filter recent like app.py does
255
+ recent_unpinned = [e for e in recent if not e.pinned]
256
+
257
+ assert len(pinned) == 1
258
+ assert pinned[0].id == id1
259
+ assert all(e.id != id1 for e in recent_unpinned)
260
+
261
+ def test_cannot_pin_sensitive_entry(self, storage, make_entry):
262
+ from clipsy.models import ClipboardEntry, ContentType
263
+ from datetime import datetime
264
+
265
+ sensitive_entry = ClipboardEntry(
266
+ id=None,
267
+ content_type=ContentType.TEXT,
268
+ text_content="password=secret123",
269
+ image_path=None,
270
+ preview="password=secret123",
271
+ content_hash="sensitive_hash",
272
+ byte_size=20,
273
+ created_at=datetime.now(),
274
+ is_sensitive=True,
275
+ masked_preview="password=••••••••",
276
+ )
277
+ entry_id = storage.add_entry(sensitive_entry)
278
+ entry = storage.get_entry(entry_id)
279
+
280
+ # The entry is sensitive
281
+ assert entry.is_sensitive is True
282
+
283
+ # Simulate app-level check: sensitive entries should not be pinned
284
+ # This mimics what _on_pin_toggle does
285
+ if entry.is_sensitive:
286
+ can_pin = False
287
+ else:
288
+ can_pin = True
289
+
290
+ assert can_pin is False
291
+
292
+ def test_max_pinned_limit(self, storage, make_entry):
293
+ from clipsy.config import MAX_PINNED_ENTRIES
294
+
295
+ # Pin up to the limit
296
+ for i in range(MAX_PINNED_ENTRIES):
297
+ entry_id = storage.add_entry(make_entry(f"entry {i}", content_hash=f"hash_{i}"))
298
+ storage.toggle_pin(entry_id)
299
+
300
+ assert storage.count_pinned() == MAX_PINNED_ENTRIES
301
+
302
+ # App should check this before allowing another pin
303
+ at_limit = storage.count_pinned() >= MAX_PINNED_ENTRIES
304
+ assert at_limit is True
305
+
306
+ def test_clear_pinned_clears_all(self, storage, make_entry):
307
+ id1 = storage.add_entry(make_entry("entry 1", content_hash="h1"))
308
+ id2 = storage.add_entry(make_entry("entry 2", content_hash="h2"))
309
+ storage.toggle_pin(id1)
310
+ storage.toggle_pin(id2)
311
+
312
+ assert storage.count_pinned() == 2
313
+
314
+ storage.clear_pinned()
315
+
316
+ assert storage.count_pinned() == 0
317
+ assert storage.get_pinned() == []
@@ -1,3 +1,6 @@
1
+ import importlib
2
+ import sys
3
+ import types as stdlib_types
1
4
  from unittest.mock import MagicMock, patch
2
5
 
3
6
  import pytest
@@ -82,6 +85,29 @@ class TestCheckClipboard:
82
85
  assert monitor.check_clipboard() is False
83
86
  assert storage.count() == 0
84
87
 
88
+ def test_empty_text_returns_no_entry(self, monitor, mock_pasteboard, storage):
89
+ mock_pasteboard.changeCount.return_value = 1
90
+ mock_pasteboard.types.return_value = ["public.utf8-plain-text"]
91
+ mock_pasteboard.stringForType_.return_value = ""
92
+
93
+ with patch("clipsy.monitor.NSPasteboardTypeString", "public.utf8-plain-text"):
94
+ assert monitor.check_clipboard() is False
95
+
96
+ assert storage.count() == 0
97
+
98
+ def test_oversized_text_skipped(self, monitor, mock_pasteboard, storage):
99
+ mock_pasteboard.changeCount.return_value = 1
100
+ mock_pasteboard.types.return_value = ["public.utf8-plain-text"]
101
+ mock_pasteboard.stringForType_.return_value = "x" * 2_000_000
102
+
103
+ with (
104
+ patch("clipsy.monitor.NSPasteboardTypeString", "public.utf8-plain-text"),
105
+ patch("clipsy.monitor.MAX_TEXT_SIZE", 1_000_000),
106
+ ):
107
+ assert monitor.check_clipboard() is False
108
+
109
+ assert storage.count() == 0
110
+
85
111
  def test_none_types_no_crash(self, monitor, mock_pasteboard):
86
112
  mock_pasteboard.changeCount.return_value = 1
87
113
  mock_pasteboard.types.return_value = None
@@ -108,6 +134,21 @@ class TestFileClipboard:
108
134
  assert entries[0].content_type == ContentType.FILE
109
135
  assert "document.pdf" in entries[0].preview
110
136
 
137
+ def test_empty_filenames_returns_no_entry(self, monitor, mock_pasteboard, storage):
138
+ mock_pasteboard.changeCount.return_value = 1
139
+ mock_pasteboard.types.return_value = ["NSFilenamesPboardType"]
140
+ mock_pasteboard.propertyListForType_.return_value = None
141
+
142
+ with (
143
+ patch("clipsy.monitor.NSPasteboardTypeString", "public.utf8-plain-text"),
144
+ patch("clipsy.monitor.NSPasteboardTypePNG", "public.png"),
145
+ patch("clipsy.monitor.NSPasteboardTypeTIFF", "public.tiff"),
146
+ patch("clipsy.monitor.NSFilenamesPboardType", "NSFilenamesPboardType"),
147
+ ):
148
+ assert monitor.check_clipboard() is False
149
+
150
+ assert storage.count() == 0
151
+
111
152
  def test_multiple_files(self, monitor, mock_pasteboard, storage):
112
153
  mock_pasteboard.changeCount.return_value = 1
113
154
  mock_pasteboard.types.return_value = ["NSFilenamesPboardType"]
@@ -470,3 +511,29 @@ class TestRichTextClipboard:
470
511
  entries = storage.get_recent()
471
512
  assert len(entries) == 1
472
513
  assert entries[0].rtf_data is None
514
+
515
+
516
+ class TestImportFallbacks:
517
+ def test_rtf_and_html_import_fallback(self):
518
+ import clipsy.monitor as monitor_mod
519
+
520
+ real_appkit = sys.modules["AppKit"]
521
+
522
+ fake_appkit = stdlib_types.ModuleType("AppKit")
523
+ for attr in (
524
+ "NSPasteboard",
525
+ "NSPasteboardTypePNG",
526
+ "NSPasteboardTypeTIFF",
527
+ "NSPasteboardTypeString",
528
+ "NSFilenamesPboardType",
529
+ ):
530
+ setattr(fake_appkit, attr, getattr(real_appkit, attr))
531
+
532
+ try:
533
+ sys.modules["AppKit"] = fake_appkit
534
+ importlib.reload(monitor_mod)
535
+ assert monitor_mod.NSPasteboardTypeRTF == "public.rtf"
536
+ assert monitor_mod.NSPasteboardTypeHTML == "public.html"
537
+ finally:
538
+ sys.modules["AppKit"] = real_appkit
539
+ importlib.reload(monitor_mod)
@@ -389,6 +389,74 @@ class TestRichTextEntries:
389
389
  assert results[0].rtf_data == rtf_bytes
390
390
 
391
391
 
392
+ class TestPinnedEntries:
393
+ def test_get_pinned_empty(self, storage):
394
+ assert storage.get_pinned() == []
395
+
396
+ def test_get_pinned_returns_pinned_entries(self, storage, make_entry):
397
+ id1 = storage.add_entry(make_entry("entry 1"))
398
+ id2 = storage.add_entry(make_entry("entry 2", content_hash="hash2"))
399
+ storage.toggle_pin(id1)
400
+
401
+ pinned = storage.get_pinned()
402
+ assert len(pinned) == 1
403
+ assert pinned[0].id == id1
404
+ assert pinned[0].pinned is True
405
+
406
+ def test_count_pinned(self, storage, make_entry):
407
+ assert storage.count_pinned() == 0
408
+
409
+ id1 = storage.add_entry(make_entry("entry 1"))
410
+ id2 = storage.add_entry(make_entry("entry 2", content_hash="hash2"))
411
+ storage.toggle_pin(id1)
412
+ storage.toggle_pin(id2)
413
+
414
+ assert storage.count_pinned() == 2
415
+
416
+ def test_toggle_pin_on_and_off(self, storage, make_entry):
417
+ entry_id = storage.add_entry(make_entry("test"))
418
+
419
+ # Pin
420
+ result = storage.toggle_pin(entry_id)
421
+ assert result is True
422
+ assert storage.get_entry(entry_id).pinned is True
423
+
424
+ # Unpin
425
+ result = storage.toggle_pin(entry_id)
426
+ assert result is False
427
+ assert storage.get_entry(entry_id).pinned is False
428
+
429
+ def test_pinned_entries_ordered_by_created_at(self, storage, make_entry):
430
+ import time
431
+
432
+ id1 = storage.add_entry(make_entry("older", content_hash="h1"))
433
+ time.sleep(0.01)
434
+ id2 = storage.add_entry(make_entry("newer", content_hash="h2"))
435
+ storage.toggle_pin(id1)
436
+ storage.toggle_pin(id2)
437
+
438
+ pinned = storage.get_pinned()
439
+ assert len(pinned) == 2
440
+ assert pinned[0].id == id2 # newer first
441
+ assert pinned[1].id == id1
442
+
443
+ def test_clear_pinned(self, storage, make_entry):
444
+ id1 = storage.add_entry(make_entry("entry 1"))
445
+ id2 = storage.add_entry(make_entry("entry 2", content_hash="hash2"))
446
+ id3 = storage.add_entry(make_entry("entry 3", content_hash="hash3"))
447
+ storage.toggle_pin(id1)
448
+ storage.toggle_pin(id2)
449
+
450
+ assert storage.count_pinned() == 2
451
+
452
+ storage.clear_pinned()
453
+
454
+ assert storage.count_pinned() == 0
455
+ assert storage.get_entry(id1).pinned is False
456
+ assert storage.get_entry(id2).pinned is False
457
+ assert storage.get_entry(id3).pinned is False # was never pinned
458
+
459
+
392
460
  class TestRichTextMigration:
393
461
  def test_migrate_adds_rtf_and_html_columns(self, tmp_path):
394
462
  """Test that migration adds rtf_data and html_data to old databases."""
@@ -148,3 +148,66 @@ class TestCreateThumbnail:
148
148
  # Thumbnail should be a valid PNG
149
149
  thumb_data = thumb_file.read_bytes()
150
150
  assert thumb_data.startswith(b"\x89PNG")
151
+
152
+ def test_tiff_representation_fails(self, tmp_path):
153
+ mock_original = MagicMock()
154
+ mock_resized = MagicMock()
155
+ mock_resized.TIFFRepresentation.return_value = None
156
+
157
+ mock_nsimage_cls = MagicMock()
158
+ mock_nsimage_cls.alloc.return_value.initWithContentsOfFile_.return_value = mock_original
159
+ mock_nsimage_cls.alloc.return_value.initWithSize_.return_value = mock_resized
160
+
161
+ with patch.dict("sys.modules", {"AppKit": MagicMock(NSImage=mock_nsimage_cls)}):
162
+ result = create_thumbnail("/fake.png", str(tmp_path / "thumb.png"))
163
+
164
+ assert result is False
165
+
166
+ def test_bitmap_rep_fails(self, tmp_path):
167
+ mock_original = MagicMock()
168
+ mock_resized = MagicMock()
169
+ mock_resized.TIFFRepresentation.return_value = MagicMock()
170
+
171
+ mock_nsimage_cls = MagicMock()
172
+ mock_nsimage_cls.alloc.return_value.initWithContentsOfFile_.return_value = mock_original
173
+ mock_nsimage_cls.alloc.return_value.initWithSize_.return_value = mock_resized
174
+
175
+ mock_bitmap_cls = MagicMock()
176
+ mock_bitmap_cls.imageRepWithData_.return_value = None
177
+
178
+ with patch.dict("sys.modules", {"AppKit": MagicMock(NSImage=mock_nsimage_cls, NSBitmapImageRep=mock_bitmap_cls)}):
179
+ result = create_thumbnail("/fake.png", str(tmp_path / "thumb.png"))
180
+
181
+ assert result is False
182
+
183
+ def test_png_data_fails(self, tmp_path):
184
+ mock_original = MagicMock()
185
+ mock_resized = MagicMock()
186
+ mock_resized.TIFFRepresentation.return_value = MagicMock()
187
+
188
+ mock_bitmap_rep = MagicMock()
189
+ mock_bitmap_rep.representationUsingType_properties_.return_value = None
190
+
191
+ mock_nsimage_cls = MagicMock()
192
+ mock_nsimage_cls.alloc.return_value.initWithContentsOfFile_.return_value = mock_original
193
+ mock_nsimage_cls.alloc.return_value.initWithSize_.return_value = mock_resized
194
+
195
+ mock_bitmap_cls = MagicMock()
196
+ mock_bitmap_cls.imageRepWithData_.return_value = mock_bitmap_rep
197
+
198
+ with patch.dict("sys.modules", {"AppKit": MagicMock(NSImage=mock_nsimage_cls, NSBitmapImageRep=mock_bitmap_cls)}):
199
+ result = create_thumbnail("/fake.png", str(tmp_path / "thumb.png"))
200
+
201
+ assert result is False
202
+
203
+ def test_exception_returns_false(self, tmp_path):
204
+ mock_original = MagicMock()
205
+ mock_original.drawInRect_.side_effect = RuntimeError("boom")
206
+
207
+ mock_nsimage_cls = MagicMock()
208
+ mock_nsimage_cls.alloc.return_value.initWithContentsOfFile_.return_value = mock_original
209
+
210
+ with patch.dict("sys.modules", {"AppKit": MagicMock(NSImage=mock_nsimage_cls)}):
211
+ result = create_thumbnail("/fake.png", str(tmp_path / "thumb.png"))
212
+
213
+ assert result is False
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