@hearthlog/module-gallery 0.0.0-stage → 1.0.1
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.
- package/LICENSE +235 -0
- package/README.md +11 -2
- package/hearthlog.module.json +72 -0
- package/package.json +42 -3
- package/public/app/App.svelte +448 -0
- package/public/app/ambient.d.ts +5 -0
- package/public/app/client.ts +7 -0
- package/public/app/types.ts +53 -0
- package/public/client-assets.ts +17 -0
- package/public/contract.ts +211 -0
- package/public/index.ts +237 -0
- package/public/tsconfig.json +21 -0
- package/src/components/gallery/AlbumCard.svelte +41 -0
- package/src/components/gallery/AlbumSection.svelte +323 -0
- package/src/components/gallery/GalleryMonthBlock.svelte +159 -0
- package/src/components/gallery/GalleryTimeline.svelte +294 -0
- package/src/components/gallery/PhotoThumb.svelte +26 -0
- package/src/components/gallery/PhotoTile.svelte +163 -0
- package/src/components/gallery/PhotoViewer.svelte +219 -0
- package/src/components/gallery/PublicationManager.svelte +386 -0
- package/src/components/gallery/PublicationPreviewPhoto.svelte +33 -0
- package/src/components/gallery/gallery-blocks.svelte.ts +30 -0
- package/src/components/gallery/view/GallerySpine.svelte +239 -0
- package/src/components/gallery/view/GalleryTile.svelte +157 -0
- package/src/components/gallery/view/JustifiedRows.svelte +54 -0
- package/src/components/gallery/view/MonthHeading.svelte +53 -0
- package/src/components/gallery/view/ViewerFrame.svelte +362 -0
- package/src/data/definitions.ts +93 -0
- package/src/data/gallery.ts +413 -0
- package/src/data/repositories.ts +8 -0
- package/src/gallery/backup-archive.ts +195 -0
- package/src/gallery/backup.ts +140 -0
- package/src/gallery/exif.ts +240 -0
- package/src/gallery/file-store.ts +206 -0
- package/src/gallery/format.ts +101 -0
- package/src/gallery/interaction.ts +6 -0
- package/src/gallery/layout.ts +67 -0
- package/src/gallery/public-details.ts +73 -0
- package/src/gallery/publication-api.ts +68 -0
- package/src/gallery/selection.ts +25 -0
- package/src/gallery/timeline.ts +77 -0
- package/src/pages/gallery.svelte +737 -0
- package/src/pages/gallery.ts +20 -0
- package/src/server/files.ts +229 -0
- package/src/server/index.ts +18 -0
- package/src/server/ledger.ts +42 -0
- package/src/server/publications.ts +576 -0
- package/src/startup.ts +2 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
import type { GalleryPhoto, GalleryAlbum, GalleryAlbumPhoto, GalleryPublication } from '@hearthlog/sdk/data';
|
|
3
|
+
import type { ReadModelSession } from '@hearthlog/sdk/data';
|
|
4
|
+
import { withTimeout } from '@hearthlog/sdk/data';
|
|
5
|
+
import { shiftMonthKey } from '@hearthlog/sdk/axis';
|
|
6
|
+
import { MAX_TILE_RATIO, MIN_TILE_RATIO } from '../gallery/layout.js';
|
|
7
|
+
import { GALLERY_BLOCK_SIZE, galleryBlockKey, galleryBlockMonth, galleryBlockPart } from '../gallery/timeline.js';
|
|
8
|
+
|
|
9
|
+
const DB_TIMEOUT_MS = 5000;
|
|
10
|
+
|
|
11
|
+
export interface GalleryCursor {
|
|
12
|
+
day: string;
|
|
13
|
+
importedAt: string;
|
|
14
|
+
id: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Which photos a timeline lists. */
|
|
18
|
+
export interface GalleryFilter {
|
|
19
|
+
query?: string;
|
|
20
|
+
favoritesOnly?: boolean;
|
|
21
|
+
albumId?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One block of the timeline: a month, or a part of a big month. */
|
|
25
|
+
export interface GalleryBlockSummary {
|
|
26
|
+
key: string;
|
|
27
|
+
month: string;
|
|
28
|
+
part: number;
|
|
29
|
+
/** Blocks in this month. */
|
|
30
|
+
parts: number;
|
|
31
|
+
photos: number;
|
|
32
|
+
/** The sum of the photos' tile ratios, for estimating the block's height. */
|
|
33
|
+
ratio_sum: number;
|
|
34
|
+
month_photos: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface GalleryMonthSummary {
|
|
38
|
+
month: string;
|
|
39
|
+
photos: number;
|
|
40
|
+
/** The most photos any month holds, for scaling the spine. */
|
|
41
|
+
peak: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface GallerySummary {
|
|
45
|
+
blocks: GalleryBlockSummary[];
|
|
46
|
+
months: GalleryMonthSummary[];
|
|
47
|
+
count: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface GalleryAlbumChip {
|
|
51
|
+
id: string;
|
|
52
|
+
title: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface GalleryAlbumSummary extends GalleryAlbum {
|
|
56
|
+
photo_count: number;
|
|
57
|
+
effective_cover_photo_id: string;
|
|
58
|
+
cover_file_version_id: string | null;
|
|
59
|
+
cover_filename: string | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type TimelinePlace = Pick<GalleryPhoto, 'id' | 'timeline_day' | 'imported_at'>;
|
|
63
|
+
|
|
64
|
+
export function createGalleryRepository(session: ReadModelSession) {
|
|
65
|
+
function conditions(filter: GalleryFilter) {
|
|
66
|
+
const query = (filter.query ?? '').trim();
|
|
67
|
+
const favoriteFilter = filter.favoritesOnly ? sql`AND p.favorite = 1` : sql``;
|
|
68
|
+
const searchFilter = query
|
|
69
|
+
? sql`AND (instr(lower(p.filename), lower(${query})) > 0
|
|
70
|
+
OR instr(lower(p.caption), lower(${query})) > 0)`
|
|
71
|
+
: sql``;
|
|
72
|
+
const albumFilter = filter.albumId
|
|
73
|
+
? sql`AND EXISTS (
|
|
74
|
+
SELECT 1 FROM ${session.table('gallery_album_photos')} AS am
|
|
75
|
+
WHERE am.album_id = ${filter.albumId} AND am.photo_id = p.id)`
|
|
76
|
+
: sql``;
|
|
77
|
+
return sql`${favoriteFilter} ${searchFilter} ${albumFilter}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Photos in a month: a range on the timeline day keeps the index in use. */
|
|
81
|
+
function inMonth(month: string) {
|
|
82
|
+
return sql`p.timeline_day >= ${month} AND p.timeline_day < ${shiftMonthKey(month, 1)}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function before(photo: TimelinePlace) {
|
|
86
|
+
return sql`(p.timeline_day < ${photo.timeline_day}
|
|
87
|
+
OR (p.timeline_day = ${photo.timeline_day} AND p.imported_at < ${photo.imported_at})
|
|
88
|
+
OR (p.timeline_day = ${photo.timeline_day} AND p.imported_at = ${photo.imported_at} AND p.id < ${photo.id}))`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function after(photo: TimelinePlace) {
|
|
92
|
+
return sql`(p.timeline_day > ${photo.timeline_day}
|
|
93
|
+
OR (p.timeline_day = ${photo.timeline_day} AND p.imported_at > ${photo.imported_at})
|
|
94
|
+
OR (p.timeline_day = ${photo.timeline_day} AND p.imported_at = ${photo.imported_at} AND p.id > ${photo.id}))`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function count(filter: GalleryFilter = {}): Promise<number> {
|
|
98
|
+
const result = await withTimeout(
|
|
99
|
+
session.execute(sql<{ count: number }>`
|
|
100
|
+
SELECT COUNT(*) AS count
|
|
101
|
+
FROM ${session.table('gallery_photos')} AS p
|
|
102
|
+
WHERE 1 = 1 ${conditions(filter)}
|
|
103
|
+
`),
|
|
104
|
+
DB_TIMEOUT_MS,
|
|
105
|
+
'Gallery count query timed out',
|
|
106
|
+
);
|
|
107
|
+
return Number(result.rows[0]?.count ?? 0);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const ratio = sql`CASE WHEN p.width > 0 AND p.height > 0
|
|
111
|
+
THEN MIN(MAX(p.width / p.height, ${MIN_TILE_RATIO}), ${MAX_TILE_RATIO}) ELSE 1.0 END`;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
/**
|
|
115
|
+
* The timeline's blocks, its months for the spine, and its photo count.
|
|
116
|
+
* Private timelines read newest first; public publication reads keep
|
|
117
|
+
* their oldest-first story order.
|
|
118
|
+
*/
|
|
119
|
+
async summary(filter: GalleryFilter = {}): Promise<GallerySummary> {
|
|
120
|
+
const blocks = await withTimeout(session.execute(sql<GalleryBlockSummary>`
|
|
121
|
+
WITH ranked AS (
|
|
122
|
+
SELECT substr(p.timeline_day, 1, 7) AS month, ${ratio} AS ratio,
|
|
123
|
+
(ROW_NUMBER() OVER (
|
|
124
|
+
PARTITION BY substr(p.timeline_day, 1, 7)
|
|
125
|
+
ORDER BY p.timeline_day DESC, p.imported_at DESC, p.id DESC
|
|
126
|
+
) - 1) / ${sql.lit(GALLERY_BLOCK_SIZE)} AS part
|
|
127
|
+
FROM ${session.table('gallery_photos')} AS p
|
|
128
|
+
WHERE 1 = 1 ${conditions(filter)}
|
|
129
|
+
), parts AS (
|
|
130
|
+
SELECT month, part, COUNT(*) AS photos, SUM(ratio) AS ratio_sum
|
|
131
|
+
FROM ranked GROUP BY month, part
|
|
132
|
+
)
|
|
133
|
+
SELECT month || '/' || printf('%03d', part) AS key, month, part, photos, ratio_sum,
|
|
134
|
+
COUNT(*) OVER (PARTITION BY month) AS parts,
|
|
135
|
+
SUM(photos) OVER (PARTITION BY month) AS month_photos
|
|
136
|
+
FROM parts
|
|
137
|
+
ORDER BY month DESC, part
|
|
138
|
+
`), DB_TIMEOUT_MS, 'Gallery summary query timed out');
|
|
139
|
+
const months = await withTimeout(session.execute(sql<GalleryMonthSummary>`
|
|
140
|
+
SELECT substr(p.timeline_day, 1, 7) AS month, COUNT(*) AS photos, MAX(COUNT(*)) OVER () AS peak
|
|
141
|
+
FROM ${session.table('gallery_photos')} AS p
|
|
142
|
+
WHERE 1 = 1 ${conditions(filter)}
|
|
143
|
+
GROUP BY month
|
|
144
|
+
ORDER BY month DESC
|
|
145
|
+
`), DB_TIMEOUT_MS, 'Gallery months query timed out');
|
|
146
|
+
return {
|
|
147
|
+
blocks: blocks.rows.map((block) => ({
|
|
148
|
+
...block, part: Number(block.part), parts: Number(block.parts), photos: Number(block.photos),
|
|
149
|
+
ratio_sum: Number(block.ratio_sum), month_photos: Number(block.month_photos),
|
|
150
|
+
})),
|
|
151
|
+
months: months.rows.map((month) => ({ month: month.month, photos: Number(month.photos), peak: Number(month.peak) })),
|
|
152
|
+
count: await count(filter),
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
count,
|
|
157
|
+
|
|
158
|
+
/** One block's photos, newest first. */
|
|
159
|
+
async blockPhotos(key: string, filter: GalleryFilter = {}): Promise<GalleryPhoto[]> {
|
|
160
|
+
const result = await withTimeout(session.execute(sql<GalleryPhoto>`
|
|
161
|
+
SELECT p.* FROM ${session.table('gallery_photos')} AS p
|
|
162
|
+
WHERE ${inMonth(galleryBlockMonth(key))} ${conditions(filter)}
|
|
163
|
+
ORDER BY p.timeline_day DESC, p.imported_at DESC, p.id DESC
|
|
164
|
+
LIMIT ${GALLERY_BLOCK_SIZE} OFFSET ${galleryBlockPart(key) * GALLERY_BLOCK_SIZE}
|
|
165
|
+
`), DB_TIMEOUT_MS, 'Gallery block query timed out');
|
|
166
|
+
return result.rows;
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
/** The two albums holding most of a month's photos. */
|
|
170
|
+
async monthAlbums(month: string, filter: GalleryFilter = {}): Promise<GalleryAlbumChip[]> {
|
|
171
|
+
const result = await withTimeout(session.execute(sql<GalleryAlbumChip>`
|
|
172
|
+
SELECT a.id, a.title
|
|
173
|
+
FROM ${session.table('gallery_albums')} AS a
|
|
174
|
+
JOIN (
|
|
175
|
+
SELECT m.album_id, COUNT(DISTINCT p.id) AS photos
|
|
176
|
+
FROM ${session.table('gallery_photos')} AS p
|
|
177
|
+
JOIN ${session.table('gallery_album_photos')} AS m ON m.photo_id = p.id
|
|
178
|
+
WHERE ${inMonth(month)} ${conditions(filter)}
|
|
179
|
+
GROUP BY m.album_id
|
|
180
|
+
) AS used ON used.album_id = a.id
|
|
181
|
+
ORDER BY used.photos DESC, a.title COLLATE NOCASE, a.id
|
|
182
|
+
LIMIT 2
|
|
183
|
+
`), DB_TIMEOUT_MS, 'Gallery month albums query timed out');
|
|
184
|
+
return result.rows;
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
/** Every photo in a month, for selecting the month at once. */
|
|
188
|
+
async monthPhotoIds(month: string, filter: GalleryFilter = {}): Promise<string[]> {
|
|
189
|
+
const result = await withTimeout(session.execute(sql<{ id: string }>`
|
|
190
|
+
SELECT p.id FROM ${session.table('gallery_photos')} AS p
|
|
191
|
+
WHERE ${inMonth(month)} ${conditions(filter)}
|
|
192
|
+
ORDER BY p.timeline_day DESC, p.imported_at DESC, p.id DESC
|
|
193
|
+
`), DB_TIMEOUT_MS, 'Gallery month photos query timed out');
|
|
194
|
+
return result.rows.map((row) => row.id);
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
/** The block that lists a photo: parts count from the month's newest photo. */
|
|
198
|
+
async photoBlock(photo: TimelinePlace, filter: GalleryFilter = {}): Promise<string> {
|
|
199
|
+
const month = photo.timeline_day.slice(0, 7);
|
|
200
|
+
const result = await withTimeout(session.execute(sql<{ part: number }>`
|
|
201
|
+
SELECT COUNT(*) / ${sql.lit(GALLERY_BLOCK_SIZE)} AS part
|
|
202
|
+
FROM ${session.table('gallery_photos')} AS p
|
|
203
|
+
WHERE ${inMonth(month)} AND ${after(photo)} ${conditions(filter)}
|
|
204
|
+
`), DB_TIMEOUT_MS, 'Gallery photo block query timed out');
|
|
205
|
+
return galleryBlockKey(month, Number(result.rows[0]?.part ?? 0));
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
/** Up to `span` photos either side of one, oldest first on both sides. */
|
|
209
|
+
async strip(photo: TimelinePlace, filter: GalleryFilter = {}, span = 8): Promise<{ older: GalleryPhoto[]; newer: GalleryPhoto[] }> {
|
|
210
|
+
const older = await withTimeout(session.execute(sql<GalleryPhoto>`
|
|
211
|
+
SELECT * FROM (
|
|
212
|
+
SELECT p.* FROM ${session.table('gallery_photos')} AS p
|
|
213
|
+
WHERE ${before(photo)} ${conditions(filter)}
|
|
214
|
+
ORDER BY p.timeline_day DESC, p.imported_at DESC, p.id DESC
|
|
215
|
+
LIMIT ${span}
|
|
216
|
+
) AS older
|
|
217
|
+
ORDER BY older.timeline_day, older.imported_at, older.id
|
|
218
|
+
`), DB_TIMEOUT_MS, 'Gallery strip query timed out');
|
|
219
|
+
const newer = await withTimeout(session.execute(sql<GalleryPhoto>`
|
|
220
|
+
SELECT p.* FROM ${session.table('gallery_photos')} AS p
|
|
221
|
+
WHERE ${after(photo)} ${conditions(filter)}
|
|
222
|
+
ORDER BY p.timeline_day, p.imported_at, p.id
|
|
223
|
+
LIMIT ${span}
|
|
224
|
+
`), DB_TIMEOUT_MS, 'Gallery strip query timed out');
|
|
225
|
+
return { older: older.rows, newer: newer.rows };
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
async get(id: string): Promise<GalleryPhoto | null> {
|
|
229
|
+
const row = await withTimeout(
|
|
230
|
+
session.selectFrom('gallery_photos').selectAll().where('id', '=', id).executeTakeFirst(),
|
|
231
|
+
DB_TIMEOUT_MS,
|
|
232
|
+
'Gallery photo query timed out',
|
|
233
|
+
);
|
|
234
|
+
return row ?? null;
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
async albums(): Promise<GalleryAlbumSummary[]> {
|
|
238
|
+
const result = await withTimeout(session.execute(sql<GalleryAlbumSummary>`
|
|
239
|
+
WITH summaries AS (
|
|
240
|
+
SELECT a.*,
|
|
241
|
+
COUNT(DISTINCT p.id) AS photo_count,
|
|
242
|
+
COALESCE(
|
|
243
|
+
(SELECT c.id FROM ${session.table('gallery_photos')} AS c
|
|
244
|
+
JOIN ${session.table('gallery_album_photos')} AS cm ON cm.photo_id = c.id
|
|
245
|
+
WHERE cm.album_id = a.id AND c.id = a.cover_photo_id LIMIT 1),
|
|
246
|
+
(SELECT c.id FROM ${session.table('gallery_photos')} AS c
|
|
247
|
+
JOIN ${session.table('gallery_album_photos')} AS cm ON cm.photo_id = c.id
|
|
248
|
+
WHERE cm.album_id = a.id
|
|
249
|
+
ORDER BY cm.added_at DESC, cm.id DESC LIMIT 1),
|
|
250
|
+
''
|
|
251
|
+
) AS effective_cover_photo_id
|
|
252
|
+
FROM ${session.table('gallery_albums')} AS a
|
|
253
|
+
LEFT JOIN ${session.table('gallery_album_photos')} AS m ON m.album_id = a.id
|
|
254
|
+
LEFT JOIN ${session.table('gallery_photos')} AS p ON p.id = m.photo_id
|
|
255
|
+
GROUP BY a.id
|
|
256
|
+
)
|
|
257
|
+
SELECT summaries.*, cover.file_version_id AS cover_file_version_id,
|
|
258
|
+
cover.filename AS cover_filename
|
|
259
|
+
FROM summaries
|
|
260
|
+
LEFT JOIN ${session.table('gallery_photos')} AS cover
|
|
261
|
+
ON cover.id = summaries.effective_cover_photo_id
|
|
262
|
+
ORDER BY summaries.updated_at DESC, summaries.id DESC
|
|
263
|
+
`), DB_TIMEOUT_MS, 'Gallery albums query timed out');
|
|
264
|
+
return result.rows;
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
async album(id: string): Promise<GalleryAlbum | null> {
|
|
268
|
+
return await withTimeout(
|
|
269
|
+
session.selectFrom('gallery_albums').selectAll().where('id', '=', id).executeTakeFirst(),
|
|
270
|
+
DB_TIMEOUT_MS, 'Gallery album query timed out',
|
|
271
|
+
) ?? null;
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
async selectablePhotos(albumId: string, query = '', before?: GalleryCursor, limit = 100): Promise<Array<GalleryPhoto & { in_album: number }>> {
|
|
275
|
+
const capped = Math.max(1, Math.min(100, Math.trunc(limit)));
|
|
276
|
+
const search = query.trim();
|
|
277
|
+
const searchFilter = search
|
|
278
|
+
? sql`AND (instr(lower(p.filename), lower(${search})) > 0 OR instr(lower(p.caption), lower(${search})) > 0)`
|
|
279
|
+
: sql``;
|
|
280
|
+
const cursor = before ? sql`AND (p.timeline_day < ${before.day}
|
|
281
|
+
OR (p.timeline_day = ${before.day} AND p.imported_at < ${before.importedAt})
|
|
282
|
+
OR (p.timeline_day = ${before.day} AND p.imported_at = ${before.importedAt} AND p.id < ${before.id}))` : sql``;
|
|
283
|
+
const result = await withTimeout(session.execute(sql<GalleryPhoto & { in_album: number }>`
|
|
284
|
+
SELECT p.*, CASE WHEN EXISTS (
|
|
285
|
+
SELECT 1 FROM ${session.table('gallery_album_photos')} AS m
|
|
286
|
+
WHERE m.album_id = ${albumId} AND m.photo_id = p.id
|
|
287
|
+
) THEN 1 ELSE 0 END AS in_album
|
|
288
|
+
FROM ${session.table('gallery_photos')} AS p
|
|
289
|
+
WHERE 1 = 1 ${searchFilter} ${cursor}
|
|
290
|
+
ORDER BY p.timeline_day DESC, p.imported_at DESC, p.id DESC
|
|
291
|
+
LIMIT ${capped}
|
|
292
|
+
`), DB_TIMEOUT_MS, 'Gallery selectable photos query timed out');
|
|
293
|
+
return result.rows;
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
async membership(albumId: string, photoId: string): Promise<GalleryAlbumPhoto | null> {
|
|
297
|
+
return await withTimeout(
|
|
298
|
+
session.selectFrom('gallery_album_photos').selectAll()
|
|
299
|
+
.where('album_id', '=', albumId).where('photo_id', '=', photoId)
|
|
300
|
+
.orderBy('added_at', 'desc').executeTakeFirst(),
|
|
301
|
+
DB_TIMEOUT_MS, 'Gallery membership query timed out',
|
|
302
|
+
) ?? null;
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
async memberships(albumId: string, photoId: string): Promise<GalleryAlbumPhoto[]> {
|
|
306
|
+
return withTimeout(
|
|
307
|
+
session.selectFrom('gallery_album_photos').selectAll()
|
|
308
|
+
.where('album_id', '=', albumId).where('photo_id', '=', photoId).execute(),
|
|
309
|
+
DB_TIMEOUT_MS, 'Gallery memberships query timed out',
|
|
310
|
+
);
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
async membershipsForPhotos(albumId: string, photoIds: string[]): Promise<GalleryAlbumPhoto[]> {
|
|
314
|
+
if (photoIds.length === 0) return [];
|
|
315
|
+
return withTimeout(
|
|
316
|
+
session.selectFrom('gallery_album_photos').selectAll()
|
|
317
|
+
.where('album_id', '=', albumId).where('photo_id', 'in', photoIds).execute(),
|
|
318
|
+
DB_TIMEOUT_MS, 'Gallery memberships query timed out',
|
|
319
|
+
);
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
async addableAlbumPhotoIds(albumId: string, photoIds: string[]): Promise<string[]> {
|
|
323
|
+
if (photoIds.length === 0 || photoIds.length > 10_000) return [];
|
|
324
|
+
const result = await withTimeout(session.execute(sql<{ id: string }>`
|
|
325
|
+
WITH requested AS (
|
|
326
|
+
SELECT value AS id, CAST(key AS INTEGER) AS position
|
|
327
|
+
FROM json_each(${JSON.stringify(photoIds)})
|
|
328
|
+
)
|
|
329
|
+
SELECT requested.id FROM requested
|
|
330
|
+
JOIN ${session.table('gallery_photos')} AS p ON p.id = requested.id
|
|
331
|
+
JOIN ${session.table('gallery_albums')} AS a ON a.id = ${albumId}
|
|
332
|
+
WHERE NOT EXISTS (
|
|
333
|
+
SELECT 1 FROM ${session.table('gallery_album_photos')} AS m
|
|
334
|
+
WHERE m.album_id = a.id AND m.photo_id = p.id
|
|
335
|
+
)
|
|
336
|
+
ORDER BY requested.position
|
|
337
|
+
`), DB_TIMEOUT_MS, 'Gallery album selection query timed out');
|
|
338
|
+
return result.rows.map((row) => row.id);
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
async photoAlbums(photoId: string): Promise<GalleryAlbum[]> {
|
|
342
|
+
const result = await withTimeout(session.execute(sql<GalleryAlbum>`
|
|
343
|
+
SELECT DISTINCT a.* FROM ${session.table('gallery_albums')} AS a
|
|
344
|
+
JOIN ${session.table('gallery_album_photos')} AS m ON m.album_id = a.id
|
|
345
|
+
WHERE m.photo_id = ${photoId}
|
|
346
|
+
ORDER BY a.title COLLATE NOCASE, a.id
|
|
347
|
+
`), DB_TIMEOUT_MS, 'Gallery photo albums query timed out');
|
|
348
|
+
return result.rows;
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
async exportSnapshot(): Promise<{
|
|
352
|
+
photos: GalleryPhoto[];
|
|
353
|
+
albums: GalleryAlbum[];
|
|
354
|
+
memberships: GalleryAlbumPhoto[];
|
|
355
|
+
}> {
|
|
356
|
+
const [photos, albums, memberships] = await Promise.all([
|
|
357
|
+
withTimeout(session.selectFrom('gallery_photos').selectAll()
|
|
358
|
+
.orderBy('timeline_day', 'desc').orderBy('imported_at', 'desc').orderBy('id', 'desc').execute(),
|
|
359
|
+
DB_TIMEOUT_MS, 'Gallery export photos query timed out'),
|
|
360
|
+
withTimeout(session.selectFrom('gallery_albums').selectAll().orderBy('id').execute(),
|
|
361
|
+
DB_TIMEOUT_MS, 'Gallery export albums query timed out'),
|
|
362
|
+
withTimeout(session.execute(sql<GalleryAlbumPhoto>`
|
|
363
|
+
SELECT m.* FROM ${session.table('gallery_album_photos')} AS m
|
|
364
|
+
JOIN ${session.table('gallery_albums')} AS a ON a.id = m.album_id
|
|
365
|
+
JOIN ${session.table('gallery_photos')} AS p ON p.id = m.photo_id
|
|
366
|
+
ORDER BY m.added_at, m.id
|
|
367
|
+
`), DB_TIMEOUT_MS, 'Gallery export memberships query timed out'),
|
|
368
|
+
]);
|
|
369
|
+
return { photos, albums, memberships: memberships.rows };
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
async publications(): Promise<GalleryPublication[]> {
|
|
373
|
+
return withTimeout(session.selectFrom('gallery_publications').selectAll()
|
|
374
|
+
.where('owner_log_id', '=', session.scope.logId)
|
|
375
|
+
.orderBy('requested_at', 'desc').orderBy('id', 'desc').execute(),
|
|
376
|
+
DB_TIMEOUT_MS, 'Gallery publications query timed out');
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
async publication(id: string): Promise<GalleryPublication | null> {
|
|
380
|
+
return await withTimeout(session.selectFrom('gallery_publications').selectAll()
|
|
381
|
+
.where('owner_log_id', '=', session.scope.logId).where('id', '=', id).executeTakeFirst(),
|
|
382
|
+
DB_TIMEOUT_MS, 'Gallery publication query timed out') ?? null;
|
|
383
|
+
},
|
|
384
|
+
|
|
385
|
+
async publicationAlbumPhotos(albumId: string): Promise<GalleryPhoto[]> {
|
|
386
|
+
const result = await withTimeout(session.execute(sql<GalleryPhoto>`
|
|
387
|
+
SELECT p.* FROM ${session.table('gallery_photos')} AS p
|
|
388
|
+
WHERE EXISTS (
|
|
389
|
+
SELECT 1 FROM ${session.table('gallery_album_photos')} AS m
|
|
390
|
+
WHERE m.album_id = ${albumId} AND m.photo_id = p.id
|
|
391
|
+
)
|
|
392
|
+
ORDER BY p.timeline_day, p.imported_at, p.id
|
|
393
|
+
LIMIT 10001
|
|
394
|
+
`), DB_TIMEOUT_MS, 'Gallery publication album query timed out');
|
|
395
|
+
return result.rows;
|
|
396
|
+
},
|
|
397
|
+
|
|
398
|
+
/** Handpicked photos, in the order asked for, or oldest first like the public page. */
|
|
399
|
+
async publicationSelectedPhotos(photoIds: string[], order: 'request' | 'time' = 'request'): Promise<GalleryPhoto[]> {
|
|
400
|
+
if (photoIds.length === 0 || photoIds.length > 10_000) return [];
|
|
401
|
+
const result = await withTimeout(session.execute(sql<GalleryPhoto>`
|
|
402
|
+
WITH requested AS (
|
|
403
|
+
SELECT value AS id, CAST(key AS INTEGER) AS position
|
|
404
|
+
FROM json_each(${JSON.stringify(photoIds)})
|
|
405
|
+
)
|
|
406
|
+
SELECT p.* FROM requested
|
|
407
|
+
JOIN ${session.table('gallery_photos')} AS p ON p.id = requested.id
|
|
408
|
+
ORDER BY ${order === 'time' ? sql`p.timeline_day, p.imported_at, p.id` : sql`requested.position`}
|
|
409
|
+
`), DB_TIMEOUT_MS, 'Gallery publication selection query timed out');
|
|
410
|
+
return result.rows;
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Gallery's read repository, added to `queryDatabase` by the composer. */
|
|
2
|
+
|
|
3
|
+
import type { ReadModelSession } from '@hearthlog/sdk/data';
|
|
4
|
+
import { createGalleryRepository } from './gallery.js';
|
|
5
|
+
|
|
6
|
+
export function createRepositories(session: ReadModelSession) {
|
|
7
|
+
return { gallery: createGalleryRepository(session) };
|
|
8
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { AddGalleryPhotoPayload } from '@hearthlog/sdk/data';
|
|
2
|
+
|
|
3
|
+
const BLOCK = 512;
|
|
4
|
+
const MAX_MANIFEST_BYTES = 32 * 1024 * 1024;
|
|
5
|
+
const MAX_PHOTOS = 100_000;
|
|
6
|
+
const MAX_PHOTO_BYTES = 20 * 1024 * 1024;
|
|
7
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8
|
+
const TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif']);
|
|
9
|
+
|
|
10
|
+
export interface GalleryBackupPhoto extends AddGalleryPhotoPayload {
|
|
11
|
+
sha256: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface GalleryBackupManifest {
|
|
15
|
+
format: 'stor-gallery';
|
|
16
|
+
version: 1;
|
|
17
|
+
exportedAt: string;
|
|
18
|
+
photos: GalleryBackupPhoto[];
|
|
19
|
+
albums: Array<{ id: string; title: string; description: string; coverPhotoId: string }>;
|
|
20
|
+
memberships: Array<{ albumId: string; photoId: string; addedAt: string }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ArchivePart = Blob;
|
|
24
|
+
|
|
25
|
+
function photoPath(id: string): string { return `photos/${id}.bin`; }
|
|
26
|
+
|
|
27
|
+
function writeOctal(target: Uint8Array, offset: number, length: number, value: number): void {
|
|
28
|
+
const text = value.toString(8).padStart(length - 1, '0');
|
|
29
|
+
if (text.length >= length) throw new Error('Gallery archive entry is too large.');
|
|
30
|
+
for (let i = 0; i < text.length; i += 1) target[offset + i] = text.charCodeAt(i);
|
|
31
|
+
target[offset + length - 1] = 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function header(name: string, size: number): Uint8Array {
|
|
35
|
+
if (!/^[a-zA-Z0-9/._-]+$/.test(name) || name.length > 100 || !Number.isSafeInteger(size) || size < 0) {
|
|
36
|
+
throw new Error('Invalid Gallery archive entry.');
|
|
37
|
+
}
|
|
38
|
+
const bytes = new Uint8Array(BLOCK);
|
|
39
|
+
bytes.set(new TextEncoder().encode(name), 0);
|
|
40
|
+
writeOctal(bytes, 100, 8, 0o644);
|
|
41
|
+
writeOctal(bytes, 108, 8, 0);
|
|
42
|
+
writeOctal(bytes, 116, 8, 0);
|
|
43
|
+
writeOctal(bytes, 124, 12, size);
|
|
44
|
+
writeOctal(bytes, 136, 12, 0);
|
|
45
|
+
bytes.fill(32, 148, 156);
|
|
46
|
+
bytes[156] = 48; // Regular file.
|
|
47
|
+
bytes.set(new TextEncoder().encode('ustar\0'), 257);
|
|
48
|
+
bytes.set(new TextEncoder().encode('00'), 263);
|
|
49
|
+
const checksum = bytes.reduce((sum, byte) => sum + byte, 0);
|
|
50
|
+
writeOctal(bytes, 148, 8, checksum);
|
|
51
|
+
return bytes;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function padding(size: number): Uint8Array {
|
|
55
|
+
return new Uint8Array((BLOCK - size % BLOCK) % BLOCK);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function bytesPart(bytes: Uint8Array): Blob {
|
|
59
|
+
return new Blob([bytes as BlobPart]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function sha256(blob: Blob): Promise<string> {
|
|
63
|
+
const digest = await crypto.subtle.digest('SHA-256', await blob.arrayBuffer());
|
|
64
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A streaming, uncompressed tar. Manifest follows the bytes so each hash is verified. */
|
|
68
|
+
export async function* createGalleryArchive(
|
|
69
|
+
manifest: Omit<GalleryBackupManifest, 'photos'> & { photos: Array<Omit<GalleryBackupPhoto, 'sha256'>> },
|
|
70
|
+
original: (photo: Omit<GalleryBackupPhoto, 'sha256'>) => Promise<Blob>,
|
|
71
|
+
): AsyncGenerator<ArchivePart> {
|
|
72
|
+
const photos: GalleryBackupPhoto[] = [];
|
|
73
|
+
for (const photo of manifest.photos) {
|
|
74
|
+
const blob = await original(photo);
|
|
75
|
+
if (blob.size !== photo.byteSize) throw new Error(`${photo.filename}: original size does not match its metadata.`);
|
|
76
|
+
photos.push({ ...photo, sha256: await sha256(blob) });
|
|
77
|
+
yield bytesPart(header(photoPath(photo.id), blob.size));
|
|
78
|
+
yield blob;
|
|
79
|
+
yield bytesPart(padding(blob.size));
|
|
80
|
+
}
|
|
81
|
+
const encoded = new TextEncoder().encode(JSON.stringify({ ...manifest, photos } satisfies GalleryBackupManifest));
|
|
82
|
+
if (encoded.byteLength > MAX_MANIFEST_BYTES) throw new Error('Gallery backup manifest is too large.');
|
|
83
|
+
yield bytesPart(header('manifest.json', encoded.byteLength));
|
|
84
|
+
yield bytesPart(encoded);
|
|
85
|
+
yield bytesPart(padding(encoded.byteLength));
|
|
86
|
+
yield bytesPart(new Uint8Array(BLOCK * 2));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function ascii(bytes: Uint8Array, start: number, length: number): string {
|
|
90
|
+
const end = bytes.indexOf(0, start);
|
|
91
|
+
return new TextDecoder('ascii', { fatal: true }).decode(bytes.subarray(start, end < 0 || end >= start + length ? start + length : end));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseHeader(bytes: Uint8Array): { name: string; size: number } | null {
|
|
95
|
+
if (bytes.length !== BLOCK) throw new Error('Gallery backup is truncated.');
|
|
96
|
+
if (bytes.every((byte) => byte === 0)) return null;
|
|
97
|
+
const expected = Number.parseInt(ascii(bytes, 148, 8).trim(), 8);
|
|
98
|
+
const copy = bytes.slice();
|
|
99
|
+
copy.fill(32, 148, 156);
|
|
100
|
+
if (!Number.isSafeInteger(expected) || copy.reduce((sum, byte) => sum + byte, 0) !== expected) {
|
|
101
|
+
throw new Error('Gallery backup header checksum is invalid.');
|
|
102
|
+
}
|
|
103
|
+
const name = ascii(bytes, 0, 100);
|
|
104
|
+
const size = Number.parseInt(ascii(bytes, 124, 12).trim(), 8);
|
|
105
|
+
if (!name || !Number.isSafeInteger(size) || size < 0 || bytes[156] !== 48) {
|
|
106
|
+
throw new Error('Gallery backup contains an invalid entry.');
|
|
107
|
+
}
|
|
108
|
+
return { name, size };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function validateManifest(value: unknown): GalleryBackupManifest {
|
|
112
|
+
if (!value || typeof value !== 'object') throw new Error('Gallery backup manifest is invalid.');
|
|
113
|
+
const manifest = value as GalleryBackupManifest;
|
|
114
|
+
if (manifest.format !== 'stor-gallery' || manifest.version !== 1 || !Array.isArray(manifest.photos)
|
|
115
|
+
|| !Array.isArray(manifest.albums) || !Array.isArray(manifest.memberships)
|
|
116
|
+
|| manifest.photos.length > MAX_PHOTOS) throw new Error('Unsupported Gallery backup.');
|
|
117
|
+
const photoIds = new Set<string>();
|
|
118
|
+
for (const photo of manifest.photos) {
|
|
119
|
+
if (!UUID.test(photo.id) || !UUID.test(photo.fileVersionId) || photoIds.has(photo.id)
|
|
120
|
+
|| !TYPES.has(photo.contentType) || !Number.isSafeInteger(photo.byteSize)
|
|
121
|
+
|| photo.byteSize < 1 || photo.byteSize > MAX_PHOTO_BYTES
|
|
122
|
+
|| !/^[0-9a-f]{64}$/.test(photo.sha256)
|
|
123
|
+
|| typeof photo.filename !== 'string' || !photo.filename || photo.filename.length > 255
|
|
124
|
+
|| /[\\/\u0000-\u001f]/.test(photo.filename)
|
|
125
|
+
|| typeof photo.caption !== 'string' || photo.caption.length > 500
|
|
126
|
+
|| !Number.isSafeInteger(photo.width) || photo.width < 0
|
|
127
|
+
|| !Number.isSafeInteger(photo.height) || photo.height < 0
|
|
128
|
+
|| typeof photo.capturedAt !== 'string' || typeof photo.importedAt !== 'string'
|
|
129
|
+
|| !/^\d{4}-\d{2}-\d{2}$/.test(photo.timelineDay)
|
|
130
|
+
|| typeof photo.favorite !== 'boolean'
|
|
131
|
+
|| !optionalString(photo.cameraMake) || !optionalString(photo.cameraModel)
|
|
132
|
+
|| !optionalString(photo.lensModel) || !optionalNumber(photo.focalLength)
|
|
133
|
+
|| !optionalNumber(photo.fNumber) || !optionalNumber(photo.exposureTime)
|
|
134
|
+
|| !optionalNumber(photo.iso) || !optionalNumber(photo.latitude)
|
|
135
|
+
|| !optionalNumber(photo.longitude)) {
|
|
136
|
+
throw new Error('Gallery backup contains invalid photo metadata.');
|
|
137
|
+
}
|
|
138
|
+
photoIds.add(photo.id);
|
|
139
|
+
}
|
|
140
|
+
const albumIds = new Set<string>();
|
|
141
|
+
for (const album of manifest.albums) {
|
|
142
|
+
if (!UUID.test(album.id) || albumIds.has(album.id) || typeof album.title !== 'string'
|
|
143
|
+
|| !album.title.trim() || album.title.length > 120 || typeof album.description !== 'string'
|
|
144
|
+
|| album.description.length > 240 || (album.coverPhotoId && !photoIds.has(album.coverPhotoId))) {
|
|
145
|
+
throw new Error('Gallery backup contains invalid album metadata.');
|
|
146
|
+
}
|
|
147
|
+
albumIds.add(album.id);
|
|
148
|
+
}
|
|
149
|
+
for (const member of manifest.memberships) {
|
|
150
|
+
if (!albumIds.has(member.albumId) || !photoIds.has(member.photoId) || typeof member.addedAt !== 'string') {
|
|
151
|
+
throw new Error('Gallery backup contains invalid album membership.');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return manifest;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function optionalString(value: unknown): boolean { return value === undefined || typeof value === 'string'; }
|
|
158
|
+
function optionalNumber(value: unknown): boolean { return value === undefined || value === null || (typeof value === 'number' && Number.isFinite(value)); }
|
|
159
|
+
|
|
160
|
+
export async function readGalleryArchive(file: Blob): Promise<{
|
|
161
|
+
manifest: GalleryBackupManifest;
|
|
162
|
+
original: (photo: GalleryBackupPhoto) => Promise<Blob>;
|
|
163
|
+
}> {
|
|
164
|
+
const entries = new Map<string, { start: number; size: number }>();
|
|
165
|
+
let offset = 0;
|
|
166
|
+
while (offset + BLOCK <= file.size && entries.size <= MAX_PHOTOS + 1) {
|
|
167
|
+
const entry = parseHeader(new Uint8Array(await file.slice(offset, offset + BLOCK).arrayBuffer()));
|
|
168
|
+
if (!entry) break;
|
|
169
|
+
const start = offset + BLOCK;
|
|
170
|
+
const next = start + entry.size + padding(entry.size).length;
|
|
171
|
+
if (next > file.size || entries.has(entry.name) || (entry.name !== 'manifest.json' && !/^photos\/[0-9a-f-]{36}\.bin$/i.test(entry.name))) {
|
|
172
|
+
throw new Error('Gallery backup contains an invalid or duplicate entry.');
|
|
173
|
+
}
|
|
174
|
+
entries.set(entry.name, { start, size: entry.size });
|
|
175
|
+
offset = next;
|
|
176
|
+
}
|
|
177
|
+
const manifestEntry = entries.get('manifest.json');
|
|
178
|
+
if (!manifestEntry || manifestEntry.size > MAX_MANIFEST_BYTES) throw new Error('Gallery backup manifest is missing.');
|
|
179
|
+
const manifest = validateManifest(JSON.parse(await file.slice(manifestEntry.start, manifestEntry.start + manifestEntry.size).text()) as unknown);
|
|
180
|
+
if (entries.size !== manifest.photos.length + 1) throw new Error('Gallery backup photo count does not match its manifest.');
|
|
181
|
+
for (const photo of manifest.photos) {
|
|
182
|
+
const entry = entries.get(photoPath(photo.id));
|
|
183
|
+
if (!entry || entry.size !== photo.byteSize) throw new Error('Gallery backup photo is missing or incomplete.');
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
manifest,
|
|
187
|
+
original: async (photo) => {
|
|
188
|
+
const entry = entries.get(photoPath(photo.id));
|
|
189
|
+
if (!entry) throw new Error('Gallery backup photo is missing.');
|
|
190
|
+
const blob = file.slice(entry.start, entry.start + entry.size, photo.contentType);
|
|
191
|
+
if (await sha256(blob) !== photo.sha256) throw new Error(`${photo.filename}: backup checksum does not match.`);
|
|
192
|
+
return blob;
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|