clipvault 1.0.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 (32) hide show
  1. clipvault-1.0.0/PKG-INFO +162 -0
  2. clipvault-1.0.0/README.md +146 -0
  3. clipvault-1.0.0/app/__init__.py +1 -0
  4. clipvault-1.0.0/app/core/__init__.py +0 -0
  5. clipvault-1.0.0/app/core/clipboard_watcher.py +57 -0
  6. clipvault-1.0.0/app/core/history_manager.py +61 -0
  7. clipvault-1.0.0/app/main.py +66 -0
  8. clipvault-1.0.0/app/models/__init__.py +0 -0
  9. clipvault-1.0.0/app/models/clip_item.py +40 -0
  10. clipvault-1.0.0/app/shared/__init__.py +0 -0
  11. clipvault-1.0.0/app/shared/constants.py +24 -0
  12. clipvault-1.0.0/app/tests/__init__.py +0 -0
  13. clipvault-1.0.0/app/tests/conftest.py +7 -0
  14. clipvault-1.0.0/app/tests/test_clip_item.py +114 -0
  15. clipvault-1.0.0/app/tests/test_clipboard_watcher.py +98 -0
  16. clipvault-1.0.0/app/tests/test_history_manager.py +163 -0
  17. clipvault-1.0.0/app/tests/test_hotkey_listener.py +56 -0
  18. clipvault-1.0.0/app/ui/__init__.py +0 -0
  19. clipvault-1.0.0/app/ui/main_window.py +428 -0
  20. clipvault-1.0.0/app/ui/tray_icon.py +64 -0
  21. clipvault-1.0.0/app/ui/widgets/__init__.py +0 -0
  22. clipvault-1.0.0/app/ui/widgets/clip_item_widget.py +157 -0
  23. clipvault-1.0.0/app/workers/__init__.py +0 -0
  24. clipvault-1.0.0/app/workers/hotkey_listener.py +37 -0
  25. clipvault-1.0.0/clipvault.egg-info/PKG-INFO +162 -0
  26. clipvault-1.0.0/clipvault.egg-info/SOURCES.txt +30 -0
  27. clipvault-1.0.0/clipvault.egg-info/dependency_links.txt +1 -0
  28. clipvault-1.0.0/clipvault.egg-info/entry_points.txt +2 -0
  29. clipvault-1.0.0/clipvault.egg-info/requires.txt +6 -0
  30. clipvault-1.0.0/clipvault.egg-info/top_level.txt +1 -0
  31. clipvault-1.0.0/pyproject.toml +46 -0
  32. clipvault-1.0.0/setup.cfg +4 -0
@@ -0,0 +1,162 @@
1
+ Metadata-Version: 2.4
2
+ Name: clipvault
3
+ Version: 1.0.0
4
+ Summary: Windows-style clipboard manager for Ubuntu
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/jinshah0322/ClipVault
7
+ Project-URL: Issues, https://github.com/jinshah0322/ClipVault/issues
8
+ Keywords: clipboard,ubuntu,linux,utility
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: pynput>=1.7.0
12
+ Requires-Dist: Pillow>=9.0.0
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
15
+ Requires-Dist: pytest-qt>=4.2.0; extra == "dev"
16
+
17
+ # ClipVault — Windows-style Clipboard Manager for Ubuntu
18
+
19
+ A pixel-perfect recreation of Windows **Win+V clipboard history** for Ubuntu Linux, built with PyQt5.
20
+
21
+ ---
22
+
23
+ ## Features
24
+
25
+ | Feature | Windows Win+V | ClipVault |
26
+ |---------|--------------|-----------|
27
+ | Clipboard History | Yes | Yes |
28
+ | Text support | Yes | Yes |
29
+ | Image support | Yes | Yes |
30
+ | Pin items | Yes | Yes |
31
+ | Search history | Yes | Yes |
32
+ | Global hotkey | Win+V | Super+V |
33
+ | One-click paste | Yes | Yes |
34
+ | Dark UI | Yes | Yes |
35
+ | System tray | Yes | Yes |
36
+ | Auto-start on login | Yes | Yes |
37
+ | Tabs (All/Pinned/Text/Images) | Yes | Yes |
38
+ | Cross-device sync | Yes (MS account) | No |
39
+
40
+ ---
41
+
42
+ ## Project structure
43
+
44
+ ```
45
+ clipboard_manager/
46
+ ├── app/ # Application package
47
+ │ ├── main.py # Entry point
48
+ │ ├── shared/
49
+ │ │ └── constants.py # Colors, file paths, limits
50
+ │ ├── models/
51
+ │ │ └── clip_item.py # ClipItem data model
52
+ │ ├── core/
53
+ │ │ ├── history_manager.py # Load/save/search clipboard history
54
+ │ │ └── clipboard_watcher.py# QThread — polls system clipboard
55
+ │ ├── workers/
56
+ │ │ └── hotkey_listener.py # Daemon thread — global Super+V hotkey
57
+ │ └── ui/
58
+ │ ├── main_window.py # ClipVaultWindow (main UI)
59
+ │ ├── tray_icon.py # System tray icon + context menu
60
+ │ └── widgets/
61
+ │ └── clip_item_widget.py # Per-item card widget
62
+ ├── installation/
63
+ │ ├── install.sh # One-shot installer for Ubuntu/Debian
64
+ │ ├── uninstall.sh # Removes all installed files
65
+ │ └── README.md # Installation guide
66
+ ├── pyproject.toml # Project metadata, deps, pytest config
67
+ └── README.md
68
+ ```
69
+
70
+ ---
71
+
72
+ ## Quick install
73
+
74
+ ```bash
75
+ chmod +x installation/install.sh
76
+ ./installation/install.sh
77
+ ```
78
+
79
+ Then launch:
80
+
81
+ ```bash
82
+ clipvault
83
+ ```
84
+
85
+ See [installation/README.md](installation/README.md) for the full installation guide and manual install instructions.
86
+
87
+ To uninstall:
88
+
89
+ ```bash
90
+ chmod +x installation/uninstall.sh
91
+ ./installation/uninstall.sh
92
+ ```
93
+
94
+ ---
95
+
96
+ ## Running without installing
97
+
98
+ ```bash
99
+ # From the project root
100
+ python3 app/main.py
101
+ ```
102
+
103
+ ---
104
+
105
+ ## Keyboard shortcuts
106
+
107
+ | Shortcut | Action |
108
+ |----------|--------|
109
+ | `Super+V` | Toggle clipboard window |
110
+ | `Escape` | Close window |
111
+ | Click item | Paste immediately |
112
+ | Pin button | Pin / Unpin item |
113
+ | X button | Delete item |
114
+
115
+ ---
116
+
117
+ ## Configuration
118
+
119
+ History is stored at `~/.clipvault_history.json`.
120
+
121
+ To change the max history size, edit `app/shared/constants.py`:
122
+
123
+ ```python
124
+ MAX_HISTORY = 50 # change this number
125
+ ```
126
+
127
+ ---
128
+
129
+ ## Compatibility
130
+
131
+ - Ubuntu 20.04+
132
+ - Debian 11+
133
+ - Any GNOME / KDE / XFCE desktop on X11
134
+
135
+ ---
136
+
137
+ ## Wayland limitations
138
+
139
+ ClipVault is built on PyQt5 + X11. On Wayland sessions some features are restricted:
140
+
141
+ | Feature | X11 | Wayland |
142
+ |---------|-----|---------|
143
+ | Clipboard history | Yes | Yes |
144
+ | `Super+V` global hotkey | Yes | No |
145
+ | Auto-paste on item click | Yes | Partial |
146
+ | System tray icon | Yes | Yes (via XWayland) |
147
+
148
+ **Why the hotkey does not work on Wayland:**
149
+ `pynput` uses X11 APIs (`python-xlib`) to intercept global key events. Wayland's security model blocks applications from listening to input outside their own window, so the `Super+V` hotkey cannot be registered.
150
+
151
+ **Workarounds on Wayland:**
152
+ - Click the system tray icon to toggle the window
153
+ - Run the session in **X11 mode**: log out → on the login screen click the gear icon → select **Ubuntu on Xorg** → log back in
154
+ - Set the environment variable before launching:
155
+ ```bash
156
+ QT_QPA_PLATFORM=xcb clipvault
157
+ ```
158
+
159
+ **Check which session you are on:**
160
+ ```bash
161
+ echo $XDG_SESSION_TYPE # prints "x11" or "wayland"
162
+ ```
@@ -0,0 +1,146 @@
1
+ # ClipVault — Windows-style Clipboard Manager for Ubuntu
2
+
3
+ A pixel-perfect recreation of Windows **Win+V clipboard history** for Ubuntu Linux, built with PyQt5.
4
+
5
+ ---
6
+
7
+ ## Features
8
+
9
+ | Feature | Windows Win+V | ClipVault |
10
+ |---------|--------------|-----------|
11
+ | Clipboard History | Yes | Yes |
12
+ | Text support | Yes | Yes |
13
+ | Image support | Yes | Yes |
14
+ | Pin items | Yes | Yes |
15
+ | Search history | Yes | Yes |
16
+ | Global hotkey | Win+V | Super+V |
17
+ | One-click paste | Yes | Yes |
18
+ | Dark UI | Yes | Yes |
19
+ | System tray | Yes | Yes |
20
+ | Auto-start on login | Yes | Yes |
21
+ | Tabs (All/Pinned/Text/Images) | Yes | Yes |
22
+ | Cross-device sync | Yes (MS account) | No |
23
+
24
+ ---
25
+
26
+ ## Project structure
27
+
28
+ ```
29
+ clipboard_manager/
30
+ ├── app/ # Application package
31
+ │ ├── main.py # Entry point
32
+ │ ├── shared/
33
+ │ │ └── constants.py # Colors, file paths, limits
34
+ │ ├── models/
35
+ │ │ └── clip_item.py # ClipItem data model
36
+ │ ├── core/
37
+ │ │ ├── history_manager.py # Load/save/search clipboard history
38
+ │ │ └── clipboard_watcher.py# QThread — polls system clipboard
39
+ │ ├── workers/
40
+ │ │ └── hotkey_listener.py # Daemon thread — global Super+V hotkey
41
+ │ └── ui/
42
+ │ ├── main_window.py # ClipVaultWindow (main UI)
43
+ │ ├── tray_icon.py # System tray icon + context menu
44
+ │ └── widgets/
45
+ │ └── clip_item_widget.py # Per-item card widget
46
+ ├── installation/
47
+ │ ├── install.sh # One-shot installer for Ubuntu/Debian
48
+ │ ├── uninstall.sh # Removes all installed files
49
+ │ └── README.md # Installation guide
50
+ ├── pyproject.toml # Project metadata, deps, pytest config
51
+ └── README.md
52
+ ```
53
+
54
+ ---
55
+
56
+ ## Quick install
57
+
58
+ ```bash
59
+ chmod +x installation/install.sh
60
+ ./installation/install.sh
61
+ ```
62
+
63
+ Then launch:
64
+
65
+ ```bash
66
+ clipvault
67
+ ```
68
+
69
+ See [installation/README.md](installation/README.md) for the full installation guide and manual install instructions.
70
+
71
+ To uninstall:
72
+
73
+ ```bash
74
+ chmod +x installation/uninstall.sh
75
+ ./installation/uninstall.sh
76
+ ```
77
+
78
+ ---
79
+
80
+ ## Running without installing
81
+
82
+ ```bash
83
+ # From the project root
84
+ python3 app/main.py
85
+ ```
86
+
87
+ ---
88
+
89
+ ## Keyboard shortcuts
90
+
91
+ | Shortcut | Action |
92
+ |----------|--------|
93
+ | `Super+V` | Toggle clipboard window |
94
+ | `Escape` | Close window |
95
+ | Click item | Paste immediately |
96
+ | Pin button | Pin / Unpin item |
97
+ | X button | Delete item |
98
+
99
+ ---
100
+
101
+ ## Configuration
102
+
103
+ History is stored at `~/.clipvault_history.json`.
104
+
105
+ To change the max history size, edit `app/shared/constants.py`:
106
+
107
+ ```python
108
+ MAX_HISTORY = 50 # change this number
109
+ ```
110
+
111
+ ---
112
+
113
+ ## Compatibility
114
+
115
+ - Ubuntu 20.04+
116
+ - Debian 11+
117
+ - Any GNOME / KDE / XFCE desktop on X11
118
+
119
+ ---
120
+
121
+ ## Wayland limitations
122
+
123
+ ClipVault is built on PyQt5 + X11. On Wayland sessions some features are restricted:
124
+
125
+ | Feature | X11 | Wayland |
126
+ |---------|-----|---------|
127
+ | Clipboard history | Yes | Yes |
128
+ | `Super+V` global hotkey | Yes | No |
129
+ | Auto-paste on item click | Yes | Partial |
130
+ | System tray icon | Yes | Yes (via XWayland) |
131
+
132
+ **Why the hotkey does not work on Wayland:**
133
+ `pynput` uses X11 APIs (`python-xlib`) to intercept global key events. Wayland's security model blocks applications from listening to input outside their own window, so the `Super+V` hotkey cannot be registered.
134
+
135
+ **Workarounds on Wayland:**
136
+ - Click the system tray icon to toggle the window
137
+ - Run the session in **X11 mode**: log out → on the login screen click the gear icon → select **Ubuntu on Xorg** → log back in
138
+ - Set the environment variable before launching:
139
+ ```bash
140
+ QT_QPA_PLATFORM=xcb clipvault
141
+ ```
142
+
143
+ **Check which session you are on:**
144
+ ```bash
145
+ echo $XDG_SESSION_TYPE # prints "x11" or "wayland"
146
+ ```
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
File without changes
@@ -0,0 +1,57 @@
1
+ import base64
2
+ import time
3
+ from io import BytesIO
4
+
5
+ from PyQt5.QtCore import QThread, pyqtSignal
6
+ from PyQt5.QtWidgets import QApplication
7
+
8
+ try:
9
+ from PIL import Image
10
+ PIL_AVAILABLE = True
11
+ except ImportError:
12
+ PIL_AVAILABLE = False
13
+
14
+
15
+ class ClipboardWatcher(QThread):
16
+ new_text = pyqtSignal(str)
17
+ new_image = pyqtSignal(str) # base64-encoded PNG
18
+
19
+ def __init__(self):
20
+ super().__init__()
21
+ self._last_text = ""
22
+ self._last_img = ""
23
+ self._running = True
24
+
25
+ def run(self):
26
+ app = QApplication.instance()
27
+ while self._running:
28
+ try:
29
+ cb = app.clipboard()
30
+ mime = cb.mimeData()
31
+
32
+ if mime.hasImage() and PIL_AVAILABLE:
33
+ img = cb.image()
34
+ if not img.isNull():
35
+ buf = BytesIO()
36
+ ba = img.bits()
37
+ ba.setsize(img.byteCount())
38
+ pil = Image.frombytes("RGBA", (img.width(), img.height()), bytes(ba))
39
+ pil.save(buf, format="PNG")
40
+ b64 = base64.b64encode(buf.getvalue()).decode()
41
+ if b64 != self._last_img:
42
+ self._last_img = b64
43
+ self.new_image.emit(b64)
44
+
45
+ elif mime.hasText():
46
+ text = mime.text()
47
+ if text and text != self._last_text:
48
+ self._last_text = text
49
+ self.new_text.emit(text)
50
+
51
+ except Exception:
52
+ pass
53
+
54
+ time.sleep(0.5)
55
+
56
+ def stop(self):
57
+ self._running = False
@@ -0,0 +1,61 @@
1
+ import json
2
+ import os
3
+
4
+ from app.models.clip_item import ClipItem
5
+ from app.shared.constants import HISTORY_FILE, MAX_HISTORY
6
+
7
+
8
+ class HistoryManager:
9
+ def __init__(self):
10
+ self.items: list[ClipItem] = []
11
+ self.load()
12
+
13
+ def load(self):
14
+ try:
15
+ if os.path.exists(HISTORY_FILE):
16
+ with open(HISTORY_FILE, "r") as f:
17
+ data = json.load(f)
18
+ self.items = [ClipItem.from_dict(d) for d in data]
19
+ except Exception:
20
+ self.items = []
21
+
22
+ def save(self):
23
+ try:
24
+ with open(HISTORY_FILE, "w") as f:
25
+ json.dump([i.to_dict() for i in self.items], f, indent=2)
26
+ except Exception:
27
+ pass
28
+
29
+ def add(self, content, kind="text"):
30
+ for item in self.items:
31
+ if item.kind == kind and item.content == content:
32
+ if not item.pinned:
33
+ self.items.remove(item)
34
+ self.items.insert(0, item)
35
+ self.save()
36
+ return False
37
+
38
+ item = ClipItem(content, kind)
39
+ pinned = [i for i in self.items if i.pinned]
40
+ unpinned = [i for i in self.items if not i.pinned]
41
+ unpinned = unpinned[: MAX_HISTORY - 1]
42
+ self.items = [item] + unpinned + pinned
43
+ self.save()
44
+ return True
45
+
46
+ def toggle_pin(self, item):
47
+ item.pinned = not item.pinned
48
+ self.save()
49
+
50
+ def remove(self, item):
51
+ if item in self.items:
52
+ self.items.remove(item)
53
+ self.save()
54
+
55
+ def clear_unpinned(self):
56
+ self.items = [i for i in self.items if i.pinned]
57
+ self.save()
58
+
59
+ def search(self, query):
60
+ q = query.lower()
61
+ return [i for i in self.items if q in i.preview_text().lower()]
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ClipVault - Windows-style Clipboard Manager for Ubuntu
4
+ Keyboard shortcut: Super+V (Win+V equivalent)
5
+ """
6
+
7
+ import sys
8
+ import os
9
+ import argparse
10
+ import signal
11
+ import atexit
12
+
13
+ # Allow running from the project root: python app/main.py
14
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
15
+
16
+ from app import __version__
17
+ from PyQt5.QtCore import Qt
18
+ from PyQt5.QtWidgets import QApplication
19
+
20
+ from app.core.history_manager import HistoryManager
21
+ from app.ui.main_window import ClipVaultWindow
22
+ from app.ui.tray_icon import TrayIcon
23
+ from app.shared.constants import PID_FILE
24
+
25
+
26
+ def _parse_args():
27
+ parser = argparse.ArgumentParser(
28
+ prog="clipvault",
29
+ description="Windows-style clipboard manager for Ubuntu",
30
+ )
31
+ parser.add_argument(
32
+ "--version", action="version", version=f"ClipVault {__version__}"
33
+ )
34
+ return parser.parse_args()
35
+
36
+
37
+ def _write_pid():
38
+ os.makedirs(os.path.dirname(PID_FILE), exist_ok=True)
39
+ with open(PID_FILE, "w") as f:
40
+ f.write(str(os.getpid()))
41
+ atexit.register(lambda: os.path.exists(PID_FILE) and os.remove(PID_FILE))
42
+
43
+
44
+ def main():
45
+ _parse_args()
46
+ _write_pid()
47
+
48
+ QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
49
+ QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
50
+ app = QApplication(sys.argv)
51
+ app.setApplicationName("ClipVault")
52
+ app.setQuitOnLastWindowClosed(False)
53
+
54
+ history = HistoryManager()
55
+ window = ClipVaultWindow(history)
56
+ tray = TrayIcon(window, app) # noqa: F841 — kept alive by reference
57
+
58
+ # SIGUSR1 → toggle window (sent by the GNOME custom keybinding toggle script)
59
+ signal.signal(signal.SIGUSR1, lambda *_: window._hotkey_activated.emit())
60
+
61
+ window.show()
62
+ sys.exit(app.exec_())
63
+
64
+
65
+ if __name__ == "__main__":
66
+ main()
File without changes
@@ -0,0 +1,40 @@
1
+ from datetime import datetime
2
+ from app.shared.constants import MAX_TEXT_PREVIEW
3
+
4
+
5
+ class ClipItem:
6
+ def __init__(self, content, kind="text", timestamp=None, pinned=False):
7
+ self.content = content # str for text, base64 str for image
8
+ self.kind = kind # "text" | "image"
9
+ self.timestamp = timestamp or datetime.now().isoformat()
10
+ self.pinned = pinned
11
+
12
+ def to_dict(self):
13
+ return {
14
+ "content": self.content,
15
+ "kind": self.kind,
16
+ "timestamp": self.timestamp,
17
+ "pinned": self.pinned,
18
+ }
19
+
20
+ @staticmethod
21
+ def from_dict(d):
22
+ return ClipItem(d["content"], d["kind"], d["timestamp"], d.get("pinned", False))
23
+
24
+ def preview_text(self):
25
+ if self.kind == "image":
26
+ return "📷 Image"
27
+ t = self.content.strip().replace("\n", " ")
28
+ return t[:MAX_TEXT_PREVIEW] + ("…" if len(t) > MAX_TEXT_PREVIEW else "")
29
+
30
+ def time_label(self):
31
+ try:
32
+ dt = datetime.fromisoformat(self.timestamp)
33
+ delta = datetime.now() - dt
34
+ s = int(delta.total_seconds())
35
+ if s < 60: return "Just now"
36
+ if s < 3600: return f"{s // 60}m ago"
37
+ if s < 86400: return f"{s // 3600}h ago"
38
+ return dt.strftime("%b %d")
39
+ except Exception:
40
+ return ""
File without changes
@@ -0,0 +1,24 @@
1
+ import os
2
+
3
+ # ── File paths ────────────────────────────────────────────────────────────────
4
+ HISTORY_FILE = os.path.expanduser("~/.clipvault_history.json")
5
+ PID_FILE = os.path.expanduser("~/.local/share/clipvault/clipvault.pid")
6
+
7
+ # ── Limits ────────────────────────────────────────────────────────────────────
8
+ MAX_HISTORY = 50
9
+ MAX_TEXT_PREVIEW = 120
10
+
11
+ # ── Colors (Windows 11 dark palette) ─────────────────────────────────────────
12
+ C_BG = "#1c1c1c"
13
+ C_SURFACE = "#2d2d2d"
14
+ C_ITEM = "#383838"
15
+ C_ITEM_HOV = "#424242"
16
+ C_ITEM_SEL = "#0078d4"
17
+ C_ACCENT = "#0078d4"
18
+ C_ACCENT2 = "#005a9e"
19
+ C_TEXT = "#ffffff"
20
+ C_TEXT_DIM = "#9d9d9d"
21
+ C_BORDER = "#404040"
22
+ C_PIN = "#f0c040"
23
+ C_CLOSE = "#c42b1c"
24
+ C_SEARCH_BG = "#404040"
File without changes
@@ -0,0 +1,7 @@
1
+ import os
2
+ import sys
3
+
4
+ # Ensure the project root is on sys.path so `app.*` imports resolve
5
+ ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
6
+ if ROOT not in sys.path:
7
+ sys.path.insert(0, ROOT)
@@ -0,0 +1,114 @@
1
+ from datetime import datetime, timedelta
2
+
3
+ import pytest
4
+
5
+ from app.models.clip_item import ClipItem
6
+ from app.shared.constants import MAX_TEXT_PREVIEW
7
+
8
+
9
+ class TestClipItemInit:
10
+ def test_defaults(self):
11
+ item = ClipItem("hello")
12
+ assert item.content == "hello"
13
+ assert item.kind == "text"
14
+ assert item.pinned is False
15
+ assert item.timestamp is not None
16
+
17
+ def test_custom_fields(self):
18
+ ts = "2024-01-01T12:00:00"
19
+ item = ClipItem("data", kind="image", timestamp=ts, pinned=True)
20
+ assert item.kind == "image"
21
+ assert item.timestamp == ts
22
+ assert item.pinned is True
23
+
24
+ def test_timestamp_auto_set(self):
25
+ before = datetime.now().isoformat()
26
+ item = ClipItem("x")
27
+ after = datetime.now().isoformat()
28
+ assert before <= item.timestamp <= after
29
+
30
+
31
+ class TestClipItemSerialization:
32
+ def test_to_dict(self):
33
+ item = ClipItem("hello", kind="text", timestamp="2024-06-01T10:00:00", pinned=True)
34
+ d = item.to_dict()
35
+ assert d == {
36
+ "content": "hello",
37
+ "kind": "text",
38
+ "timestamp": "2024-06-01T10:00:00",
39
+ "pinned": True,
40
+ }
41
+
42
+ def test_from_dict_roundtrip(self):
43
+ original = ClipItem("world", kind="text", timestamp="2024-06-01T10:00:00", pinned=False)
44
+ restored = ClipItem.from_dict(original.to_dict())
45
+ assert restored.content == original.content
46
+ assert restored.kind == original.kind
47
+ assert restored.timestamp == original.timestamp
48
+ assert restored.pinned == original.pinned
49
+
50
+ def test_from_dict_missing_pinned_defaults_false(self):
51
+ d = {"content": "hi", "kind": "text", "timestamp": "2024-01-01T00:00:00"}
52
+ item = ClipItem.from_dict(d)
53
+ assert item.pinned is False
54
+
55
+
56
+ class TestClipItemPreviewText:
57
+ def test_text_short(self):
58
+ item = ClipItem("Hello world")
59
+ assert item.preview_text() == "Hello world"
60
+
61
+ def test_text_strips_newlines(self):
62
+ item = ClipItem("line1\nline2\nline3")
63
+ assert "\n" not in item.preview_text()
64
+ assert "line1 line2 line3" == item.preview_text()
65
+
66
+ def test_text_strips_leading_trailing_whitespace(self):
67
+ item = ClipItem(" trimmed ")
68
+ assert item.preview_text() == "trimmed"
69
+
70
+ def test_text_truncated_at_max(self):
71
+ long_text = "a" * (MAX_TEXT_PREVIEW + 10)
72
+ item = ClipItem(long_text)
73
+ preview = item.preview_text()
74
+ assert preview.endswith("…")
75
+ assert len(preview) == MAX_TEXT_PREVIEW + 1 # content + ellipsis char
76
+
77
+ def test_text_exactly_at_limit_no_ellipsis(self):
78
+ text = "b" * MAX_TEXT_PREVIEW
79
+ item = ClipItem(text)
80
+ assert item.preview_text() == text
81
+ assert not item.preview_text().endswith("…")
82
+
83
+ def test_image_preview(self):
84
+ item = ClipItem("base64data", kind="image")
85
+ assert item.preview_text() == "📷 Image"
86
+
87
+
88
+ class TestClipItemTimeLabel:
89
+ def _item_with_delta(self, seconds):
90
+ ts = (datetime.now() - timedelta(seconds=seconds)).isoformat()
91
+ return ClipItem("x", timestamp=ts)
92
+
93
+ def test_just_now(self):
94
+ assert self._item_with_delta(30).time_label() == "Just now"
95
+
96
+ def test_minutes_ago(self):
97
+ label = self._item_with_delta(150).time_label() # 2m 30s
98
+ assert label == "2m ago"
99
+
100
+ def test_hours_ago(self):
101
+ label = self._item_with_delta(7200).time_label() # 2h
102
+ assert label == "2h ago"
103
+
104
+ def test_days_ago_shows_date(self):
105
+ ts = (datetime.now() - timedelta(days=3)).isoformat()
106
+ item = ClipItem("x", timestamp=ts)
107
+ label = item.time_label()
108
+ # Should be a month-day string like "Mar 24"
109
+ assert len(label) > 0
110
+ assert "ago" not in label
111
+
112
+ def test_invalid_timestamp_returns_empty(self):
113
+ item = ClipItem("x", timestamp="not-a-date")
114
+ assert item.time_label() == ""