schenesort 2.1.1__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.
- schenesort/__init__.py +1 -0
- schenesort/cli.py +1323 -0
- schenesort/config.py +108 -0
- schenesort/db.py +341 -0
- schenesort/tui/__init__.py +5 -0
- schenesort/tui/app.py +180 -0
- schenesort/tui/widgets/__init__.py +6 -0
- schenesort/tui/widgets/image_preview.py +97 -0
- schenesort/tui/widgets/metadata_panel.py +161 -0
- schenesort/xmp.py +294 -0
- schenesort-2.1.1.dist-info/METADATA +318 -0
- schenesort-2.1.1.dist-info/RECORD +15 -0
- schenesort-2.1.1.dist-info/WHEEL +4 -0
- schenesort-2.1.1.dist-info/entry_points.txt +2 -0
- schenesort-2.1.1.dist-info/licenses/LICENSE +21 -0
schenesort/config.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Configuration file handling for Schenesort."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
APP_NAME = "schenesort"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_config_dir() -> Path:
|
|
12
|
+
"""Get the XDG config directory for schenesort."""
|
|
13
|
+
xdg_config = os.environ.get("XDG_CONFIG_HOME", "")
|
|
14
|
+
if xdg_config:
|
|
15
|
+
config_dir = Path(xdg_config) / APP_NAME
|
|
16
|
+
else:
|
|
17
|
+
config_dir = Path.home() / ".config" / APP_NAME
|
|
18
|
+
return config_dir
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_config_path() -> Path:
|
|
22
|
+
"""Get the path to the config file."""
|
|
23
|
+
return get_config_dir() / "config.toml"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Config:
|
|
28
|
+
"""Schenesort configuration."""
|
|
29
|
+
|
|
30
|
+
# Ollama settings
|
|
31
|
+
ollama_host: str = ""
|
|
32
|
+
ollama_model: str = "llava"
|
|
33
|
+
|
|
34
|
+
# Collection paths (optional defaults)
|
|
35
|
+
wallpaper_path: str = ""
|
|
36
|
+
|
|
37
|
+
# Database settings
|
|
38
|
+
db_path: str = ""
|
|
39
|
+
|
|
40
|
+
# Additional settings as needed
|
|
41
|
+
extra: dict = field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_config() -> Config:
|
|
45
|
+
"""Load configuration from file."""
|
|
46
|
+
config_path = get_config_path()
|
|
47
|
+
|
|
48
|
+
if not config_path.exists():
|
|
49
|
+
return Config()
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
with open(config_path, "rb") as f:
|
|
53
|
+
data = tomllib.load(f)
|
|
54
|
+
|
|
55
|
+
config = Config()
|
|
56
|
+
|
|
57
|
+
# Ollama section
|
|
58
|
+
ollama = data.get("ollama", {})
|
|
59
|
+
if ollama.get("host"):
|
|
60
|
+
config.ollama_host = ollama["host"]
|
|
61
|
+
if ollama.get("model"):
|
|
62
|
+
config.ollama_model = ollama["model"]
|
|
63
|
+
|
|
64
|
+
# Paths section
|
|
65
|
+
paths = data.get("paths", {})
|
|
66
|
+
if paths.get("wallpaper"):
|
|
67
|
+
config.wallpaper_path = paths["wallpaper"]
|
|
68
|
+
if paths.get("database"):
|
|
69
|
+
config.db_path = paths["database"]
|
|
70
|
+
|
|
71
|
+
# Store any extra settings
|
|
72
|
+
config.extra = data
|
|
73
|
+
|
|
74
|
+
return config
|
|
75
|
+
|
|
76
|
+
except Exception:
|
|
77
|
+
return Config()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def create_default_config() -> Path:
|
|
81
|
+
"""Create a default config file if it doesn't exist."""
|
|
82
|
+
config_path = get_config_path()
|
|
83
|
+
|
|
84
|
+
if config_path.exists():
|
|
85
|
+
return config_path
|
|
86
|
+
|
|
87
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
|
|
89
|
+
default_config = """\
|
|
90
|
+
# Schenesort configuration file
|
|
91
|
+
|
|
92
|
+
[ollama]
|
|
93
|
+
# Ollama server URL (leave empty for localhost:11434)
|
|
94
|
+
# host = "http://server:11434"
|
|
95
|
+
|
|
96
|
+
# Default vision model
|
|
97
|
+
model = "llava"
|
|
98
|
+
|
|
99
|
+
[paths]
|
|
100
|
+
# Default wallpaper collection path
|
|
101
|
+
# wallpaper = "~/wallpapers"
|
|
102
|
+
|
|
103
|
+
# Database path (default: ~/.local/share/schenesort/index.db)
|
|
104
|
+
# database = ""
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
config_path.write_text(default_config)
|
|
108
|
+
return config_path
|
schenesort/db.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""SQLite database for wallpaper collection indexing."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sqlite3
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from schenesort.xmp import ImageMetadata, get_xmp_path
|
|
8
|
+
|
|
9
|
+
APP_NAME = "schenesort"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_data_dir() -> Path:
|
|
13
|
+
"""Get the XDG data directory for schenesort."""
|
|
14
|
+
xdg_data = os.environ.get("XDG_DATA_HOME", "")
|
|
15
|
+
if xdg_data:
|
|
16
|
+
data_dir = Path(xdg_data) / APP_NAME
|
|
17
|
+
else:
|
|
18
|
+
data_dir = Path.home() / ".local" / "share" / APP_NAME
|
|
19
|
+
return data_dir
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_default_db_path() -> Path:
|
|
23
|
+
"""Get the default database path."""
|
|
24
|
+
return get_data_dir() / "index.db"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DEFAULT_DB_PATH = get_default_db_path()
|
|
28
|
+
|
|
29
|
+
SCHEMA = """
|
|
30
|
+
CREATE TABLE IF NOT EXISTS wallpapers (
|
|
31
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
32
|
+
path TEXT UNIQUE NOT NULL,
|
|
33
|
+
filename TEXT NOT NULL,
|
|
34
|
+
extension TEXT NOT NULL,
|
|
35
|
+
description TEXT,
|
|
36
|
+
scene TEXT,
|
|
37
|
+
style TEXT,
|
|
38
|
+
time_of_day TEXT,
|
|
39
|
+
subject TEXT,
|
|
40
|
+
source TEXT,
|
|
41
|
+
ai_model TEXT,
|
|
42
|
+
width INTEGER,
|
|
43
|
+
height INTEGER,
|
|
44
|
+
recommended_screen TEXT,
|
|
45
|
+
mtime REAL
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
CREATE TABLE IF NOT EXISTS tags (
|
|
49
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
50
|
+
wallpaper_id INTEGER NOT NULL,
|
|
51
|
+
tag TEXT NOT NULL,
|
|
52
|
+
FOREIGN KEY (wallpaper_id) REFERENCES wallpapers(id) ON DELETE CASCADE
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
CREATE TABLE IF NOT EXISTS moods (
|
|
56
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
57
|
+
wallpaper_id INTEGER NOT NULL,
|
|
58
|
+
mood TEXT NOT NULL,
|
|
59
|
+
FOREIGN KEY (wallpaper_id) REFERENCES wallpapers(id) ON DELETE CASCADE
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
CREATE TABLE IF NOT EXISTS colors (
|
|
63
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
64
|
+
wallpaper_id INTEGER NOT NULL,
|
|
65
|
+
color TEXT NOT NULL,
|
|
66
|
+
FOREIGN KEY (wallpaper_id) REFERENCES wallpapers(id) ON DELETE CASCADE
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
CREATE INDEX IF NOT EXISTS idx_wallpapers_description ON wallpapers(description);
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_wallpapers_style ON wallpapers(style);
|
|
71
|
+
CREATE INDEX IF NOT EXISTS idx_wallpapers_subject ON wallpapers(subject);
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_wallpapers_time_of_day ON wallpapers(time_of_day);
|
|
73
|
+
CREATE INDEX IF NOT EXISTS idx_wallpapers_recommended_screen ON wallpapers(recommended_screen);
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_wallpapers_width ON wallpapers(width);
|
|
75
|
+
CREATE INDEX IF NOT EXISTS idx_wallpapers_height ON wallpapers(height);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_tags_tag ON tags(tag);
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_moods_mood ON moods(mood);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_colors_color ON colors(color);
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class WallpaperDB:
|
|
83
|
+
"""SQLite database for wallpaper metadata."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, db_path: Path = DEFAULT_DB_PATH) -> None:
|
|
86
|
+
self.db_path = db_path
|
|
87
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
self.conn: sqlite3.Connection | None = None
|
|
89
|
+
|
|
90
|
+
def __enter__(self) -> "WallpaperDB":
|
|
91
|
+
self.connect()
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
def __exit__(self, *args) -> None:
|
|
95
|
+
self.close()
|
|
96
|
+
|
|
97
|
+
def connect(self) -> None:
|
|
98
|
+
self.conn = sqlite3.connect(self.db_path)
|
|
99
|
+
self.conn.row_factory = sqlite3.Row
|
|
100
|
+
self.conn.execute("PRAGMA foreign_keys = ON")
|
|
101
|
+
self.conn.executescript(SCHEMA)
|
|
102
|
+
self.conn.commit()
|
|
103
|
+
|
|
104
|
+
def close(self) -> None:
|
|
105
|
+
if self.conn:
|
|
106
|
+
self.conn.close()
|
|
107
|
+
self.conn = None
|
|
108
|
+
|
|
109
|
+
def clear(self) -> None:
|
|
110
|
+
"""Clear all data from the database."""
|
|
111
|
+
if not self.conn:
|
|
112
|
+
return
|
|
113
|
+
self.conn.execute("DELETE FROM colors")
|
|
114
|
+
self.conn.execute("DELETE FROM moods")
|
|
115
|
+
self.conn.execute("DELETE FROM tags")
|
|
116
|
+
self.conn.execute("DELETE FROM wallpapers")
|
|
117
|
+
self.conn.commit()
|
|
118
|
+
|
|
119
|
+
def index_image(self, image_path: Path, metadata: ImageMetadata) -> None:
|
|
120
|
+
"""Add or update an image in the index."""
|
|
121
|
+
if not self.conn:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
xmp_path = get_xmp_path(image_path)
|
|
125
|
+
mtime = xmp_path.stat().st_mtime if xmp_path.exists() else 0
|
|
126
|
+
|
|
127
|
+
# Check if already indexed with same mtime
|
|
128
|
+
cursor = self.conn.execute(
|
|
129
|
+
"SELECT id, mtime FROM wallpapers WHERE path = ?", (str(image_path),)
|
|
130
|
+
)
|
|
131
|
+
row = cursor.fetchone()
|
|
132
|
+
|
|
133
|
+
if row and row["mtime"] == mtime:
|
|
134
|
+
return # Already up to date
|
|
135
|
+
|
|
136
|
+
# Delete existing entry if present
|
|
137
|
+
if row:
|
|
138
|
+
self.conn.execute("DELETE FROM wallpapers WHERE id = ?", (row["id"],))
|
|
139
|
+
|
|
140
|
+
# Insert new entry
|
|
141
|
+
cursor = self.conn.execute(
|
|
142
|
+
"""INSERT INTO wallpapers
|
|
143
|
+
(path, filename, extension, description, scene, style, time_of_day,
|
|
144
|
+
subject, source, ai_model, width, height, recommended_screen, mtime)
|
|
145
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
146
|
+
(
|
|
147
|
+
str(image_path),
|
|
148
|
+
image_path.name,
|
|
149
|
+
image_path.suffix.lower(),
|
|
150
|
+
metadata.description or None,
|
|
151
|
+
metadata.scene or None,
|
|
152
|
+
metadata.style or None,
|
|
153
|
+
metadata.time_of_day or None,
|
|
154
|
+
metadata.subject or None,
|
|
155
|
+
metadata.source or None,
|
|
156
|
+
metadata.ai_model or None,
|
|
157
|
+
metadata.width or None,
|
|
158
|
+
metadata.height or None,
|
|
159
|
+
metadata.recommended_screen or None,
|
|
160
|
+
mtime,
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
wallpaper_id = cursor.lastrowid
|
|
164
|
+
|
|
165
|
+
# Insert tags
|
|
166
|
+
for tag in metadata.tags:
|
|
167
|
+
self.conn.execute(
|
|
168
|
+
"INSERT INTO tags (wallpaper_id, tag) VALUES (?, ?)", (wallpaper_id, tag)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Insert moods
|
|
172
|
+
for mood in metadata.mood:
|
|
173
|
+
self.conn.execute(
|
|
174
|
+
"INSERT INTO moods (wallpaper_id, mood) VALUES (?, ?)", (wallpaper_id, mood)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Insert colors
|
|
178
|
+
for color in metadata.colors:
|
|
179
|
+
self.conn.execute(
|
|
180
|
+
"INSERT INTO colors (wallpaper_id, color) VALUES (?, ?)", (wallpaper_id, color)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def commit(self) -> None:
|
|
184
|
+
if self.conn:
|
|
185
|
+
self.conn.commit()
|
|
186
|
+
|
|
187
|
+
def query(
|
|
188
|
+
self,
|
|
189
|
+
description: str | None = None,
|
|
190
|
+
tag: str | None = None,
|
|
191
|
+
mood: str | None = None,
|
|
192
|
+
color: str | None = None,
|
|
193
|
+
style: str | None = None,
|
|
194
|
+
subject: str | None = None,
|
|
195
|
+
time_of_day: str | None = None,
|
|
196
|
+
screen: str | None = None,
|
|
197
|
+
min_width: int | None = None,
|
|
198
|
+
min_height: int | None = None,
|
|
199
|
+
search: str | None = None,
|
|
200
|
+
limit: int | None = None,
|
|
201
|
+
random: bool = False,
|
|
202
|
+
) -> list[dict]:
|
|
203
|
+
"""Query wallpapers with filters."""
|
|
204
|
+
if not self.conn:
|
|
205
|
+
return []
|
|
206
|
+
|
|
207
|
+
conditions = []
|
|
208
|
+
params: list = []
|
|
209
|
+
|
|
210
|
+
if description:
|
|
211
|
+
conditions.append("w.description LIKE ?")
|
|
212
|
+
params.append(f"%{description}%")
|
|
213
|
+
|
|
214
|
+
if style:
|
|
215
|
+
conditions.append("w.style LIKE ?")
|
|
216
|
+
params.append(f"%{style}%")
|
|
217
|
+
|
|
218
|
+
if subject:
|
|
219
|
+
conditions.append("w.subject LIKE ?")
|
|
220
|
+
params.append(f"%{subject}%")
|
|
221
|
+
|
|
222
|
+
if time_of_day:
|
|
223
|
+
conditions.append("w.time_of_day LIKE ?")
|
|
224
|
+
params.append(f"%{time_of_day}%")
|
|
225
|
+
|
|
226
|
+
if screen:
|
|
227
|
+
conditions.append("w.recommended_screen LIKE ?")
|
|
228
|
+
params.append(f"%{screen}%")
|
|
229
|
+
|
|
230
|
+
if min_width:
|
|
231
|
+
conditions.append("w.width >= ?")
|
|
232
|
+
params.append(min_width)
|
|
233
|
+
|
|
234
|
+
if min_height:
|
|
235
|
+
conditions.append("w.height >= ?")
|
|
236
|
+
params.append(min_height)
|
|
237
|
+
|
|
238
|
+
if search:
|
|
239
|
+
conditions.append(
|
|
240
|
+
"(w.description LIKE ? OR w.scene LIKE ? OR w.style LIKE ? OR w.subject LIKE ?)"
|
|
241
|
+
)
|
|
242
|
+
params.extend([f"%{search}%"] * 4)
|
|
243
|
+
|
|
244
|
+
if tag:
|
|
245
|
+
conditions.append(
|
|
246
|
+
"EXISTS (SELECT 1 FROM tags t WHERE t.wallpaper_id = w.id AND t.tag LIKE ?)"
|
|
247
|
+
)
|
|
248
|
+
params.append(f"%{tag}%")
|
|
249
|
+
|
|
250
|
+
if mood:
|
|
251
|
+
conditions.append(
|
|
252
|
+
"EXISTS (SELECT 1 FROM moods m WHERE m.wallpaper_id = w.id AND m.mood LIKE ?)"
|
|
253
|
+
)
|
|
254
|
+
params.append(f"%{mood}%")
|
|
255
|
+
|
|
256
|
+
if color:
|
|
257
|
+
conditions.append(
|
|
258
|
+
"EXISTS (SELECT 1 FROM colors c WHERE c.wallpaper_id = w.id AND c.color LIKE ?)"
|
|
259
|
+
)
|
|
260
|
+
params.append(f"%{color}%")
|
|
261
|
+
|
|
262
|
+
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
|
263
|
+
order_clause = "ORDER BY RANDOM()" if random else "ORDER BY w.filename"
|
|
264
|
+
limit_clause = f"LIMIT {limit}" if limit else ""
|
|
265
|
+
|
|
266
|
+
query = f"""
|
|
267
|
+
SELECT DISTINCT w.path, w.filename, w.description, w.style, w.subject,
|
|
268
|
+
w.time_of_day, w.recommended_screen, w.width, w.height
|
|
269
|
+
FROM wallpapers w
|
|
270
|
+
WHERE {where_clause}
|
|
271
|
+
{order_clause}
|
|
272
|
+
{limit_clause}
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
cursor = self.conn.execute(query, params)
|
|
276
|
+
return [dict(row) for row in cursor.fetchall()]
|
|
277
|
+
|
|
278
|
+
def stats(self) -> dict:
|
|
279
|
+
"""Get database statistics."""
|
|
280
|
+
if not self.conn:
|
|
281
|
+
return {}
|
|
282
|
+
|
|
283
|
+
stats = {}
|
|
284
|
+
|
|
285
|
+
cursor = self.conn.execute("SELECT COUNT(*) as count FROM wallpapers")
|
|
286
|
+
stats["total_wallpapers"] = cursor.fetchone()["count"]
|
|
287
|
+
|
|
288
|
+
cursor = self.conn.execute(
|
|
289
|
+
"SELECT COUNT(*) as count FROM wallpapers WHERE description IS NOT NULL"
|
|
290
|
+
)
|
|
291
|
+
stats["with_metadata"] = cursor.fetchone()["count"]
|
|
292
|
+
|
|
293
|
+
cursor = self.conn.execute(
|
|
294
|
+
"SELECT recommended_screen, COUNT(*) as count FROM wallpapers "
|
|
295
|
+
"WHERE recommended_screen IS NOT NULL GROUP BY recommended_screen ORDER BY count DESC"
|
|
296
|
+
)
|
|
297
|
+
stats["by_screen"] = {row["recommended_screen"]: row["count"] for row in cursor.fetchall()}
|
|
298
|
+
|
|
299
|
+
cursor = self.conn.execute(
|
|
300
|
+
"SELECT style, COUNT(*) as count FROM wallpapers "
|
|
301
|
+
"WHERE style IS NOT NULL GROUP BY style ORDER BY count DESC"
|
|
302
|
+
)
|
|
303
|
+
stats["by_style"] = {row["style"]: row["count"] for row in cursor.fetchall()}
|
|
304
|
+
|
|
305
|
+
cursor = self.conn.execute(
|
|
306
|
+
"SELECT subject, COUNT(*) as count FROM wallpapers "
|
|
307
|
+
"WHERE subject IS NOT NULL GROUP BY subject ORDER BY count DESC"
|
|
308
|
+
)
|
|
309
|
+
stats["by_subject"] = {row["subject"]: row["count"] for row in cursor.fetchall()}
|
|
310
|
+
|
|
311
|
+
cursor = self.conn.execute(
|
|
312
|
+
"SELECT tag, COUNT(*) as count FROM tags GROUP BY tag ORDER BY count DESC LIMIT 20"
|
|
313
|
+
)
|
|
314
|
+
stats["top_tags"] = {row["tag"]: row["count"] for row in cursor.fetchall()}
|
|
315
|
+
|
|
316
|
+
cursor = self.conn.execute(
|
|
317
|
+
"SELECT mood, COUNT(*) as count FROM moods GROUP BY mood ORDER BY count DESC LIMIT 10"
|
|
318
|
+
)
|
|
319
|
+
stats["top_moods"] = {row["mood"]: row["count"] for row in cursor.fetchall()}
|
|
320
|
+
|
|
321
|
+
cursor = self.conn.execute(
|
|
322
|
+
"SELECT color, COUNT(*) as count FROM colors "
|
|
323
|
+
"GROUP BY color ORDER BY count DESC LIMIT 10"
|
|
324
|
+
)
|
|
325
|
+
stats["top_colors"] = {row["color"]: row["count"] for row in cursor.fetchall()}
|
|
326
|
+
|
|
327
|
+
return stats
|
|
328
|
+
|
|
329
|
+
def prune(self, valid_paths: set[str]) -> int:
|
|
330
|
+
"""Remove entries for files that no longer exist."""
|
|
331
|
+
if not self.conn:
|
|
332
|
+
return 0
|
|
333
|
+
|
|
334
|
+
cursor = self.conn.execute("SELECT id, path FROM wallpapers")
|
|
335
|
+
to_delete = [row["id"] for row in cursor.fetchall() if row["path"] not in valid_paths]
|
|
336
|
+
|
|
337
|
+
for wallpaper_id in to_delete:
|
|
338
|
+
self.conn.execute("DELETE FROM wallpapers WHERE id = ?", (wallpaper_id,))
|
|
339
|
+
|
|
340
|
+
self.conn.commit()
|
|
341
|
+
return len(to_delete)
|
schenesort/tui/app.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Wallpaper Browser TUI application."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from textual.app import App, ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.containers import Horizontal
|
|
8
|
+
from textual.widgets import Footer, Header, Static
|
|
9
|
+
|
|
10
|
+
from schenesort.tui.widgets.image_preview import ImagePreview
|
|
11
|
+
from schenesort.tui.widgets.metadata_panel import MetadataPanel
|
|
12
|
+
from schenesort.xmp import read_xmp
|
|
13
|
+
|
|
14
|
+
VALID_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff", ".tif"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WallpaperBrowser(App):
|
|
18
|
+
"""A TUI application for browsing wallpapers with metadata display."""
|
|
19
|
+
|
|
20
|
+
TITLE = "Schenesort - Wallpaper Browser"
|
|
21
|
+
|
|
22
|
+
CSS = """
|
|
23
|
+
Screen {
|
|
24
|
+
layout: vertical;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#main-container {
|
|
28
|
+
width: 100%;
|
|
29
|
+
height: 1fr;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#image-panel {
|
|
33
|
+
width: 60%;
|
|
34
|
+
height: 100%;
|
|
35
|
+
border: solid $primary;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#metadata-panel {
|
|
39
|
+
width: 40%;
|
|
40
|
+
height: 100%;
|
|
41
|
+
border: solid $secondary;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#status-bar {
|
|
45
|
+
dock: bottom;
|
|
46
|
+
height: 1;
|
|
47
|
+
background: $surface;
|
|
48
|
+
color: $text;
|
|
49
|
+
padding: 0 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#status-left {
|
|
53
|
+
width: 1fr;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#status-right {
|
|
57
|
+
width: auto;
|
|
58
|
+
}
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
BINDINGS = [
|
|
62
|
+
Binding("q", "quit", "Quit", show=True),
|
|
63
|
+
Binding("j", "next_image", "Next", show=True),
|
|
64
|
+
Binding("k", "prev_image", "Previous", show=True),
|
|
65
|
+
Binding("g", "first_image", "First", show=True),
|
|
66
|
+
Binding("G", "last_image", "Last", show=True, key_display="shift+g"),
|
|
67
|
+
Binding("+", "zoom_in", "Zoom In", show=True),
|
|
68
|
+
Binding("-", "zoom_out", "Zoom Out", show=True),
|
|
69
|
+
Binding("0", "reset_zoom", "Reset Zoom", show=False),
|
|
70
|
+
Binding("down", "next_image", "Next", show=False),
|
|
71
|
+
Binding("up", "prev_image", "Previous", show=False),
|
|
72
|
+
Binding("home", "first_image", "First", show=False),
|
|
73
|
+
Binding("end", "last_image", "Last", show=False),
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
def __init__(self, path: Path, recursive: bool = False) -> None:
|
|
77
|
+
super().__init__()
|
|
78
|
+
self._base_path = path
|
|
79
|
+
self._recursive = recursive
|
|
80
|
+
self._images: list[Path] = []
|
|
81
|
+
self._current_index: int = 0
|
|
82
|
+
|
|
83
|
+
def compose(self) -> ComposeResult:
|
|
84
|
+
yield Header()
|
|
85
|
+
with Horizontal(id="main-container"):
|
|
86
|
+
yield ImagePreview(id="image-panel")
|
|
87
|
+
yield MetadataPanel(id="metadata-panel")
|
|
88
|
+
with Horizontal(id="status-bar"):
|
|
89
|
+
yield Static("", id="status-left")
|
|
90
|
+
yield Static("", id="status-right")
|
|
91
|
+
yield Footer()
|
|
92
|
+
|
|
93
|
+
def on_mount(self) -> None:
|
|
94
|
+
"""Initialize the browser when mounted."""
|
|
95
|
+
self._load_images()
|
|
96
|
+
if self._images:
|
|
97
|
+
self._show_current_image()
|
|
98
|
+
else:
|
|
99
|
+
self._update_status("No images found", "")
|
|
100
|
+
|
|
101
|
+
def _load_images(self) -> None:
|
|
102
|
+
"""Load list of images from the specified path."""
|
|
103
|
+
if self._base_path.is_file():
|
|
104
|
+
if self._base_path.suffix.lower() in VALID_IMAGE_EXTENSIONS:
|
|
105
|
+
self._images = [self._base_path]
|
|
106
|
+
else:
|
|
107
|
+
pattern = "**/*" if self._recursive else "*"
|
|
108
|
+
self._images = sorted(
|
|
109
|
+
f
|
|
110
|
+
for f in self._base_path.glob(pattern)
|
|
111
|
+
if f.is_file() and f.suffix.lower() in VALID_IMAGE_EXTENSIONS
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def _show_current_image(self) -> None:
|
|
115
|
+
"""Display the current image and its metadata."""
|
|
116
|
+
if not self._images:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
current_image = self._images[self._current_index]
|
|
120
|
+
|
|
121
|
+
# Load image
|
|
122
|
+
preview = self.query_one("#image-panel", ImagePreview)
|
|
123
|
+
preview.load_image(current_image)
|
|
124
|
+
|
|
125
|
+
# Load metadata
|
|
126
|
+
metadata = read_xmp(current_image)
|
|
127
|
+
panel = self.query_one("#metadata-panel", MetadataPanel)
|
|
128
|
+
panel.update_metadata(metadata, current_image.name)
|
|
129
|
+
|
|
130
|
+
# Update status
|
|
131
|
+
self._update_status(
|
|
132
|
+
str(current_image.relative_to(self._base_path))
|
|
133
|
+
if current_image.is_relative_to(self._base_path)
|
|
134
|
+
else current_image.name,
|
|
135
|
+
f"{self._current_index + 1}/{len(self._images)}",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def _update_status(self, left: str, right: str) -> None:
|
|
139
|
+
"""Update the status bar."""
|
|
140
|
+
self.query_one("#status-left", Static).update(left)
|
|
141
|
+
self.query_one("#status-right", Static).update(right)
|
|
142
|
+
|
|
143
|
+
def action_next_image(self) -> None:
|
|
144
|
+
"""Navigate to the next image."""
|
|
145
|
+
if self._images and self._current_index < len(self._images) - 1:
|
|
146
|
+
self._current_index += 1
|
|
147
|
+
self._show_current_image()
|
|
148
|
+
|
|
149
|
+
def action_prev_image(self) -> None:
|
|
150
|
+
"""Navigate to the previous image."""
|
|
151
|
+
if self._images and self._current_index > 0:
|
|
152
|
+
self._current_index -= 1
|
|
153
|
+
self._show_current_image()
|
|
154
|
+
|
|
155
|
+
def action_first_image(self) -> None:
|
|
156
|
+
"""Navigate to the first image."""
|
|
157
|
+
if self._images:
|
|
158
|
+
self._current_index = 0
|
|
159
|
+
self._show_current_image()
|
|
160
|
+
|
|
161
|
+
def action_last_image(self) -> None:
|
|
162
|
+
"""Navigate to the last image."""
|
|
163
|
+
if self._images:
|
|
164
|
+
self._current_index = len(self._images) - 1
|
|
165
|
+
self._show_current_image()
|
|
166
|
+
|
|
167
|
+
def action_zoom_in(self) -> None:
|
|
168
|
+
"""Zoom in on the current image."""
|
|
169
|
+
preview = self.query_one("#image-panel", ImagePreview)
|
|
170
|
+
preview.zoom_in()
|
|
171
|
+
|
|
172
|
+
def action_zoom_out(self) -> None:
|
|
173
|
+
"""Zoom out of the current image."""
|
|
174
|
+
preview = self.query_one("#image-panel", ImagePreview)
|
|
175
|
+
preview.zoom_out()
|
|
176
|
+
|
|
177
|
+
def action_reset_zoom(self) -> None:
|
|
178
|
+
"""Reset the zoom level."""
|
|
179
|
+
preview = self.query_one("#image-panel", ImagePreview)
|
|
180
|
+
preview.reset_zoom()
|