clipsy 1.8.0__tar.gz → 1.9.1__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.8.0/src/clipsy.egg-info → clipsy-1.9.1}/PKG-INFO +23 -2
- {clipsy-1.8.0 → clipsy-1.9.1}/README.md +22 -1
- {clipsy-1.8.0 → clipsy-1.9.1}/pyproject.toml +1 -1
- {clipsy-1.8.0 → clipsy-1.9.1}/src/clipsy/__init__.py +1 -1
- {clipsy-1.8.0 → clipsy-1.9.1}/src/clipsy/app.py +54 -3
- {clipsy-1.8.0 → clipsy-1.9.1}/src/clipsy/config.py +1 -0
- {clipsy-1.8.0 → clipsy-1.9.1}/src/clipsy/storage.py +15 -0
- {clipsy-1.8.0 → clipsy-1.9.1/src/clipsy.egg-info}/PKG-INFO +23 -2
- {clipsy-1.8.0 → clipsy-1.9.1}/tests/test_app.py +77 -0
- {clipsy-1.8.0 → clipsy-1.9.1}/tests/test_main.py +65 -1
- {clipsy-1.8.0 → clipsy-1.9.1}/tests/test_monitor.py +67 -0
- {clipsy-1.8.0 → clipsy-1.9.1}/tests/test_storage.py +68 -0
- {clipsy-1.8.0 → clipsy-1.9.1}/tests/test_utils.py +63 -0
- {clipsy-1.8.0 → clipsy-1.9.1}/LICENSE +0 -0
- {clipsy-1.8.0 → clipsy-1.9.1}/setup.cfg +0 -0
- {clipsy-1.8.0 → clipsy-1.9.1}/src/clipsy/__main__.py +0 -0
- {clipsy-1.8.0 → clipsy-1.9.1}/src/clipsy/models.py +0 -0
- {clipsy-1.8.0 → clipsy-1.9.1}/src/clipsy/monitor.py +0 -0
- {clipsy-1.8.0 → clipsy-1.9.1}/src/clipsy/redact.py +0 -0
- {clipsy-1.8.0 → clipsy-1.9.1}/src/clipsy/utils.py +0 -0
- {clipsy-1.8.0 → clipsy-1.9.1}/src/clipsy.egg-info/SOURCES.txt +0 -0
- {clipsy-1.8.0 → clipsy-1.9.1}/src/clipsy.egg-info/dependency_links.txt +0 -0
- {clipsy-1.8.0 → clipsy-1.9.1}/src/clipsy.egg-info/entry_points.txt +0 -0
- {clipsy-1.8.0 → clipsy-1.9.1}/src/clipsy.egg-info/requires.txt +0 -0
- {clipsy-1.8.0 → clipsy-1.9.1}/src/clipsy.egg-info/top_level.txt +0 -0
- {clipsy-1.8.0 → clipsy-1.9.1}/tests/test_config.py +0 -0
- {clipsy-1.8.0 → clipsy-1.9.1}/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.1
|
|
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,17 @@ 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
|
+
│ ├── ──────────────────
|
|
112
|
+
│ └── Clear Pinned
|
|
113
|
+
├── ──────────────────
|
|
106
114
|
├── "Meeting notes for Q3 plan..."
|
|
107
115
|
├── "https://github.com/example..."
|
|
108
116
|
├── 🔒 "password=••••••••"
|
|
109
117
|
├── [thumbnail] "[Image: 1920x1080]"
|
|
110
|
-
├── ... (up to 10 items)
|
|
118
|
+
├── ... (up to 10 items, configurable)
|
|
111
119
|
├── ──────────────────
|
|
112
120
|
├── Clear History
|
|
113
121
|
├── ──────────────────
|
|
@@ -116,6 +124,8 @@ Then just use your Mac normally. Every time you copy something, it shows up in t
|
|
|
116
124
|
└── Quit Clipsy
|
|
117
125
|
```
|
|
118
126
|
|
|
127
|
+
**Tip:** Hold **Option (⌥)** while clicking an entry to pin/unpin it.
|
|
128
|
+
|
|
119
129
|
## Commands
|
|
120
130
|
|
|
121
131
|
```bash
|
|
@@ -125,6 +135,17 @@ clipsy uninstall # Remove from login items
|
|
|
125
135
|
clipsy run # Run in foreground (for debugging)
|
|
126
136
|
```
|
|
127
137
|
|
|
138
|
+
## Configuration
|
|
139
|
+
|
|
140
|
+
| Variable | Default | Range | Description |
|
|
141
|
+
|----------|---------|-------|-------------|
|
|
142
|
+
| `CLIPSY_MENU_DISPLAY_COUNT` | `10` | 5–50 | Number of entries shown in the menu |
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
# Example: show 20 entries in the menu
|
|
146
|
+
export CLIPSY_MENU_DISPLAY_COUNT=20
|
|
147
|
+
```
|
|
148
|
+
|
|
128
149
|
## Data Storage
|
|
129
150
|
|
|
130
151
|
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,17 @@ 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
|
+
│ ├── ──────────────────
|
|
81
|
+
│ └── Clear Pinned
|
|
82
|
+
├── ──────────────────
|
|
75
83
|
├── "Meeting notes for Q3 plan..."
|
|
76
84
|
├── "https://github.com/example..."
|
|
77
85
|
├── 🔒 "password=••••••••"
|
|
78
86
|
├── [thumbnail] "[Image: 1920x1080]"
|
|
79
|
-
├── ... (up to 10 items)
|
|
87
|
+
├── ... (up to 10 items, configurable)
|
|
80
88
|
├── ──────────────────
|
|
81
89
|
├── Clear History
|
|
82
90
|
├── ──────────────────
|
|
@@ -85,6 +93,8 @@ Then just use your Mac normally. Every time you copy something, it shows up in t
|
|
|
85
93
|
└── Quit Clipsy
|
|
86
94
|
```
|
|
87
95
|
|
|
96
|
+
**Tip:** Hold **Option (⌥)** while clicking an entry to pin/unpin it.
|
|
97
|
+
|
|
88
98
|
## Commands
|
|
89
99
|
|
|
90
100
|
```bash
|
|
@@ -94,6 +104,17 @@ clipsy uninstall # Remove from login items
|
|
|
94
104
|
clipsy run # Run in foreground (for debugging)
|
|
95
105
|
```
|
|
96
106
|
|
|
107
|
+
## Configuration
|
|
108
|
+
|
|
109
|
+
| Variable | Default | Range | Description |
|
|
110
|
+
|----------|---------|-------|-------------|
|
|
111
|
+
| `CLIPSY_MENU_DISPLAY_COUNT` | `10` | 5–50 | Number of entries shown in the menu |
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
# Example: show 20 entries in the menu
|
|
115
|
+
export CLIPSY_MENU_DISPLAY_COUNT=20
|
|
116
|
+
```
|
|
117
|
+
|
|
97
118
|
## Data Storage
|
|
98
119
|
|
|
99
120
|
All data is stored in `~/.local/share/clipsy/`:
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
__version__ = "1.
|
|
1
|
+
__version__ = "1.9.1"
|
|
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,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.
|
|
3
|
+
Version: 1.9.1
|
|
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,17 @@ 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
|
+
│ ├── ──────────────────
|
|
112
|
+
│ └── Clear Pinned
|
|
113
|
+
├── ──────────────────
|
|
106
114
|
├── "Meeting notes for Q3 plan..."
|
|
107
115
|
├── "https://github.com/example..."
|
|
108
116
|
├── 🔒 "password=••••••••"
|
|
109
117
|
├── [thumbnail] "[Image: 1920x1080]"
|
|
110
|
-
├── ... (up to 10 items)
|
|
118
|
+
├── ... (up to 10 items, configurable)
|
|
111
119
|
├── ──────────────────
|
|
112
120
|
├── Clear History
|
|
113
121
|
├── ──────────────────
|
|
@@ -116,6 +124,8 @@ Then just use your Mac normally. Every time you copy something, it shows up in t
|
|
|
116
124
|
└── Quit Clipsy
|
|
117
125
|
```
|
|
118
126
|
|
|
127
|
+
**Tip:** Hold **Option (⌥)** while clicking an entry to pin/unpin it.
|
|
128
|
+
|
|
119
129
|
## Commands
|
|
120
130
|
|
|
121
131
|
```bash
|
|
@@ -125,6 +135,17 @@ clipsy uninstall # Remove from login items
|
|
|
125
135
|
clipsy run # Run in foreground (for debugging)
|
|
126
136
|
```
|
|
127
137
|
|
|
138
|
+
## Configuration
|
|
139
|
+
|
|
140
|
+
| Variable | Default | Range | Description |
|
|
141
|
+
|----------|---------|-------|-------------|
|
|
142
|
+
| `CLIPSY_MENU_DISPLAY_COUNT` | `10` | 5–50 | Number of entries shown in the menu |
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
# Example: show 20 entries in the menu
|
|
146
|
+
export CLIPSY_MENU_DISPLAY_COUNT=20
|
|
147
|
+
```
|
|
148
|
+
|
|
128
149
|
## Data Storage
|
|
129
150
|
|
|
130
151
|
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() == []
|
|
@@ -5,7 +5,6 @@ from unittest.mock import MagicMock, patch
|
|
|
5
5
|
import pytest
|
|
6
6
|
|
|
7
7
|
from clipsy.__main__ import (
|
|
8
|
-
PLIST_NAME,
|
|
9
8
|
check_status,
|
|
10
9
|
create_plist,
|
|
11
10
|
get_clipsy_path,
|
|
@@ -157,3 +156,68 @@ class TestCLIParsing:
|
|
|
157
156
|
with patch("sys.argv", ["clipsy", "run"]):
|
|
158
157
|
main()
|
|
159
158
|
mock_run.assert_called_once()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class TestRunApp:
|
|
162
|
+
@patch("clipsy.app.ClipsyApp")
|
|
163
|
+
@patch("clipsy.__main__.logging.StreamHandler")
|
|
164
|
+
@patch("clipsy.__main__.logging.FileHandler")
|
|
165
|
+
@patch("clipsy.__main__.logging.basicConfig")
|
|
166
|
+
@patch("clipsy.__main__.ensure_dirs")
|
|
167
|
+
def test_run_app_initializes_and_runs(
|
|
168
|
+
self, mock_dirs, mock_logging, mock_file_handler, mock_stream_handler, mock_app_class
|
|
169
|
+
):
|
|
170
|
+
from clipsy.__main__ import run_app
|
|
171
|
+
|
|
172
|
+
mock_app = MagicMock()
|
|
173
|
+
mock_app_class.return_value = mock_app
|
|
174
|
+
|
|
175
|
+
run_app()
|
|
176
|
+
|
|
177
|
+
mock_dirs.assert_called_once()
|
|
178
|
+
mock_logging.assert_called_once()
|
|
179
|
+
mock_app_class.assert_called_once()
|
|
180
|
+
mock_app.run.assert_called_once()
|
|
181
|
+
|
|
182
|
+
@patch("clipsy.app.ClipsyApp")
|
|
183
|
+
@patch("clipsy.__main__.logging.StreamHandler")
|
|
184
|
+
@patch("clipsy.__main__.logging.FileHandler")
|
|
185
|
+
@patch("clipsy.__main__.logging.basicConfig")
|
|
186
|
+
@patch("clipsy.__main__.ensure_dirs")
|
|
187
|
+
def test_run_app_configures_logging(
|
|
188
|
+
self, mock_dirs, mock_logging, mock_file_handler, mock_stream_handler, mock_app_class
|
|
189
|
+
):
|
|
190
|
+
from clipsy.__main__ import run_app
|
|
191
|
+
|
|
192
|
+
mock_app_class.return_value = MagicMock()
|
|
193
|
+
|
|
194
|
+
run_app()
|
|
195
|
+
|
|
196
|
+
call_kwargs = mock_logging.call_args[1]
|
|
197
|
+
assert call_kwargs["level"] == 20 # logging.INFO
|
|
198
|
+
assert "%(asctime)s" in call_kwargs["format"]
|
|
199
|
+
assert len(call_kwargs["handlers"]) == 2
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class TestModuleEntryPoint:
|
|
203
|
+
def test_module_entry_point(self):
|
|
204
|
+
import importlib.util
|
|
205
|
+
import sys
|
|
206
|
+
from pathlib import Path
|
|
207
|
+
|
|
208
|
+
main_file = Path(__file__).parent.parent / "src" / "clipsy" / "__main__.py"
|
|
209
|
+
|
|
210
|
+
spec = importlib.util.spec_from_file_location("__main__", main_file)
|
|
211
|
+
module = importlib.util.module_from_spec(spec)
|
|
212
|
+
|
|
213
|
+
with patch("sys.argv", ["clipsy"]):
|
|
214
|
+
with patch.object(module, "install_launchagent", return_value=0, create=True):
|
|
215
|
+
old_modules = sys.modules.copy()
|
|
216
|
+
try:
|
|
217
|
+
sys.modules["__main__"] = module
|
|
218
|
+
spec.loader.exec_module(module)
|
|
219
|
+
except SystemExit as e:
|
|
220
|
+
assert e.code == 0
|
|
221
|
+
finally:
|
|
222
|
+
sys.modules.clear()
|
|
223
|
+
sys.modules.update(old_modules)
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|