clipsy 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
clipsy/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ __version__ = "1.5.0"
2
+ __app_name__ = "Clipsy"
clipsy/__main__.py ADDED
@@ -0,0 +1,26 @@
1
+ import logging
2
+ import sys
3
+
4
+ from clipsy.config import LOG_PATH
5
+ from clipsy.utils import ensure_dirs
6
+
7
+
8
+ def main():
9
+ ensure_dirs()
10
+
11
+ logging.basicConfig(
12
+ level=logging.INFO,
13
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
14
+ handlers=[
15
+ logging.FileHandler(LOG_PATH),
16
+ logging.StreamHandler(sys.stderr),
17
+ ],
18
+ )
19
+
20
+ from clipsy.app import ClipsyApp
21
+ app = ClipsyApp()
22
+ app.run()
23
+
24
+
25
+ if __name__ == "__main__":
26
+ main()
clipsy/app.py ADDED
@@ -0,0 +1,199 @@
1
+ import logging
2
+ import webbrowser
3
+ from pathlib import Path
4
+
5
+ import rumps
6
+
7
+ from clipsy import __version__
8
+ from clipsy.config import DB_PATH, IMAGE_DIR, MENU_DISPLAY_COUNT, POLL_INTERVAL, REDACT_SENSITIVE, THUMBNAIL_SIZE
9
+ from clipsy.models import ClipboardEntry, ContentType
10
+ from clipsy.monitor import ClipboardMonitor
11
+ from clipsy.storage import StorageManager
12
+ from clipsy.utils import create_thumbnail, ensure_dirs
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ ENTRY_KEY_PREFIX = "clipsy_entry_"
17
+
18
+
19
+ class ClipsyApp(rumps.App):
20
+ def __init__(self):
21
+ super().__init__("Clipsy", title="✂️", quit_button=None)
22
+ ensure_dirs()
23
+ self._storage = StorageManager(DB_PATH)
24
+ self._monitor = ClipboardMonitor(self._storage, on_change=self._refresh_menu)
25
+ self._entry_ids: dict[str, int] = {}
26
+ self._build_menu()
27
+
28
+ def _build_menu(self) -> None:
29
+ self.menu.clear()
30
+ self.menu = [
31
+ rumps.MenuItem(f"Clipsy v{__version__} - Clipboard History", callback=None),
32
+ None, # separator
33
+ rumps.MenuItem("Search...", callback=self._on_search),
34
+ None, # separator
35
+ ]
36
+
37
+ entries = self._storage.get_recent(limit=MENU_DISPLAY_COUNT)
38
+ self._entry_ids.clear()
39
+
40
+ if not entries:
41
+ self.menu.add(rumps.MenuItem("(No clipboard history)", callback=None))
42
+ else:
43
+ for entry in entries:
44
+ self.menu.add(self._create_entry_menu_item(entry))
45
+
46
+ self.menu.add(None) # separator
47
+ self.menu.add(rumps.MenuItem("Clear History", callback=self._on_clear))
48
+ self.menu.add(None) # separator
49
+ self.menu.add(rumps.MenuItem("Support Clipsy", callback=self._on_support))
50
+ self.menu.add(None) # separator
51
+ self.menu.add(rumps.MenuItem("Quit Clipsy", callback=self._on_quit))
52
+
53
+ def _create_entry_menu_item(self, entry: ClipboardEntry) -> rumps.MenuItem:
54
+ """Create a menu item for a clipboard entry."""
55
+ key = f"{ENTRY_KEY_PREFIX}{entry.id}"
56
+ self._entry_ids[key] = entry.id
57
+ display_text = self._get_display_preview(entry)
58
+
59
+ if entry.content_type == ContentType.IMAGE:
60
+ thumb_path = self._ensure_thumbnail(entry)
61
+ if thumb_path:
62
+ item = rumps.MenuItem(
63
+ display_text,
64
+ callback=self._on_entry_click,
65
+ icon=thumb_path,
66
+ dimensions=(32, 32),
67
+ template=False,
68
+ )
69
+ else:
70
+ item = rumps.MenuItem(display_text, callback=self._on_entry_click)
71
+ else:
72
+ item = rumps.MenuItem(display_text, callback=self._on_entry_click)
73
+
74
+ item._id = key
75
+ return item
76
+
77
+ def _get_display_preview(self, entry: ClipboardEntry) -> str:
78
+ """Get the display preview for an entry, masking sensitive data if enabled."""
79
+ if REDACT_SENSITIVE and entry.is_sensitive and entry.masked_preview:
80
+ return f"🔒 {entry.masked_preview}"
81
+ return entry.preview
82
+
83
+ def _ensure_thumbnail(self, entry: ClipboardEntry) -> str | None:
84
+ """Ensure a thumbnail exists for an image entry, generating if needed."""
85
+ if entry.thumbnail_path:
86
+ return entry.thumbnail_path
87
+
88
+ if not entry.image_path:
89
+ return None
90
+
91
+ # Generate thumbnail for legacy entries
92
+ image_path = Path(entry.image_path)
93
+ if not image_path.exists():
94
+ return None
95
+
96
+ thumb_filename = image_path.stem + "_thumb.png"
97
+ thumb_path = IMAGE_DIR / thumb_filename
98
+
99
+ if thumb_path.exists() or create_thumbnail(str(image_path), str(thumb_path), THUMBNAIL_SIZE):
100
+ self._storage.update_thumbnail_path(entry.id, str(thumb_path))
101
+ return str(thumb_path)
102
+
103
+ return None
104
+
105
+ def _refresh_menu(self) -> None:
106
+ self._build_menu()
107
+
108
+ @rumps.timer(POLL_INTERVAL)
109
+ def _poll_clipboard(self, _sender) -> None:
110
+ self._monitor.check_clipboard()
111
+
112
+ def _on_entry_click(self, sender) -> None:
113
+ entry_id = self._entry_ids.get(getattr(sender, "_id", ""))
114
+ if entry_id is None:
115
+ return
116
+
117
+ entry = self._storage.get_entry(entry_id)
118
+ if entry is None:
119
+ return
120
+
121
+ try:
122
+ from AppKit import NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeString
123
+
124
+ pb = NSPasteboard.generalPasteboard()
125
+
126
+ copied = False
127
+
128
+ if entry.content_type == ContentType.TEXT and entry.text_content:
129
+ pb.clearContents()
130
+ pb.setString_forType_(entry.text_content, NSPasteboardTypeString)
131
+ self._monitor.sync_change_count()
132
+ copied = True
133
+
134
+ elif entry.content_type == ContentType.IMAGE and entry.image_path:
135
+ from Foundation import NSData
136
+ img_data = NSData.dataWithContentsOfFile_(entry.image_path)
137
+ if img_data:
138
+ pb.clearContents()
139
+ pb.setData_forType_(img_data, NSPasteboardTypePNG)
140
+ self._monitor.sync_change_count()
141
+ copied = True
142
+
143
+ elif entry.content_type == ContentType.FILE and entry.text_content:
144
+ pb.clearContents()
145
+ pb.setString_forType_(entry.text_content, NSPasteboardTypeString)
146
+ self._monitor.sync_change_count()
147
+ copied = True
148
+
149
+ if copied:
150
+ self._storage.update_timestamp(entry_id)
151
+ self._refresh_menu()
152
+ rumps.notification("Clipsy", "", "Copied to clipboard", sound=False)
153
+ except Exception:
154
+ logger.exception("Error copying entry to clipboard")
155
+
156
+ def _on_search(self, _sender) -> None:
157
+ response = rumps.Window(
158
+ message="Search clipboard history:",
159
+ title="Clipsy Search",
160
+ default_text="",
161
+ ok="Search",
162
+ cancel="Cancel",
163
+ dimensions=(300, 24),
164
+ ).run()
165
+
166
+ if response.clicked and response.text.strip():
167
+ query = response.text.strip()
168
+ results = self._storage.search(query, limit=MENU_DISPLAY_COUNT)
169
+
170
+ if not results:
171
+ rumps.alert("Clipsy Search", f'No results for "{query}"')
172
+ return
173
+
174
+ self.menu.clear()
175
+ self._entry_ids.clear()
176
+ self.menu = [
177
+ rumps.MenuItem(f'Search: "{query}" ({len(results)} results)', callback=None),
178
+ None,
179
+ rumps.MenuItem("Show All", callback=lambda _: self._refresh_menu()),
180
+ None,
181
+ ]
182
+
183
+ for entry in results:
184
+ self.menu.add(self._create_entry_menu_item(entry))
185
+
186
+ self.menu.add(None)
187
+ self.menu.add(rumps.MenuItem("Quit Clipsy", callback=self._on_quit))
188
+
189
+ def _on_clear(self, _sender) -> None:
190
+ if rumps.alert("Clipsy", "Clear all clipboard history?", ok="Clear", cancel="Cancel"):
191
+ self._storage.clear_all()
192
+ self._refresh_menu()
193
+
194
+ def _on_support(self, _sender) -> None:
195
+ webbrowser.open("https://github.com/sponsors/brencon")
196
+
197
+ def _on_quit(self, _sender) -> None:
198
+ self._storage.close()
199
+ rumps.quit_application()
clipsy/config.py ADDED
@@ -0,0 +1,16 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ DATA_DIR = Path(os.environ.get("CLIPSY_DATA_DIR", Path.home() / ".local" / "share" / "clipsy"))
5
+ DB_PATH = DATA_DIR / "clipsy.db"
6
+ IMAGE_DIR = DATA_DIR / "images"
7
+ LOG_PATH = DATA_DIR / "clipsy.log"
8
+
9
+ POLL_INTERVAL = 0.5 # seconds between clipboard checks
10
+ MAX_ENTRIES = 500 # auto-purge threshold
11
+ MAX_TEXT_SIZE = 1_000_000 # 1MB text limit
12
+ MAX_IMAGE_SIZE = 10_000_000 # 10MB image limit
13
+ PREVIEW_LENGTH = 60 # characters shown in menu item
14
+ MENU_DISPLAY_COUNT = 10 # items shown in dropdown
15
+ THUMBNAIL_SIZE = (32, 32) # pixels, for menu icon display
16
+ REDACT_SENSITIVE = True # mask sensitive data in preview (API keys, passwords, etc.)
clipsy/models.py ADDED
@@ -0,0 +1,26 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+ from enum import Enum
4
+
5
+
6
+ class ContentType(str, Enum):
7
+ TEXT = "text"
8
+ IMAGE = "image"
9
+ FILE = "file"
10
+
11
+
12
+ @dataclass
13
+ class ClipboardEntry:
14
+ id: int | None
15
+ content_type: ContentType
16
+ text_content: str | None
17
+ image_path: str | None
18
+ preview: str
19
+ content_hash: str
20
+ byte_size: int
21
+ created_at: datetime
22
+ pinned: bool = False
23
+ source_app: str | None = None
24
+ thumbnail_path: str | None = None
25
+ is_sensitive: bool = False
26
+ masked_preview: str | None = None
clipsy/monitor.py ADDED
@@ -0,0 +1,153 @@
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
clipsy/redact.py ADDED
@@ -0,0 +1,211 @@
1
+ """Sensitive data detection and masking for clipboard entries."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+
7
+
8
+ class SensitiveType(Enum):
9
+ """Types of sensitive data that can be detected."""
10
+
11
+ API_KEY = "api_key"
12
+ PASSWORD = "password"
13
+ SSN = "ssn"
14
+ CREDIT_CARD = "credit_card"
15
+ PRIVATE_KEY = "private_key"
16
+ CERTIFICATE = "certificate"
17
+ TOKEN = "token"
18
+
19
+
20
+ @dataclass
21
+ class SensitiveMatch:
22
+ """A detected sensitive data match."""
23
+
24
+ sensitive_type: SensitiveType
25
+ start: int
26
+ end: int
27
+ original: str
28
+ masked: str
29
+
30
+
31
+ # Pattern definitions for sensitive data detection
32
+ PATTERNS: dict[SensitiveType, list[re.Pattern]] = {
33
+ SensitiveType.API_KEY: [
34
+ re.compile(r"sk-[a-zA-Z0-9]{20,}", re.ASCII), # OpenAI
35
+ re.compile(r"sk-proj-[a-zA-Z0-9_-]{20,}", re.ASCII), # OpenAI project keys
36
+ re.compile(r"AKIA[A-Z0-9]{16}", re.ASCII), # AWS Access Key
37
+ re.compile(r"ghp_[a-zA-Z0-9]{36}", re.ASCII), # GitHub PAT
38
+ re.compile(r"gho_[a-zA-Z0-9]{36}", re.ASCII), # GitHub OAuth
39
+ re.compile(r"github_pat_[a-zA-Z0-9_]{22,}", re.ASCII), # GitHub fine-grained PAT
40
+ re.compile(r"xox[baprs]-[a-zA-Z0-9-]{10,}", re.ASCII), # Slack tokens
41
+ re.compile(r"AIza[a-zA-Z0-9_-]{35}", re.ASCII), # Google API Key
42
+ re.compile(r"sq0[a-z]{3}-[a-zA-Z0-9_-]{22,}", re.ASCII), # Square
43
+ re.compile(r"sk_live_[a-zA-Z0-9]{24,}", re.ASCII), # Stripe live
44
+ re.compile(r"sk_test_[a-zA-Z0-9]{24,}", re.ASCII), # Stripe test
45
+ re.compile(r"rk_live_[a-zA-Z0-9]{24,}", re.ASCII), # Stripe restricted
46
+ re.compile(r"pk_live_[a-zA-Z0-9]{24,}", re.ASCII), # Stripe publishable
47
+ re.compile(r"pk_test_[a-zA-Z0-9]{24,}", re.ASCII), # Stripe test publishable
48
+ ],
49
+ SensitiveType.PASSWORD: [
50
+ re.compile(r"(?:password|passwd|pwd|pass|secret|token|api_key|apikey|auth)[=:\s]+['\"]?(\S{6,})['\"]?", re.IGNORECASE),
51
+ ],
52
+ SensitiveType.SSN: [
53
+ re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), # 123-45-6789
54
+ re.compile(r"\b\d{9}\b"), # 123456789 (only if looks like SSN context)
55
+ ],
56
+ SensitiveType.CREDIT_CARD: [
57
+ re.compile(r"\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b"), # 16 digits
58
+ re.compile(r"\b\d{4}[- ]?\d{6}[- ]?\d{5}\b"), # Amex format
59
+ ],
60
+ SensitiveType.PRIVATE_KEY: [
61
+ re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----", re.ASCII),
62
+ re.compile(r"-----BEGIN RSA PRIVATE KEY-----", re.ASCII),
63
+ re.compile(r"-----BEGIN EC PRIVATE KEY-----", re.ASCII),
64
+ re.compile(r"-----BEGIN OPENSSH PRIVATE KEY-----", re.ASCII),
65
+ ],
66
+ SensitiveType.CERTIFICATE: [
67
+ re.compile(r"-----BEGIN CERTIFICATE-----", re.ASCII),
68
+ re.compile(r"-----BEGIN X509 CERTIFICATE-----", re.ASCII),
69
+ ],
70
+ SensitiveType.TOKEN: [
71
+ re.compile(r"eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}", re.ASCII), # JWT
72
+ re.compile(r"Bearer\s+[a-zA-Z0-9_-]{20,}", re.ASCII), # Bearer token
73
+ ],
74
+ }
75
+
76
+
77
+ def _mask_value(value: str, sensitive_type: SensitiveType) -> str:
78
+ """Mask a sensitive value based on its type."""
79
+ if sensitive_type == SensitiveType.SSN:
80
+ # Show last 4 digits: •••-••-6789
81
+ if "-" in value:
82
+ return "•••-••-" + value[-4:]
83
+ return "•••••" + value[-4:]
84
+
85
+ if sensitive_type == SensitiveType.CREDIT_CARD:
86
+ # Show last 4 digits: ••••-••••-••••-1234
87
+ digits = re.sub(r"[- ]", "", value)
88
+ return "••••-••••-••••-" + digits[-4:]
89
+
90
+ if sensitive_type in (SensitiveType.PRIVATE_KEY, SensitiveType.CERTIFICATE):
91
+ # Just indicate the type
92
+ if "PRIVATE KEY" in value:
93
+ return "[Private Key]"
94
+ return "[Certificate]"
95
+
96
+ if sensitive_type == SensitiveType.PASSWORD:
97
+ return "••••••••"
98
+
99
+ if sensitive_type == SensitiveType.TOKEN:
100
+ if value.startswith("Bearer "):
101
+ return "Bearer ••••••••"
102
+ # JWT - show first part only
103
+ if value.startswith("eyJ"):
104
+ return value[:10] + "••••••••"
105
+ return "••••••••"
106
+
107
+ # API keys - show prefix and last few chars
108
+ if len(value) > 12:
109
+ prefix_len = min(8, len(value) // 4)
110
+ suffix_len = 4
111
+ return value[:prefix_len] + "••••••••" + value[-suffix_len:]
112
+
113
+ return "••••••••"
114
+
115
+
116
+ def detect_sensitive(text: str) -> list[SensitiveMatch]:
117
+ """Detect sensitive data in text.
118
+
119
+ Args:
120
+ text: The text to scan for sensitive data.
121
+
122
+ Returns:
123
+ List of SensitiveMatch objects for each detection.
124
+ """
125
+ matches: list[SensitiveMatch] = []
126
+
127
+ for sensitive_type, patterns in PATTERNS.items():
128
+ for pattern in patterns:
129
+ for match in pattern.finditer(text):
130
+ # For password patterns, we capture the password itself in group 1
131
+ if sensitive_type == SensitiveType.PASSWORD and match.lastindex:
132
+ original = match.group(1)
133
+ start = match.start(1)
134
+ end = match.end(1)
135
+ else:
136
+ original = match.group(0)
137
+ start = match.start()
138
+ end = match.end()
139
+
140
+ masked = _mask_value(original, sensitive_type)
141
+ matches.append(SensitiveMatch(
142
+ sensitive_type=sensitive_type,
143
+ start=start,
144
+ end=end,
145
+ original=original,
146
+ masked=masked,
147
+ ))
148
+
149
+ # Sort by start position and remove overlapping matches
150
+ matches.sort(key=lambda m: m.start)
151
+ non_overlapping: list[SensitiveMatch] = []
152
+ last_end = -1
153
+ for match in matches:
154
+ if match.start >= last_end:
155
+ non_overlapping.append(match)
156
+ last_end = match.end
157
+
158
+ return non_overlapping
159
+
160
+
161
+ def mask_text(text: str, matches: list[SensitiveMatch] | None = None) -> str:
162
+ """Mask sensitive data in text.
163
+
164
+ Args:
165
+ text: The original text.
166
+ matches: Pre-detected matches, or None to detect automatically.
167
+
168
+ Returns:
169
+ Text with sensitive data masked.
170
+ """
171
+ if matches is None:
172
+ matches = detect_sensitive(text)
173
+
174
+ if not matches:
175
+ return text
176
+
177
+ # Build masked text by replacing matched regions
178
+ result = []
179
+ last_pos = 0
180
+ for match in matches:
181
+ result.append(text[last_pos:match.start])
182
+ result.append(match.masked)
183
+ last_pos = match.end
184
+ result.append(text[last_pos:])
185
+
186
+ return "".join(result)
187
+
188
+
189
+ def is_sensitive(text: str) -> bool:
190
+ """Check if text contains any sensitive data.
191
+
192
+ Args:
193
+ text: The text to check.
194
+
195
+ Returns:
196
+ True if sensitive data is detected.
197
+ """
198
+ return len(detect_sensitive(text)) > 0
199
+
200
+
201
+ def get_sensitivity_summary(matches: list[SensitiveMatch]) -> str:
202
+ """Get a summary of detected sensitive types.
203
+
204
+ Args:
205
+ matches: List of sensitive matches.
206
+
207
+ Returns:
208
+ Human-readable summary like "API Key, Password".
209
+ """
210
+ types = sorted(set(m.sensitive_type.value.replace("_", " ").title() for m in matches))
211
+ return ", ".join(types)
clipsy/storage.py ADDED
@@ -0,0 +1,258 @@
1
+ import sqlite3
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+
5
+ from clipsy.config import DB_PATH, MAX_ENTRIES
6
+ from clipsy.models import ClipboardEntry, ContentType
7
+
8
+
9
+ SCHEMA = """
10
+ CREATE TABLE IF NOT EXISTS clipboard_entries (
11
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
12
+ content_type TEXT NOT NULL CHECK(content_type IN ('text', 'image', 'file')),
13
+ text_content TEXT,
14
+ image_path TEXT,
15
+ preview TEXT NOT NULL,
16
+ content_hash TEXT NOT NULL,
17
+ byte_size INTEGER NOT NULL DEFAULT 0,
18
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
19
+ pinned INTEGER NOT NULL DEFAULT 0,
20
+ source_app TEXT,
21
+ thumbnail_path TEXT,
22
+ is_sensitive INTEGER NOT NULL DEFAULT 0,
23
+ masked_preview TEXT
24
+ );
25
+
26
+ CREATE INDEX IF NOT EXISTS idx_created_at ON clipboard_entries(created_at DESC);
27
+ CREATE INDEX IF NOT EXISTS idx_content_hash ON clipboard_entries(content_hash);
28
+ CREATE INDEX IF NOT EXISTS idx_content_type ON clipboard_entries(content_type);
29
+
30
+ CREATE VIRTUAL TABLE IF NOT EXISTS clipboard_fts USING fts5(
31
+ preview,
32
+ text_content,
33
+ content='clipboard_entries',
34
+ content_rowid='id'
35
+ );
36
+
37
+ CREATE TRIGGER IF NOT EXISTS clipboard_ai AFTER INSERT ON clipboard_entries BEGIN
38
+ INSERT INTO clipboard_fts(rowid, preview, text_content)
39
+ VALUES (new.id, new.preview, new.text_content);
40
+ END;
41
+
42
+ CREATE TRIGGER IF NOT EXISTS clipboard_ad AFTER DELETE ON clipboard_entries BEGIN
43
+ INSERT INTO clipboard_fts(clipboard_fts, rowid, preview, text_content)
44
+ VALUES ('delete', old.id, old.preview, old.text_content);
45
+ END;
46
+ """
47
+
48
+
49
+ class StorageManager:
50
+ def __init__(self, db_path: str | Path | None = None):
51
+ self._db_path = str(db_path) if db_path else str(DB_PATH)
52
+ self._conn = sqlite3.connect(self._db_path)
53
+ self._conn.row_factory = sqlite3.Row
54
+ self._conn.execute("PRAGMA journal_mode=WAL")
55
+ self._conn.execute("PRAGMA foreign_keys=ON")
56
+ self.init_db()
57
+
58
+ def init_db(self) -> None:
59
+ self._conn.executescript(SCHEMA)
60
+ self._migrate_schema()
61
+ self._conn.commit()
62
+
63
+ def _migrate_schema(self) -> None:
64
+ """Add new columns to existing databases."""
65
+ cursor = self._conn.execute("PRAGMA table_info(clipboard_entries)")
66
+ columns = {row[1] for row in cursor.fetchall()}
67
+ if "thumbnail_path" not in columns:
68
+ self._conn.execute("ALTER TABLE clipboard_entries ADD COLUMN thumbnail_path TEXT")
69
+ if "is_sensitive" not in columns:
70
+ self._conn.execute("ALTER TABLE clipboard_entries ADD COLUMN is_sensitive INTEGER NOT NULL DEFAULT 0")
71
+ if "masked_preview" not in columns:
72
+ self._conn.execute("ALTER TABLE clipboard_entries ADD COLUMN masked_preview TEXT")
73
+
74
+ def add_entry(self, entry: ClipboardEntry) -> int:
75
+ cursor = self._conn.execute(
76
+ """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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
79
+ (
80
+ entry.content_type.value,
81
+ entry.text_content,
82
+ entry.image_path,
83
+ entry.preview,
84
+ entry.content_hash,
85
+ entry.byte_size,
86
+ entry.created_at.isoformat(),
87
+ int(entry.pinned),
88
+ entry.source_app,
89
+ entry.thumbnail_path,
90
+ int(entry.is_sensitive),
91
+ entry.masked_preview,
92
+ ),
93
+ )
94
+ self._conn.commit()
95
+ return cursor.lastrowid
96
+
97
+ def get_recent(self, limit: int = 25) -> list[ClipboardEntry]:
98
+ rows = self._conn.execute(
99
+ "SELECT * FROM clipboard_entries ORDER BY created_at DESC LIMIT ?",
100
+ (limit,),
101
+ ).fetchall()
102
+ return [self._row_to_entry(r) for r in rows]
103
+
104
+ def search(self, query: str, limit: int = 25) -> list[ClipboardEntry]:
105
+ sanitized = self._sanitize_fts_query(query)
106
+ if not sanitized:
107
+ return []
108
+ rows = self._conn.execute(
109
+ """SELECT e.* FROM clipboard_entries e
110
+ JOIN clipboard_fts f ON e.id = f.rowid
111
+ WHERE clipboard_fts MATCH ?
112
+ ORDER BY e.created_at DESC
113
+ LIMIT ?""",
114
+ (sanitized, limit),
115
+ ).fetchall()
116
+ return [self._row_to_entry(r) for r in rows]
117
+
118
+ def get_entry(self, entry_id: int) -> ClipboardEntry | None:
119
+ row = self._conn.execute(
120
+ "SELECT * FROM clipboard_entries WHERE id = ?", (entry_id,)
121
+ ).fetchone()
122
+ return self._row_to_entry(row) if row else None
123
+
124
+ def delete_entry(self, entry_id: int) -> None:
125
+ entry = self.get_entry(entry_id)
126
+ 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()
135
+ self._conn.execute("DELETE FROM clipboard_entries WHERE id = ?", (entry_id,))
136
+ self._conn.commit()
137
+
138
+ def find_by_hash(self, content_hash: str) -> ClipboardEntry | None:
139
+ row = self._conn.execute(
140
+ "SELECT * FROM clipboard_entries WHERE content_hash = ? ORDER BY created_at DESC LIMIT 1",
141
+ (content_hash,),
142
+ ).fetchone()
143
+ return self._row_to_entry(row) if row else None
144
+
145
+ def update_timestamp(self, entry_id: int) -> None:
146
+ now = datetime.now().isoformat()
147
+ self._conn.execute(
148
+ "UPDATE clipboard_entries SET created_at = ? WHERE id = ?",
149
+ (now, entry_id),
150
+ )
151
+ self._conn.commit()
152
+
153
+ def update_thumbnail_path(self, entry_id: int, thumbnail_path: str) -> None:
154
+ self._conn.execute(
155
+ "UPDATE clipboard_entries SET thumbnail_path = ? WHERE id = ?",
156
+ (thumbnail_path, entry_id),
157
+ )
158
+ self._conn.commit()
159
+
160
+ def purge_old(self, keep_count: int | None = None) -> int:
161
+ keep = keep_count if keep_count is not None else MAX_ENTRIES
162
+ rows = self._conn.execute(
163
+ """SELECT id, image_path, thumbnail_path FROM clipboard_entries
164
+ WHERE pinned = 0
165
+ ORDER BY created_at DESC
166
+ LIMIT -1 OFFSET ?""",
167
+ (keep,),
168
+ ).fetchall()
169
+
170
+ deleted = 0
171
+ 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()
180
+ self._conn.execute("DELETE FROM clipboard_entries WHERE id = ?", (row["id"],))
181
+ deleted += 1
182
+
183
+ if deleted:
184
+ self._conn.commit()
185
+ return deleted
186
+
187
+ def toggle_pin(self, entry_id: int) -> bool:
188
+ entry = self.get_entry(entry_id)
189
+ if not entry:
190
+ return False
191
+ new_pinned = not entry.pinned
192
+ self._conn.execute(
193
+ "UPDATE clipboard_entries SET pinned = ? WHERE id = ?",
194
+ (int(new_pinned), entry_id),
195
+ )
196
+ self._conn.commit()
197
+ return new_pinned
198
+
199
+ def clear_all(self) -> None:
200
+ rows = self._conn.execute(
201
+ "SELECT image_path, thumbnail_path FROM clipboard_entries WHERE image_path IS NOT NULL OR thumbnail_path IS NOT NULL"
202
+ ).fetchall()
203
+ 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()
212
+ self._conn.execute("DELETE FROM clipboard_entries")
213
+ self._conn.commit()
214
+
215
+ def count(self) -> int:
216
+ row = self._conn.execute("SELECT COUNT(*) as cnt FROM clipboard_entries").fetchone()
217
+ return row["cnt"]
218
+
219
+ def close(self) -> None:
220
+ self._conn.close()
221
+
222
+ def __enter__(self):
223
+ return self
224
+
225
+ def __exit__(self, exc_type, exc_val, exc_tb):
226
+ self.close()
227
+ return False
228
+
229
+ @staticmethod
230
+ def _sanitize_fts_query(query: str) -> str:
231
+ # Quote each token to prevent FTS5 syntax errors from special chars
232
+ tokens = query.split()
233
+ if not tokens:
234
+ return ""
235
+ quoted = ['"' + token.replace('"', '""') + '"' for token in tokens]
236
+ return " ".join(quoted)
237
+
238
+ def _row_to_entry(self, row: sqlite3.Row) -> ClipboardEntry:
239
+ # Handle columns which may not exist in older databases
240
+ keys = row.keys()
241
+ thumbnail_path = row["thumbnail_path"] if "thumbnail_path" in keys else None
242
+ is_sensitive = bool(row["is_sensitive"]) if "is_sensitive" in keys else False
243
+ masked_preview = row["masked_preview"] if "masked_preview" in keys else None
244
+ return ClipboardEntry(
245
+ id=row["id"],
246
+ content_type=ContentType(row["content_type"]),
247
+ text_content=row["text_content"],
248
+ image_path=row["image_path"],
249
+ preview=row["preview"],
250
+ content_hash=row["content_hash"],
251
+ byte_size=row["byte_size"],
252
+ created_at=datetime.fromisoformat(row["created_at"]),
253
+ pinned=bool(row["pinned"]),
254
+ source_app=row["source_app"],
255
+ thumbnail_path=thumbnail_path,
256
+ is_sensitive=is_sensitive,
257
+ masked_preview=masked_preview,
258
+ )
clipsy/utils.py ADDED
@@ -0,0 +1,72 @@
1
+ import hashlib
2
+ import struct
3
+ from pathlib import Path
4
+
5
+ from clipsy.config import DATA_DIR, IMAGE_DIR
6
+
7
+
8
+ def compute_hash(data: str | bytes) -> str:
9
+ if isinstance(data, str):
10
+ data = data.encode("utf-8")
11
+ return hashlib.sha256(data).hexdigest()
12
+
13
+
14
+ def truncate_text(text: str, max_len: int) -> str:
15
+ single_line = " ".join(text.split())
16
+ if len(single_line) <= max_len:
17
+ return single_line
18
+ return single_line[: max_len - 3] + "..."
19
+
20
+
21
+ def ensure_dirs() -> None:
22
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
23
+ IMAGE_DIR.mkdir(parents=True, exist_ok=True)
24
+
25
+
26
+ def get_image_dimensions(png_bytes: bytes) -> tuple[int, int]:
27
+ if len(png_bytes) < 24 or png_bytes[:8] != b"\x89PNG\r\n\x1a\n":
28
+ return (0, 0)
29
+ width = struct.unpack(">I", png_bytes[16:20])[0]
30
+ height = struct.unpack(">I", png_bytes[20:24])[0]
31
+ return (width, height)
32
+
33
+
34
+ def create_thumbnail(image_path: str, thumb_path: str, size: tuple[int, int] = (32, 32)) -> bool:
35
+ """Create a thumbnail from an image file using native NSImage.
36
+
37
+ Args:
38
+ image_path: Path to the source image
39
+ thumb_path: Path to save the thumbnail
40
+ size: Target size in pixels (width, height)
41
+
42
+ Returns:
43
+ True if thumbnail was created successfully, False otherwise
44
+ """
45
+ try:
46
+ from AppKit import NSBitmapImageRep, NSGraphicsContext, NSImage
47
+
48
+ original = NSImage.alloc().initWithContentsOfFile_(image_path)
49
+ if not original:
50
+ return False
51
+
52
+ resized = NSImage.alloc().initWithSize_(size)
53
+ resized.lockFocus()
54
+ NSGraphicsContext.currentContext().setImageInterpolation_(3) # High quality
55
+ original.drawInRect_(((0, 0), size))
56
+ resized.unlockFocus()
57
+
58
+ tiff_data = resized.TIFFRepresentation()
59
+ if not tiff_data:
60
+ return False
61
+
62
+ bitmap_rep = NSBitmapImageRep.imageRepWithData_(tiff_data)
63
+ if not bitmap_rep:
64
+ return False
65
+
66
+ png_data = bitmap_rep.representationUsingType_properties_(4, None) # 4 = PNG
67
+ if not png_data:
68
+ return False
69
+
70
+ return bool(png_data.writeToFile_atomically_(thumb_path, True))
71
+ except Exception:
72
+ return False
@@ -0,0 +1,166 @@
1
+ Metadata-Version: 2.4
2
+ Name: clipsy
3
+ Version: 1.5.0
4
+ Summary: Lightweight clipboard history manager for macOS
5
+ Author-email: Brendan Conrad <brendan.conrad@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/brencon/clipsy
8
+ Project-URL: Repository, https://github.com/brencon/clipsy
9
+ Project-URL: Issues, https://github.com/brencon/clipsy/issues
10
+ Keywords: clipboard,macos,menu-bar,history,productivity
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: MacOS X
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Desktop Environment
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: rumps>=0.4.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7.0; extra == "dev"
29
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # Clipsy
33
+
34
+ [![PyPI](https://img.shields.io/pypi/v/clipsy)](https://pypi.org/project/clipsy/)
35
+ [![CI](https://github.com/brencon/clipsy/actions/workflows/ci.yml/badge.svg)](https://github.com/brencon/clipsy/actions/workflows/ci.yml)
36
+ [![codecov](https://codecov.io/gh/brencon/clipsy/branch/main/graph/badge.svg)](https://codecov.io/gh/brencon/clipsy)
37
+ ![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)
38
+ ![Platform: macOS](https://img.shields.io/badge/platform-macOS-lightgrey.svg)
39
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
40
+
41
+ A lightweight clipboard history manager for macOS. Runs as a menu bar icon — no admin privileges, no code signing, no App Store required.
42
+
43
+ ## Features
44
+
45
+ - **Clipboard history** — Automatically captures text, images, and file copies
46
+ - **Image thumbnails** — Visual previews for copied images in the menu
47
+ - **Sensitive data masking** — Auto-detects API keys, passwords, SSNs, credit cards, private keys, and tokens; displays masked previews with 🔒 icon
48
+ - **Search** — Full-text search across all clipboard entries (SQLite FTS5)
49
+ - **Click to re-copy** — Click any entry in the menu to put it back on your clipboard
50
+ - **Deduplication** — Copying the same content twice bumps it to the top instead of creating a duplicate
51
+ - **Auto-purge** — Keeps the most recent 500 entries, automatically cleans up old ones
52
+ - **Persistent storage** — History survives app restarts (SQLite database)
53
+ - **Corporate IT friendly** — Runs as a plain Python process, no `.app` bundle or Gatekeeper issues
54
+
55
+ ## Requirements
56
+
57
+ - macOS
58
+ - Python 3.10+ (Homebrew recommended: `brew install python3`)
59
+
60
+ ## Installation
61
+
62
+ ### Via pip (recommended)
63
+
64
+ ```bash
65
+ pip install clipsy
66
+ ```
67
+
68
+ ### From source
69
+
70
+ ```bash
71
+ git clone https://github.com/brencon/clipsy.git
72
+ cd clipsy
73
+ python3 -m venv .venv
74
+ .venv/bin/pip install -e .
75
+ ```
76
+
77
+ ## Usage
78
+
79
+ ```bash
80
+ # Run clipsy (a scissors icon appears in your menu bar)
81
+ clipsy
82
+ ```
83
+
84
+ Then just use your Mac normally. Every time you copy something, it shows up in the Clipsy menu:
85
+
86
+ ```
87
+ [✂️ Icon]
88
+ ├── Clipsy - Clipboard History
89
+ ├── ──────────────────
90
+ ├── Search...
91
+ ├── ──────────────────
92
+ ├── "Meeting notes for Q3 plan..."
93
+ ├── "https://github.com/example..."
94
+ ├── 🔒 "password=••••••••"
95
+ ├── [thumbnail] "[Image: 1920x1080]"
96
+ ├── ... (up to 10 items)
97
+ ├── ──────────────────
98
+ ├── Clear History
99
+ ├── ──────────────────
100
+ ├── Support Clipsy
101
+ ├── ──────────────────
102
+ └── Quit Clipsy
103
+ ```
104
+
105
+ ## Auto-Start on Login
106
+
107
+ Run clipsy automatically when you log in — no terminal needed:
108
+
109
+ ```bash
110
+ # Install as a LaunchAgent
111
+ scripts/install_launchagent.sh install
112
+
113
+ # Check status
114
+ scripts/install_launchagent.sh status
115
+
116
+ # Remove auto-start
117
+ scripts/install_launchagent.sh uninstall
118
+ ```
119
+
120
+ ## Data Storage
121
+
122
+ All data is stored in `~/.local/share/clipsy/`:
123
+
124
+ | File | Purpose |
125
+ |------|---------|
126
+ | `clipsy.db` | SQLite database with clipboard entries |
127
+ | `images/` | Saved clipboard images (PNG files) |
128
+ | `clipsy.log` | Application log |
129
+
130
+ ## Development
131
+
132
+ ```bash
133
+ # Install with dev dependencies
134
+ .venv/bin/pip install -e ".[dev]"
135
+
136
+ # Run tests
137
+ .venv/bin/python -m pytest tests/ -v
138
+
139
+ # Run with coverage
140
+ .venv/bin/python -m pytest tests/ --cov=clipsy --cov-report=term-missing
141
+ ```
142
+
143
+ ## Architecture
144
+
145
+ ```
146
+ NSPasteboard → monitor.py → redact.py → storage.py (SQLite) → app.py (menu bar UI)
147
+ ```
148
+
149
+ - **`app.py`** — `rumps.App` subclass; renders the menu bar dropdown, handles clicks and search
150
+ - **`monitor.py`** — Polls `NSPasteboard.changeCount()` every 0.5s; detects text, images, and file copies
151
+ - **`storage.py`** — SQLite with FTS5 full-text search, SHA-256 deduplication, auto-purge
152
+ - **`redact.py`** — Sensitive data detection and masking (API keys, passwords, SSN, credit cards, tokens)
153
+ - **`config.py`** — Constants, paths, limits
154
+ - **`models.py`** — `ClipboardEntry` dataclass, `ContentType` enum
155
+ - **`utils.py`** — Hashing, text truncation, PNG dimension parsing, thumbnail generation
156
+
157
+ ### Dependencies
158
+
159
+ Only one external dependency:
160
+
161
+ - **`rumps`** — macOS menu bar app framework (brings `pyobjc-framework-Cocoa` transitively)
162
+ - **`sqlite3`** — Built into Python
163
+
164
+ ## License
165
+
166
+ MIT License — see [LICENSE](LICENSE) for details.
@@ -0,0 +1,15 @@
1
+ clipsy/__init__.py,sha256=RfTNMAT90hngQkclaI0i0rdsR5s2s2IFJWzExWhvwjU,46
2
+ clipsy/__main__.py,sha256=vCWOKz08ZY3Uc5orHOMND0sEgBnyvD0WkD4o6b4_6Mw,493
3
+ clipsy/app.py,sha256=_IKuZgA-DvAE3KDsDN00S0aaMIxwBpiv_P1fpzwQFqU,7299
4
+ clipsy/config.py,sha256=E9NCsGMeT2H0YXYCq5xYwbd27VkksV4S9txZSMX0RmE,672
5
+ clipsy/models.py,sha256=i3KjD4Dcp7y_gTXkIBr2xZxFIIC6kHaMWmv6cSIDABM,563
6
+ clipsy/monitor.py,sha256=UUKZToYcZ5pzu7LfiuJ_nnE4AA-d3uLW-WdQU_XrDME,6288
7
+ clipsy/redact.py,sha256=EU2nq8oEDYLZ_I_9tX2bXACIF971a0V_ApKdvpN4VGY,7284
8
+ clipsy/storage.py,sha256=QNox2-spkVx_gMPSWqKgF_yj-SMpRJ4UAT6yd9l6rVY,9794
9
+ clipsy/utils.py,sha256=rztT20cXYJ7633q3oLwt6Fv0Ubq9hm5bPPKDkWyvxdI,2208
10
+ clipsy-1.5.0.dist-info/licenses/LICENSE,sha256=bZ7uVihgPnLGryi5ugUgkVrEoho0_hFAd3bVdhYXGqs,1071
11
+ clipsy-1.5.0.dist-info/METADATA,sha256=7lAWnXyqO69PZSLoFjOZKU1WuXaaykI0a3V-dUSRkUE,5693
12
+ clipsy-1.5.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
13
+ clipsy-1.5.0.dist-info/entry_points.txt,sha256=ISIoWU3Zj-4NEuj2pJW96LxpsvjRJJH73otv9gA9YSs,48
14
+ clipsy-1.5.0.dist-info/top_level.txt,sha256=trxprVJk4ZMudCshc7PD0N9iFgQO4Tq4sW5L5wLduns,7
15
+ clipsy-1.5.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ clipsy = clipsy.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Brendan Conrad
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ clipsy