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.
Files changed (28) hide show
  1. {clipsy-1.7.0/src/clipsy.egg-info → clipsy-1.8.0}/PKG-INFO +9 -2
  2. {clipsy-1.7.0 → clipsy-1.8.0}/README.md +8 -1
  3. {clipsy-1.7.0 → clipsy-1.8.0}/pyproject.toml +1 -1
  4. {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy/__init__.py +1 -1
  5. {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy/__main__.py +1 -0
  6. {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy/app.py +9 -1
  7. {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy/config.py +12 -1
  8. {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy/models.py +2 -0
  9. clipsy-1.8.0/src/clipsy/monitor.py +201 -0
  10. {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy/storage.py +26 -27
  11. {clipsy-1.7.0 → clipsy-1.8.0/src/clipsy.egg-info}/PKG-INFO +9 -2
  12. {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy.egg-info/SOURCES.txt +2 -0
  13. {clipsy-1.7.0 → clipsy-1.8.0}/tests/test_app.py +36 -0
  14. clipsy-1.8.0/tests/test_config.py +36 -0
  15. clipsy-1.8.0/tests/test_main.py +159 -0
  16. {clipsy-1.7.0 → clipsy-1.8.0}/tests/test_monitor.py +101 -0
  17. {clipsy-1.7.0 → clipsy-1.8.0}/tests/test_storage.py +79 -0
  18. clipsy-1.7.0/src/clipsy/monitor.py +0 -153
  19. {clipsy-1.7.0 → clipsy-1.8.0}/LICENSE +0 -0
  20. {clipsy-1.7.0 → clipsy-1.8.0}/setup.cfg +0 -0
  21. {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy/redact.py +0 -0
  22. {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy/utils.py +0 -0
  23. {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy.egg-info/dependency_links.txt +0 -0
  24. {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy.egg-info/entry_points.txt +0 -0
  25. {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy.egg-info/requires.txt +0 -0
  26. {clipsy-1.7.0 → clipsy-1.8.0}/src/clipsy.egg-info/top_level.txt +0 -0
  27. {clipsy-1.7.0 → clipsy-1.8.0}/tests/test_redact.py +0 -0
  28. {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.7.0
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
@@ -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 pipx (recommended)
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "clipsy"
7
- version = "1.7.0"
7
+ version = "1.8.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.7.0"
1
+ __version__ = "1.8.0"
2
2
  __app_name__ = "Clipsy"
@@ -37,6 +37,7 @@ def create_plist(clipsy_path: str) -> str:
37
37
  <key>ProgramArguments</key>
38
38
  <array>
39
39
  <string>{clipsy_path}</string>
40
+ <string>run</string>
40
41
  </array>
41
42
  <key>RunAtLoad</key>
42
43
  <true/>
@@ -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
- 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.)
@@ -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
@@ -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
- 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.0
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
@@ -17,6 +17,8 @@ src/clipsy.egg-info/entry_points.txt
17
17
  src/clipsy.egg-info/requires.txt
18
18
  src/clipsy.egg-info/top_level.txt
19
19
  tests/test_app.py
20
+ tests/test_config.py
21
+ tests/test_main.py
20
22
  tests/test_monitor.py
21
23
  tests/test_redact.py
22
24
  tests/test_storage.py
@@ -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