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/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)
@@ -0,0 +1,5 @@
1
+ """Schenesort TUI - Terminal UI for browsing wallpapers."""
2
+
3
+ from schenesort.tui.app import WallpaperBrowser
4
+
5
+ __all__ = ["WallpaperBrowser"]
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()
@@ -0,0 +1,6 @@
1
+ """Schenesort TUI widgets."""
2
+
3
+ from schenesort.tui.widgets.image_preview import ImagePreview
4
+ from schenesort.tui.widgets.metadata_panel import MetadataPanel
5
+
6
+ __all__ = ["ImagePreview", "MetadataPanel"]