11import sqlite3
2+ from typing import Any , List , Optional , Tuple , TypedDict
3+
24import bcrypt
35from app .config .settings import DATABASE_PATH
46from app .database .connection import get_db_connection
57
68
9+ class AlbumRow (TypedDict ):
10+ """A row of the albums table, as the read helpers below return it."""
11+
12+ album_id : str
13+ album_name : str
14+ description : Optional [str ]
15+ is_locked : bool
16+ password_hash : Optional [str ]
17+ cover_image_path : Optional [str ]
18+ created_at : Optional [str ]
19+ updated_at : Optional [str ]
20+
21+
22+ def _connect () -> sqlite3 .Connection :
23+ conn = sqlite3 .connect (DATABASE_PATH )
24+ # Ensure ON DELETE CASCADE and other FKs are enforced
25+ conn .execute ("PRAGMA foreign_keys = ON" )
26+ return conn
27+
28+
29+ # Named once so the SELECTs and the mapper below cannot drift apart.
30+ _ALBUM_COLUMNS = (
31+ "album_id, album_name, description, is_locked, "
32+ "password_hash, cover_image_path, created_at, updated_at"
33+ )
34+
35+ # Built once from the column list rather than interpolated at each call site.
36+ _SELECT_ALL_ALBUMS = f"SELECT { _ALBUM_COLUMNS } FROM albums ORDER BY rowid"
37+ _SELECT_ALBUM_BY_NAME = f"SELECT { _ALBUM_COLUMNS } FROM albums WHERE album_name = ?"
38+ _SELECT_ALBUM_BY_ID = f"SELECT { _ALBUM_COLUMNS } FROM albums WHERE album_id = ?"
39+
40+
41+ def _to_album_row (row : Tuple [Any , ...]) -> AlbumRow :
42+ """Map a SELECT of _ALBUM_COLUMNS onto a named record."""
43+ return AlbumRow (
44+ album_id = row [0 ],
45+ album_name = row [1 ],
46+ description = row [2 ],
47+ is_locked = bool (row [3 ]),
48+ password_hash = row [4 ],
49+ cover_image_path = row [5 ],
50+ created_at = row [6 ],
51+ updated_at = row [7 ],
52+ )
53+
54+
755def db_create_albums_table () -> None :
856 conn = None
957 try :
10- conn = sqlite3 . connect ( DATABASE_PATH )
58+ conn = _connect ( )
1159 cursor = conn .cursor ()
1260 cursor .execute (
1361 """
@@ -17,18 +65,29 @@ def db_create_albums_table() -> None:
1765 description TEXT,
1866 is_locked BOOLEAN DEFAULT 0,
1967 password_hash TEXT,
20- cover_image_path TEXT
68+ cover_image_path TEXT,
69+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
70+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
2171 )
2272 """
2373 )
2474 # Shipped databases predate the is_hidden -> is_locked rename and the
25- # cover_image_path column, and CREATE IF NOT EXISTS won't add either.
75+ # cover_image_path and created_at columns, and CREATE IF NOT EXISTS
76+ # won't add any of them.
2677 cursor .execute ("PRAGMA table_info(albums)" )
2778 columns = {row [1 ] for row in cursor .fetchall ()}
2879 if "is_locked" not in columns and "is_hidden" in columns :
2980 cursor .execute ("ALTER TABLE albums RENAME COLUMN is_hidden TO is_locked" )
3081 if "cover_image_path" not in columns :
3182 cursor .execute ("ALTER TABLE albums ADD COLUMN cover_image_path TEXT" )
83+ if "created_at" not in columns :
84+ # No default: SQLite rejects a non-constant one on ALTER TABLE, and
85+ # stamping every existing album with the upgrade time would be a
86+ # date that never happened. They stay NULL and read as oldest,
87+ # which their insertion order already reflects.
88+ cursor .execute ("ALTER TABLE albums ADD COLUMN created_at DATETIME" )
89+ if "updated_at" not in columns :
90+ cursor .execute ("ALTER TABLE albums ADD COLUMN updated_at DATETIME" )
3291 conn .commit ()
3392 finally :
3493 if conn is not None :
@@ -38,7 +97,7 @@ def db_create_albums_table() -> None:
3897def db_create_album_images_table () -> None :
3998 conn = None
4099 try :
41- conn = sqlite3 . connect ( DATABASE_PATH )
100+ conn = _connect ( )
42101 cursor = conn .cursor ()
43102 cursor .execute (
44103 """
@@ -63,44 +122,50 @@ def db_create_album_images_table() -> None:
63122 conn .close ()
64123
65124
66- def db_get_all_albums ():
125+ def _touch_album (cursor : sqlite3 .Cursor , album_id : str ) -> None :
126+ """
127+ Mark an album as changed just now.
128+
129+ Adding or removing photos counts: to a user, that is the album changing,
130+ not just its name or its lock.
131+ """
132+ cursor .execute (
133+ "UPDATE albums SET updated_at = CURRENT_TIMESTAMP WHERE album_id = ?" ,
134+ (album_id ,),
135+ )
136+
137+
138+ def db_get_all_albums () -> List [AlbumRow ]:
67139 """Get all albums (both locked and unlocked)."""
68- conn = sqlite3 . connect ( DATABASE_PATH )
140+ conn = _connect ( )
69141 cursor = conn .cursor ()
70142 try :
71- cursor .execute (
72- "SELECT album_id, album_name, description, is_locked, password_hash, cover_image_path FROM albums"
73- )
74- albums = cursor .fetchall ()
75- return albums
143+ # Insertion order, so albums predating created_at keep the order they
144+ # were made in rather than an arbitrary one.
145+ cursor .execute (_SELECT_ALL_ALBUMS )
146+ return [_to_album_row (row ) for row in cursor .fetchall ()]
76147 finally :
77148 conn .close ()
78149
79150
80- def db_get_album_by_name (name : str ):
81- conn = sqlite3 . connect ( DATABASE_PATH )
151+ def db_get_album_by_name (name : str ) -> Optional [ AlbumRow ] :
152+ conn = _connect ( )
82153 cursor = conn .cursor ()
83154 try :
84- cursor .execute (
85- "SELECT album_id, album_name, description, is_locked, password_hash, cover_image_path FROM albums WHERE album_name = ?" ,
86- (name ,),
87- )
155+ cursor .execute (_SELECT_ALBUM_BY_NAME , (name ,))
88156 album = cursor .fetchone ()
89- return album if album else None
157+ return _to_album_row ( album ) if album else None
90158 finally :
91159 conn .close ()
92160
93161
94- def db_get_album (album_id : str ):
95- conn = sqlite3 . connect ( DATABASE_PATH )
162+ def db_get_album (album_id : str ) -> Optional [ AlbumRow ] :
163+ conn = _connect ( )
96164 cursor = conn .cursor ()
97165 try :
98- cursor .execute (
99- "SELECT album_id, album_name, description, is_locked, password_hash, cover_image_path FROM albums WHERE album_id = ?" ,
100- (album_id ,),
101- )
166+ cursor .execute (_SELECT_ALBUM_BY_ID , (album_id ,))
102167 album = cursor .fetchone ()
103- return album if album else None
168+ return _to_album_row ( album ) if album else None
104169 finally :
105170 conn .close ()
106171
@@ -110,20 +175,25 @@ def db_insert_album(
110175 album_name : str ,
111176 description : str = "" ,
112177 is_locked : bool = False ,
113- password : str = None ,
178+ password : Optional [ str ] = None ,
114179):
115- conn = sqlite3 . connect ( DATABASE_PATH )
180+ conn = _connect ( )
116181 cursor = conn .cursor ()
117182 try :
118183 password_hash = None
119184 if password :
120185 password_hash = bcrypt .hashpw (
121186 password .encode ("utf-8" ), bcrypt .gensalt ()
122187 ).decode ("utf-8" )
188+ # created_at is set here rather than left to the column default: a
189+ # database migrated with ALTER TABLE has no default to fall back on.
123190 cursor .execute (
124191 """
125- INSERT INTO albums (album_id, album_name, description, is_locked, password_hash)
126- VALUES (?, ?, ?, ?, ?)
192+ INSERT INTO albums (
193+ album_id, album_name, description, is_locked,
194+ password_hash, created_at, updated_at
195+ )
196+ VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
127197 """ ,
128198 (album_id , album_name , description , int (is_locked ), password_hash ),
129199 )
@@ -132,14 +202,45 @@ def db_insert_album(
132202 conn .close ()
133203
134204
205+ def db_create_album_with_images (
206+ album_id : str , album_name : str , description : str , image_ids : list [str ]
207+ ) -> int :
208+ """
209+ Create an album and link its images in a single transaction.
210+
211+ Both halves commit together, so a failed link never strands an empty album.
212+ Takes image ids rather than the id of whatever they came from, so the
213+ caller owns that choice. Returns the number of images actually linked.
214+ """
215+ with get_db_connection () as conn :
216+ cursor = conn .cursor ()
217+ cursor .execute (
218+ """
219+ INSERT INTO albums (
220+ album_id, album_name, description, is_locked,
221+ password_hash, created_at, updated_at
222+ )
223+ VALUES (?, ?, ?, 0, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
224+ """ ,
225+ (album_id , album_name , description ),
226+ )
227+ # Foreign keys are on for this connection, so an image id that no
228+ # longer exists rolls the album back with it rather than half-writing.
229+ cursor .executemany (
230+ "INSERT OR IGNORE INTO album_images (album_id, image_id) VALUES (?, ?)" ,
231+ [(album_id , image_id ) for image_id in image_ids ],
232+ )
233+ return cursor .rowcount
234+
235+
135236def db_update_album (
136237 album_id : str ,
137238 album_name : str ,
138239 description : str ,
139240 is_locked : bool ,
140- password : str = None ,
241+ password : Optional [ str ] = None ,
141242):
142- conn = sqlite3 . connect ( DATABASE_PATH )
243+ conn = _connect ( )
143244 cursor = conn .cursor ()
144245 try :
145246 if password is not None :
@@ -150,7 +251,8 @@ def db_update_album(
150251 cursor .execute (
151252 """
152253 UPDATE albums
153- SET album_name = ?, description = ?, is_locked = ?, password_hash = ?
254+ SET album_name = ?, description = ?, is_locked = ?, password_hash = ?,
255+ updated_at = CURRENT_TIMESTAMP
154256 WHERE album_id = ?
155257 """ ,
156258 (album_name , description , int (is_locked ), password_hash , album_id ),
@@ -160,7 +262,8 @@ def db_update_album(
160262 cursor .execute (
161263 """
162264 UPDATE albums
163- SET album_name = ?, description = ?, is_locked = ?
265+ SET album_name = ?, description = ?, is_locked = ?,
266+ updated_at = CURRENT_TIMESTAMP
164267 WHERE album_id = ?
165268 """ ,
166269 (album_name , description , int (is_locked ), album_id ),
@@ -178,7 +281,7 @@ def db_delete_album(album_id: str):
178281
179282def db_get_album_cover_path (album_id : str ) -> str | None :
180283 """Path of the album's cover: its first image, by insertion order."""
181- conn = sqlite3 . connect ( DATABASE_PATH )
284+ conn = _connect ( )
182285 cursor = conn .cursor ()
183286 try :
184287 cursor .execute (
@@ -199,7 +302,7 @@ def db_get_album_cover_path(album_id: str) -> str | None:
199302
200303
201304def db_get_album_images (album_id : str ):
202- conn = sqlite3 . connect ( DATABASE_PATH )
305+ conn = _connect ( )
203306 cursor = conn .cursor ()
204307 try :
205308 cursor .execute (
@@ -247,6 +350,11 @@ def db_add_images_to_album(album_id: str, image_ids: list[str]):
247350 "INSERT OR IGNORE INTO album_images (album_id, image_id) VALUES (?, ?)" ,
248351 [(album_id , img_id ) for img_id in valid_images ],
249352 )
353+ # Every id may already be in the album, in which case OR IGNORE writes
354+ # nothing and the album has not actually changed. Read before touching:
355+ # the touch overwrites rowcount.
356+ if cursor .rowcount :
357+ _touch_album (cursor , album_id )
250358 conn .commit ()
251359
252360
@@ -265,25 +373,29 @@ def db_remove_image_from_album(album_id: str, image_id: str):
265373 "DELETE FROM album_images WHERE album_id = ? AND image_id = ?" ,
266374 (album_id , image_id ),
267375 )
376+ _touch_album (cursor , album_id )
268377 else :
269378 raise ValueError ("Image not found in the specified album" )
270379
271380
272381def db_remove_images_from_album (album_id : str , image_ids : list [str ]):
273- conn = sqlite3 . connect ( DATABASE_PATH )
382+ conn = _connect ( )
274383 cursor = conn .cursor ()
275384 try :
276385 cursor .executemany (
277386 "DELETE FROM album_images WHERE album_id = ? AND image_id = ?" ,
278387 [(album_id , img_id ) for img_id in image_ids ],
279388 )
389+ # Same as the insert: ids that were not in the album delete nothing.
390+ if cursor .rowcount :
391+ _touch_album (cursor , album_id )
280392 conn .commit ()
281393 finally :
282394 conn .close ()
283395
284396
285397def verify_album_password (album_id : str , password : str ) -> bool :
286- conn = sqlite3 . connect ( DATABASE_PATH )
398+ conn = _connect ( )
287399 cursor = conn .cursor ()
288400 try :
289401 cursor .execute (
0 commit comments