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.
- clipvault-1.0.0/PKG-INFO +162 -0
- clipvault-1.0.0/README.md +146 -0
- clipvault-1.0.0/app/__init__.py +1 -0
- clipvault-1.0.0/app/core/__init__.py +0 -0
- clipvault-1.0.0/app/core/clipboard_watcher.py +57 -0
- clipvault-1.0.0/app/core/history_manager.py +61 -0
- clipvault-1.0.0/app/main.py +66 -0
- clipvault-1.0.0/app/models/__init__.py +0 -0
- clipvault-1.0.0/app/models/clip_item.py +40 -0
- clipvault-1.0.0/app/shared/__init__.py +0 -0
- clipvault-1.0.0/app/shared/constants.py +24 -0
- clipvault-1.0.0/app/tests/__init__.py +0 -0
- clipvault-1.0.0/app/tests/conftest.py +7 -0
- clipvault-1.0.0/app/tests/test_clip_item.py +114 -0
- clipvault-1.0.0/app/tests/test_clipboard_watcher.py +98 -0
- clipvault-1.0.0/app/tests/test_history_manager.py +163 -0
- clipvault-1.0.0/app/tests/test_hotkey_listener.py +56 -0
- clipvault-1.0.0/app/ui/__init__.py +0 -0
- clipvault-1.0.0/app/ui/main_window.py +428 -0
- clipvault-1.0.0/app/ui/tray_icon.py +64 -0
- clipvault-1.0.0/app/ui/widgets/__init__.py +0 -0
- clipvault-1.0.0/app/ui/widgets/clip_item_widget.py +157 -0
- clipvault-1.0.0/app/workers/__init__.py +0 -0
- clipvault-1.0.0/app/workers/hotkey_listener.py +37 -0
- clipvault-1.0.0/clipvault.egg-info/PKG-INFO +162 -0
- clipvault-1.0.0/clipvault.egg-info/SOURCES.txt +30 -0
- clipvault-1.0.0/clipvault.egg-info/dependency_links.txt +1 -0
- clipvault-1.0.0/clipvault.egg-info/entry_points.txt +2 -0
- clipvault-1.0.0/clipvault.egg-info/requires.txt +6 -0
- clipvault-1.0.0/clipvault.egg-info/top_level.txt +1 -0
- clipvault-1.0.0/pyproject.toml +46 -0
- clipvault-1.0.0/setup.cfg +4 -0
clipvault-1.0.0/PKG-INFO
ADDED
|
@@ -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,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() == ""
|