@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,140 @@
|
|
|
1
|
+
import { queryDatabase } from '@hearthlog/sdk/data';
|
|
2
|
+
import { requireActiveContext } from '@hearthlog/sdk/runtime';
|
|
3
|
+
import { galleryPhotoCommands, galleryAlbumCommands, galleryAlbumPhotoCommands } from '@hearthlog/sdk/data';
|
|
4
|
+
import { galleryOriginal, stageGalleryOriginal, discardStagedGalleryOriginal, triggerGalleryTransfers } from './file-store.js';
|
|
5
|
+
import { createGalleryArchive, readGalleryArchive, type GalleryBackupManifest, type GalleryBackupPhoto } from './backup-archive.js';
|
|
6
|
+
|
|
7
|
+
const BLOB_DOWNLOAD_LIMIT = 512 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
interface ArchiveWriter {
|
|
10
|
+
write(part: Blob): Promise<void>;
|
|
11
|
+
close(): Promise<void>;
|
|
12
|
+
abort(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface SaveHandle { createWritable(): Promise<ArchiveWriter> }
|
|
16
|
+
|
|
17
|
+
function backupName(): string { return `stor-gallery-${new Date().toISOString().slice(0, 10)}.tar`; }
|
|
18
|
+
|
|
19
|
+
/** Invoked directly from a click so the browser's file picker keeps user activation. */
|
|
20
|
+
export async function exportGalleryBackup(onProgress: (message: string) => void): Promise<number> {
|
|
21
|
+
const picker = (window as Window & {
|
|
22
|
+
showSaveFilePicker?: (options: { suggestedName: string; types: Array<{ description: string; accept: Record<string, string[]> }> }) => Promise<SaveHandle>;
|
|
23
|
+
}).showSaveFilePicker;
|
|
24
|
+
const handlePromise = picker?.call(window, {
|
|
25
|
+
suggestedName: backupName(),
|
|
26
|
+
types: [{ description: 'Gallery backup', accept: { 'application/x-tar': ['.tar'] } }],
|
|
27
|
+
});
|
|
28
|
+
const writer = handlePromise ? await (await handlePromise).createWritable() : null;
|
|
29
|
+
try {
|
|
30
|
+
const context = await requireActiveContext();
|
|
31
|
+
const snapshot = await queryDatabase((repositories) => repositories.gallery.exportSnapshot());
|
|
32
|
+
if (snapshot.photos.length === 0) throw new Error('There are no photos to back up.');
|
|
33
|
+
const manifest: Omit<GalleryBackupManifest, 'photos'> & { photos: Array<Omit<GalleryBackupPhoto, 'sha256'>> } = {
|
|
34
|
+
format: 'stor-gallery', version: 1, exportedAt: new Date().toISOString(),
|
|
35
|
+
photos: snapshot.photos.map((photo) => ({
|
|
36
|
+
id: photo.id, fileVersionId: photo.file_version_id, filename: photo.filename,
|
|
37
|
+
contentType: photo.content_type, byteSize: photo.byte_size, width: photo.width,
|
|
38
|
+
height: photo.height, capturedAt: photo.captured_at, importedAt: photo.imported_at,
|
|
39
|
+
timelineDay: photo.timeline_day, caption: photo.caption, favorite: Boolean(photo.favorite),
|
|
40
|
+
cameraMake: photo.camera_make, cameraModel: photo.camera_model,
|
|
41
|
+
lensModel: photo.lens_model, focalLength: photo.focal_length, fNumber: photo.f_number,
|
|
42
|
+
exposureTime: photo.exposure_time, iso: photo.iso,
|
|
43
|
+
latitude: photo.latitude, longitude: photo.longitude,
|
|
44
|
+
})),
|
|
45
|
+
albums: snapshot.albums.map((album) => ({
|
|
46
|
+
id: album.id, title: album.title, description: album.description,
|
|
47
|
+
coverPhotoId: album.cover_photo_id,
|
|
48
|
+
})),
|
|
49
|
+
memberships: snapshot.memberships.map((member) => ({
|
|
50
|
+
albumId: member.album_id, photoId: member.photo_id, addedAt: member.added_at,
|
|
51
|
+
})),
|
|
52
|
+
};
|
|
53
|
+
const parts: Blob[] = [];
|
|
54
|
+
let bytes = 0;
|
|
55
|
+
let completed = 0;
|
|
56
|
+
for await (const part of createGalleryArchive(manifest, async (photo) => {
|
|
57
|
+
onProgress(`Backing up ${completed + 1} of ${manifest.photos.length} photos…`);
|
|
58
|
+
const blob = await galleryOriginal({
|
|
59
|
+
tenantId: context.tenantId, logId: context.logId,
|
|
60
|
+
photoId: photo.id, fileVersionId: photo.fileVersionId,
|
|
61
|
+
}, { cache: false });
|
|
62
|
+
completed += 1;
|
|
63
|
+
return blob;
|
|
64
|
+
})) {
|
|
65
|
+
if (writer) await writer.write(part);
|
|
66
|
+
else {
|
|
67
|
+
bytes += part.size;
|
|
68
|
+
if (bytes > BLOB_DOWNLOAD_LIMIT) {
|
|
69
|
+
throw new Error('This backup is too large for a browser download. Use a browser with Save file support.');
|
|
70
|
+
}
|
|
71
|
+
parts.push(part);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (writer) await writer.close();
|
|
75
|
+
else {
|
|
76
|
+
const url = URL.createObjectURL(new Blob(parts, { type: 'application/x-tar' }));
|
|
77
|
+
const link = document.createElement('a');
|
|
78
|
+
link.href = url;
|
|
79
|
+
link.download = backupName();
|
|
80
|
+
link.click();
|
|
81
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
82
|
+
}
|
|
83
|
+
return completed;
|
|
84
|
+
} catch (cause) {
|
|
85
|
+
await writer?.abort().catch(() => undefined);
|
|
86
|
+
throw cause;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Validate all bytes first, then append fresh events into the active log. */
|
|
91
|
+
export async function restoreGalleryBackup(file: File, onProgress: (message: string) => void): Promise<{
|
|
92
|
+
photos: number; albums: number;
|
|
93
|
+
}> {
|
|
94
|
+
const archive = await readGalleryArchive(file);
|
|
95
|
+
const { manifest } = archive;
|
|
96
|
+
if (manifest.photos.length === 0) throw new Error('This backup contains no photos.');
|
|
97
|
+
for (let index = 0; index < manifest.photos.length; index += 1) {
|
|
98
|
+
onProgress(`Checking ${index + 1} of ${manifest.photos.length} originals…`);
|
|
99
|
+
await archive.original(manifest.photos[index]);
|
|
100
|
+
}
|
|
101
|
+
const context = await requireActiveContext();
|
|
102
|
+
const photoIds = new Map(manifest.photos.map((photo) => [photo.id, crypto.randomUUID()]));
|
|
103
|
+
const albumIds = new Map(manifest.albums.map((album) => [album.id, crypto.randomUUID()]));
|
|
104
|
+
let restored = 0;
|
|
105
|
+
try {
|
|
106
|
+
for (const photo of manifest.photos) {
|
|
107
|
+
onProgress(`Restoring ${restored + 1} of ${manifest.photos.length} photos…`);
|
|
108
|
+
const id = photoIds.get(photo.id)!;
|
|
109
|
+
const fileVersionId = crypto.randomUUID();
|
|
110
|
+
const scope = { tenantId: context.tenantId, logId: context.logId, photoId: id, fileVersionId };
|
|
111
|
+
const blob = await archive.original(photo);
|
|
112
|
+
await stageGalleryOriginal(scope, new File([blob], photo.filename, { type: photo.contentType }));
|
|
113
|
+
try {
|
|
114
|
+
const { sha256: _digest, ...metadata } = photo;
|
|
115
|
+
await galleryPhotoCommands.create({ ...metadata, id, fileVersionId }, { submissionId: crypto.randomUUID() });
|
|
116
|
+
} catch (cause) {
|
|
117
|
+
await discardStagedGalleryOriginal(scope);
|
|
118
|
+
throw cause;
|
|
119
|
+
}
|
|
120
|
+
restored += 1;
|
|
121
|
+
}
|
|
122
|
+
for (const album of manifest.albums) {
|
|
123
|
+
await galleryAlbumCommands.create({
|
|
124
|
+
id: albumIds.get(album.id)!, title: album.title,
|
|
125
|
+
description: album.description, coverPhotoId: photoIds.get(album.coverPhotoId) ?? '',
|
|
126
|
+
}, { submissionId: crypto.randomUUID() });
|
|
127
|
+
}
|
|
128
|
+
for (const member of manifest.memberships) {
|
|
129
|
+
await galleryAlbumPhotoCommands.create({
|
|
130
|
+
id: crypto.randomUUID(), albumId: albumIds.get(member.albumId)!,
|
|
131
|
+
photoId: photoIds.get(member.photoId)!, addedAt: member.addedAt,
|
|
132
|
+
}, { submissionId: crypto.randomUUID() });
|
|
133
|
+
}
|
|
134
|
+
} catch (cause) {
|
|
135
|
+
throw new Error(`${restored} photos were restored before the backup stopped. ${cause instanceof Error ? cause.message : 'Restore failed.'}`);
|
|
136
|
+
} finally {
|
|
137
|
+
triggerGalleryTransfers();
|
|
138
|
+
}
|
|
139
|
+
return { photos: restored, albums: manifest.albums.length };
|
|
140
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal EXIF reader for Gallery imports. It reads only the capture and
|
|
3
|
+
* camera fields the Gallery stores, from JPEG, PNG, WebP, and HEIC/HEIF
|
|
4
|
+
* originals. Malformed or missing metadata yields empty fields, never an error.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface PhotoMetadata {
|
|
8
|
+
/** `YYYY-MM-DDTHH:mm:ss`, with a UTC offset when the camera recorded one. */
|
|
9
|
+
capturedAt: string;
|
|
10
|
+
/** Calendar day of the capture in the camera's local time. */
|
|
11
|
+
capturedDay: string;
|
|
12
|
+
cameraMake: string;
|
|
13
|
+
cameraModel: string;
|
|
14
|
+
lensModel: string;
|
|
15
|
+
/** Millimetres. */
|
|
16
|
+
focalLength: number;
|
|
17
|
+
fNumber: number;
|
|
18
|
+
/** Seconds. */
|
|
19
|
+
exposureTime: number;
|
|
20
|
+
iso: number;
|
|
21
|
+
latitude: number | null;
|
|
22
|
+
longitude: number | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const EMPTY_PHOTO_METADATA: PhotoMetadata = {
|
|
26
|
+
capturedAt: '', capturedDay: '', cameraMake: '', cameraModel: '', lensModel: '',
|
|
27
|
+
focalLength: 0, fNumber: 0, exposureTime: 0, iso: 0, latitude: null, longitude: null,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const TAGS = {
|
|
31
|
+
make: 0x010f,
|
|
32
|
+
model: 0x0110,
|
|
33
|
+
dateTime: 0x0132,
|
|
34
|
+
exifIfd: 0x8769,
|
|
35
|
+
gpsIfd: 0x8825,
|
|
36
|
+
exposureTime: 0x829a,
|
|
37
|
+
fNumber: 0x829d,
|
|
38
|
+
iso: 0x8827,
|
|
39
|
+
dateTimeOriginal: 0x9003,
|
|
40
|
+
dateTimeDigitized: 0x9004,
|
|
41
|
+
offsetTimeOriginal: 0x9011,
|
|
42
|
+
focalLength: 0x920a,
|
|
43
|
+
lensModel: 0xa434,
|
|
44
|
+
gpsLatitudeRef: 1,
|
|
45
|
+
gpsLatitude: 2,
|
|
46
|
+
gpsLongitudeRef: 3,
|
|
47
|
+
gpsLongitude: 4,
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
const TYPE_SIZES: Record<number, number> = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8 };
|
|
51
|
+
|
|
52
|
+
type TagValue = string | number[];
|
|
53
|
+
|
|
54
|
+
export function readPhotoMetadata(bytes: Uint8Array): PhotoMetadata {
|
|
55
|
+
try {
|
|
56
|
+
const tiff = findTiff(bytes);
|
|
57
|
+
return tiff === null ? { ...EMPTY_PHOTO_METADATA } : parseTiff(bytes, tiff);
|
|
58
|
+
} catch {
|
|
59
|
+
return { ...EMPTY_PHOTO_METADATA };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function findTiff(bytes: Uint8Array): number | null {
|
|
64
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8) return findJpegTiff(bytes);
|
|
65
|
+
if (ascii(bytes, 1, 'PNG')) return findChunkTiff(bytes, 8, 'eXIf', 'big');
|
|
66
|
+
if (ascii(bytes, 0, 'RIFF') && ascii(bytes, 8, 'WEBP')) return findChunkTiff(bytes, 12, 'EXIF', 'little');
|
|
67
|
+
// HEIC/HEIF store an `Exif` item whose payload is `Exif\0\0` + TIFF.
|
|
68
|
+
const marker = [0x45, 0x78, 0x69, 0x66, 0, 0];
|
|
69
|
+
for (let index = 0; index + 10 <= bytes.length; index += 1) {
|
|
70
|
+
if (marker.every((byte, offset) => bytes[index + offset] === byte) && isTiffHeader(bytes, index + 6)) {
|
|
71
|
+
return index + 6;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function findJpegTiff(bytes: Uint8Array): number | null {
|
|
78
|
+
let offset = 2;
|
|
79
|
+
while (offset + 4 <= bytes.length && bytes[offset] === 0xff) {
|
|
80
|
+
const marker = bytes[offset + 1];
|
|
81
|
+
if (marker === 0xda || marker === 0xd9) return null;
|
|
82
|
+
const length = (bytes[offset + 2] << 8) | bytes[offset + 3];
|
|
83
|
+
if (marker === 0xe1 && ascii(bytes, offset + 4, 'Exif\0\0') && isTiffHeader(bytes, offset + 10)) {
|
|
84
|
+
return offset + 10;
|
|
85
|
+
}
|
|
86
|
+
offset += 2 + length;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function findChunkTiff(bytes: Uint8Array, start: number, type: string, sizeOrder: 'big' | 'little'): number | null {
|
|
92
|
+
// PNG chunks are length + type + data + CRC; RIFF chunks are type + length + data (+ pad).
|
|
93
|
+
let offset = start;
|
|
94
|
+
while (offset + 8 <= bytes.length) {
|
|
95
|
+
const png = sizeOrder === 'big';
|
|
96
|
+
const size = png ? readUint32(bytes, offset, false) : readUint32(bytes, offset + 4, true);
|
|
97
|
+
const chunkType = png ? offset + 4 : offset;
|
|
98
|
+
const data = offset + 8;
|
|
99
|
+
if (ascii(bytes, chunkType, type)) {
|
|
100
|
+
if (isTiffHeader(bytes, data)) return data;
|
|
101
|
+
if (ascii(bytes, data, 'Exif\0\0') && isTiffHeader(bytes, data + 6)) return data + 6;
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
offset = data + size + (png ? 4 : size % 2);
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parseTiff(bytes: Uint8Array, tiff: number): PhotoMetadata {
|
|
110
|
+
const little = bytes[tiff] === 0x49;
|
|
111
|
+
const ifd0 = readIfd(bytes, tiff, readUint32(bytes, tiff + 4, little), little);
|
|
112
|
+
const exifOffset = numberAt(ifd0.get(TAGS.exifIfd));
|
|
113
|
+
const gpsOffset = numberAt(ifd0.get(TAGS.gpsIfd));
|
|
114
|
+
const exif = exifOffset ? readIfd(bytes, tiff, exifOffset, little) : new Map<number, TagValue>();
|
|
115
|
+
const gps = gpsOffset ? readIfd(bytes, tiff, gpsOffset, little) : new Map<number, TagValue>();
|
|
116
|
+
|
|
117
|
+
const captured = exifDate(
|
|
118
|
+
text(exif.get(TAGS.dateTimeOriginal)) || text(exif.get(TAGS.dateTimeDigitized)) || text(ifd0.get(TAGS.dateTime)),
|
|
119
|
+
text(exif.get(TAGS.offsetTimeOriginal)),
|
|
120
|
+
);
|
|
121
|
+
const make = text(ifd0.get(TAGS.make));
|
|
122
|
+
let model = text(ifd0.get(TAGS.model));
|
|
123
|
+
// Many cameras repeat the make inside the model ("Canon Canon EOS R6").
|
|
124
|
+
if (make && model.toLowerCase().startsWith(make.toLowerCase())) model = model.slice(make.length).trim();
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
capturedAt: captured.at,
|
|
128
|
+
capturedDay: captured.day,
|
|
129
|
+
cameraMake: make,
|
|
130
|
+
cameraModel: model,
|
|
131
|
+
lensModel: text(exif.get(TAGS.lensModel)),
|
|
132
|
+
focalLength: rounded(numberAt(exif.get(TAGS.focalLength)), 1),
|
|
133
|
+
fNumber: rounded(numberAt(exif.get(TAGS.fNumber)), 1),
|
|
134
|
+
exposureTime: rounded(numberAt(exif.get(TAGS.exposureTime)), 6),
|
|
135
|
+
iso: Math.round(numberAt(exif.get(TAGS.iso))),
|
|
136
|
+
latitude: coordinate(gps.get(TAGS.gpsLatitude), text(gps.get(TAGS.gpsLatitudeRef)), 'S', 90),
|
|
137
|
+
longitude: coordinate(gps.get(TAGS.gpsLongitude), text(gps.get(TAGS.gpsLongitudeRef)), 'W', 180),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function readIfd(bytes: Uint8Array, tiff: number, offset: number, little: boolean): Map<number, TagValue> {
|
|
142
|
+
const values = new Map<number, TagValue>();
|
|
143
|
+
const start = tiff + offset;
|
|
144
|
+
if (offset <= 0 || start + 2 > bytes.length) return values;
|
|
145
|
+
const count = readUint16(bytes, start, little);
|
|
146
|
+
for (let index = 0; index < count; index += 1) {
|
|
147
|
+
const entry = start + 2 + index * 12;
|
|
148
|
+
if (entry + 12 > bytes.length) break;
|
|
149
|
+
const tag = readUint16(bytes, entry, little);
|
|
150
|
+
const type = readUint16(bytes, entry + 2, little);
|
|
151
|
+
const items = readUint32(bytes, entry + 4, little);
|
|
152
|
+
const size = TYPE_SIZES[type];
|
|
153
|
+
if (!size || items > 1024) continue;
|
|
154
|
+
const valueOffset = size * items <= 4 ? entry + 8 : tiff + readUint32(bytes, entry + 8, little);
|
|
155
|
+
if (valueOffset + size * items > bytes.length) continue;
|
|
156
|
+
values.set(tag, readValue(bytes, valueOffset, type, items, little));
|
|
157
|
+
}
|
|
158
|
+
return values;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function readValue(bytes: Uint8Array, offset: number, type: number, items: number, little: boolean): TagValue {
|
|
162
|
+
if (type === 2) {
|
|
163
|
+
let value = '';
|
|
164
|
+
for (let index = 0; index < items && bytes[offset + index] !== 0; index += 1) {
|
|
165
|
+
value += String.fromCharCode(bytes[offset + index]);
|
|
166
|
+
}
|
|
167
|
+
return value.trim();
|
|
168
|
+
}
|
|
169
|
+
const values: number[] = [];
|
|
170
|
+
for (let index = 0; index < items; index += 1) {
|
|
171
|
+
const at = offset + index * TYPE_SIZES[type];
|
|
172
|
+
if (type === 1 || type === 7) values.push(bytes[at]);
|
|
173
|
+
else if (type === 3) values.push(readUint16(bytes, at, little));
|
|
174
|
+
else if (type === 4) values.push(readUint32(bytes, at, little));
|
|
175
|
+
else if (type === 9) values.push(readUint32(bytes, at, little) | 0);
|
|
176
|
+
else {
|
|
177
|
+
const signed = type === 10;
|
|
178
|
+
const numerator = signed ? readUint32(bytes, at, little) | 0 : readUint32(bytes, at, little);
|
|
179
|
+
const denominator = signed ? readUint32(bytes, at + 4, little) | 0 : readUint32(bytes, at + 4, little);
|
|
180
|
+
values.push(denominator === 0 ? 0 : numerator / denominator);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return values;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function exifDate(value: string, offset: string): { at: string; day: string } {
|
|
187
|
+
const match = /^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(value);
|
|
188
|
+
if (!match) return { at: '', day: '' };
|
|
189
|
+
const [, year, month, day, hour, minute, second] = match;
|
|
190
|
+
const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second)));
|
|
191
|
+
if (Number(year) < 1900 || date.getUTCMonth() !== Number(month) - 1 || date.getUTCDate() !== Number(day)) {
|
|
192
|
+
return { at: '', day: '' };
|
|
193
|
+
}
|
|
194
|
+
const zone = /^[+-]\d{2}:\d{2}$/.test(offset) ? offset : '';
|
|
195
|
+
return { at: `${year}-${month}-${day}T${hour}:${minute}:${second}${zone}`, day: `${year}-${month}-${day}` };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function coordinate(value: TagValue | undefined, ref: string, negative: string, limit: number): number | null {
|
|
199
|
+
if (!Array.isArray(value) || value.length === 0) return null;
|
|
200
|
+
const [degrees = 0, minutes = 0, seconds = 0] = value;
|
|
201
|
+
const decimal = degrees + minutes / 60 + seconds / 3600;
|
|
202
|
+
if (!Number.isFinite(decimal) || decimal > limit || (decimal === 0 && !ref)) return null;
|
|
203
|
+
return rounded(ref.toUpperCase() === negative ? -decimal : decimal, 6);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function text(value: TagValue | undefined): string {
|
|
207
|
+
return typeof value === 'string' ? value : '';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function numberAt(value: TagValue | undefined): number {
|
|
211
|
+
return Array.isArray(value) && Number.isFinite(value[0]) ? value[0] : 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function rounded(value: number, digits: number): number {
|
|
215
|
+
const factor = 10 ** digits;
|
|
216
|
+
return Math.round(value * factor) / factor;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isTiffHeader(bytes: Uint8Array, offset: number): boolean {
|
|
220
|
+
return (bytes[offset] === 0x49 && bytes[offset + 1] === 0x49 && bytes[offset + 2] === 0x2a && bytes[offset + 3] === 0)
|
|
221
|
+
|| (bytes[offset] === 0x4d && bytes[offset + 1] === 0x4d && bytes[offset + 2] === 0 && bytes[offset + 3] === 0x2a);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function ascii(bytes: Uint8Array, offset: number, value: string): boolean {
|
|
225
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
226
|
+
if (bytes[offset + index] !== value.charCodeAt(index)) return false;
|
|
227
|
+
}
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function readUint16(bytes: Uint8Array, offset: number, little: boolean): number {
|
|
232
|
+
return little ? bytes[offset] | (bytes[offset + 1] << 8) : (bytes[offset] << 8) | bytes[offset + 1];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function readUint32(bytes: Uint8Array, offset: number, little: boolean): number {
|
|
236
|
+
const value = little
|
|
237
|
+
? bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24)
|
|
238
|
+
: (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3];
|
|
239
|
+
return value >>> 0;
|
|
240
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
|
|
2
|
+
import { AccessSessionExpiredError, fetchAccessProtected } from '@hearthlog/sdk/runtime';
|
|
3
|
+
import { requireActiveContext } from '@hearthlog/sdk/runtime';
|
|
4
|
+
import { commandActionsAvailable, commandAvailability } from '@hearthlog/sdk/runtime';
|
|
5
|
+
|
|
6
|
+
const DB_NAME = 'stor-gallery-files';
|
|
7
|
+
const DB_VERSION = 1;
|
|
8
|
+
const RETRY_MAX_MS = 5 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
export interface GalleryFileScope {
|
|
11
|
+
tenantId: string;
|
|
12
|
+
logId: string;
|
|
13
|
+
photoId: string;
|
|
14
|
+
fileVersionId: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface GalleryBlob {
|
|
18
|
+
key: string;
|
|
19
|
+
blob: Blob;
|
|
20
|
+
size: number;
|
|
21
|
+
contentType: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface GalleryUpload extends GalleryFileScope {
|
|
25
|
+
id: string;
|
|
26
|
+
blobKey: string;
|
|
27
|
+
attempts: number;
|
|
28
|
+
nextAttemptAt: number;
|
|
29
|
+
lastError: string;
|
|
30
|
+
state: 'pending' | 'needs_attention';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface GalleryFileDatabase extends DBSchema {
|
|
34
|
+
blobs: { key: string; value: GalleryBlob };
|
|
35
|
+
uploads: { key: string; value: GalleryUpload };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let databasePromise: Promise<IDBPDatabase<GalleryFileDatabase>> | null = null;
|
|
39
|
+
let transferPromise: Promise<void> | null = null;
|
|
40
|
+
|
|
41
|
+
function database(): Promise<IDBPDatabase<GalleryFileDatabase>> {
|
|
42
|
+
databasePromise ??= openDB<GalleryFileDatabase>(DB_NAME, DB_VERSION, {
|
|
43
|
+
upgrade(db) {
|
|
44
|
+
db.createObjectStore('blobs', { keyPath: 'key' });
|
|
45
|
+
db.createObjectStore('uploads', { keyPath: 'id' });
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
return databasePromise;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function blobKey(scope: Pick<GalleryFileScope, 'tenantId' | 'photoId' | 'fileVersionId'>): string {
|
|
52
|
+
return `${scope.tenantId}:${scope.photoId}:${scope.fileVersionId}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function galleryFileEndpoint(scope: GalleryFileScope, variant: 'original' | 'thumbnail' | 'display' = 'original'): string {
|
|
56
|
+
return `/api/gallery-files/v1/logs/${encodeURIComponent(scope.logId)}/photos/${encodeURIComponent(scope.photoId)}/versions/${encodeURIComponent(scope.fileVersionId)}/${variant}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Save bytes and the retry record in one durable transaction before appending metadata. */
|
|
60
|
+
export async function stageGalleryOriginal(scope: GalleryFileScope, file: File): Promise<void> {
|
|
61
|
+
const db = await database();
|
|
62
|
+
const key = blobKey(scope);
|
|
63
|
+
const tx = db.transaction(['blobs', 'uploads'], 'readwrite');
|
|
64
|
+
await Promise.all([
|
|
65
|
+
tx.objectStore('blobs').put({ key, blob: file, size: file.size, contentType: file.type }),
|
|
66
|
+
tx.objectStore('uploads').put({
|
|
67
|
+
...scope, id: key, blobKey: key, attempts: 0, nextAttemptAt: Date.now(),
|
|
68
|
+
lastError: '', state: 'pending',
|
|
69
|
+
}),
|
|
70
|
+
]);
|
|
71
|
+
await tx.done;
|
|
72
|
+
try { await navigator.storage?.persist?.(); } catch { /* Best effort. */ }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Only use when the corresponding metadata command definitively failed. */
|
|
76
|
+
export async function discardStagedGalleryOriginal(scope: GalleryFileScope): Promise<void> {
|
|
77
|
+
const db = await database();
|
|
78
|
+
const key = blobKey(scope);
|
|
79
|
+
const tx = db.transaction(['blobs', 'uploads'], 'readwrite');
|
|
80
|
+
await Promise.all([tx.objectStore('blobs').delete(key), tx.objectStore('uploads').delete(key)]);
|
|
81
|
+
await tx.done;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function galleryUploadState(scope: GalleryFileScope): Promise<GalleryUpload | null> {
|
|
85
|
+
return (await (await database()).get('uploads', blobKey(scope))) ?? null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function galleryOriginal(scope: GalleryFileScope, options: { cache?: boolean } = {}): Promise<Blob> {
|
|
89
|
+
const db = await database();
|
|
90
|
+
const cached = await db.get('blobs', blobKey(scope));
|
|
91
|
+
if (cached) return cached.blob;
|
|
92
|
+
const response = await fetchAccessProtected(galleryFileEndpoint(scope));
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
if (response.status === 404) throw new Error('This photo is available when its original is uploaded.');
|
|
95
|
+
throw new Error('The photo could not be loaded.');
|
|
96
|
+
}
|
|
97
|
+
const blob = await response.blob();
|
|
98
|
+
// A viewed original is cached for offline use. Pending originals are never
|
|
99
|
+
// evicted; cache budgeting follows in the recovery slice.
|
|
100
|
+
if (options.cache !== false) {
|
|
101
|
+
await db.put('blobs', {
|
|
102
|
+
key: blobKey(scope), blob, size: blob.size,
|
|
103
|
+
contentType: blob.type || response.headers.get('Content-Type') || 'application/octet-stream',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return blob;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function galleryRendition(scope: GalleryFileScope, variant: 'thumbnail' | 'display'): Promise<Blob> {
|
|
110
|
+
const db = await database();
|
|
111
|
+
const key = `${blobKey(scope)}:${variant}`;
|
|
112
|
+
const cached = await db.get('blobs', key);
|
|
113
|
+
if (cached) return cached.blob;
|
|
114
|
+
const localOriginal = await db.get('blobs', blobKey(scope));
|
|
115
|
+
if (localOriginal) return localOriginal.blob;
|
|
116
|
+
const response = await fetchAccessProtected(galleryFileEndpoint(scope, variant));
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
if (response.status === 404) throw new Error('This photo is available when its display image is uploaded.');
|
|
119
|
+
throw new Error('The photo could not be loaded.');
|
|
120
|
+
}
|
|
121
|
+
const blob = await response.blob();
|
|
122
|
+
await db.put('blobs', { key, blob, size: blob.size, contentType: 'image/webp' });
|
|
123
|
+
return blob;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function galleryLocalStorageStats(): Promise<{ files: number; bytes: number; pending: number }> {
|
|
127
|
+
const db = await database();
|
|
128
|
+
const context = await requireActiveContext();
|
|
129
|
+
const blobs = await db.getAll('blobs');
|
|
130
|
+
const uploads = await db.getAll('uploads');
|
|
131
|
+
return {
|
|
132
|
+
files: blobs.filter((blob) => blob.key.startsWith(`${context.tenantId}:`)).length,
|
|
133
|
+
bytes: blobs.filter((blob) => blob.key.startsWith(`${context.tenantId}:`)).reduce((total, blob) => total + blob.size, 0),
|
|
134
|
+
pending: uploads.filter((upload) => upload.tenantId === context.tenantId).length,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function runGalleryTransfers(): Promise<void> {
|
|
139
|
+
if (!commandActionsAvailable() || !navigator.onLine) return;
|
|
140
|
+
const tenantId = (await requireActiveContext()).tenantId;
|
|
141
|
+
const db = await database();
|
|
142
|
+
const now = Date.now();
|
|
143
|
+
const uploads = (await db.getAll('uploads'))
|
|
144
|
+
.filter((upload) => upload.tenantId === tenantId && upload.state === 'pending' && upload.nextAttemptAt <= now)
|
|
145
|
+
.sort((a, b) => a.nextAttemptAt - b.nextAttemptAt)
|
|
146
|
+
.slice(0, 3);
|
|
147
|
+
for (const upload of uploads) {
|
|
148
|
+
if (!commandActionsAvailable() || (await requireActiveContext()).tenantId !== tenantId) break;
|
|
149
|
+
const cached = await db.get('blobs', upload.blobKey);
|
|
150
|
+
if (!cached) {
|
|
151
|
+
await db.put('uploads', { ...upload, state: 'needs_attention', lastError: 'The local photo is missing.' });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const response = await fetchAccessProtected(galleryFileEndpoint(upload), {
|
|
156
|
+
method: 'PUT', headers: { 'Content-Type': cached.contentType }, body: cached.blob,
|
|
157
|
+
});
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
const body: unknown = await response.json().catch(() => null);
|
|
160
|
+
const code = body && typeof body === 'object' && 'code' in body ? body.code : null;
|
|
161
|
+
const permanent = [400, 409, 413, 415].includes(response.status);
|
|
162
|
+
if (permanent) {
|
|
163
|
+
await db.put('uploads', {
|
|
164
|
+
...upload, state: 'needs_attention', lastError: String(code ?? 'The upload was rejected.'),
|
|
165
|
+
});
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
throw new Error(String(code ?? 'The upload could not be completed.'));
|
|
169
|
+
}
|
|
170
|
+
await db.delete('uploads', upload.id);
|
|
171
|
+
window.dispatchEvent(new CustomEvent('stor:gallery-transfer', { detail: upload.photoId }));
|
|
172
|
+
} catch (cause) {
|
|
173
|
+
if (cause instanceof AccessSessionExpiredError) break;
|
|
174
|
+
const attempts = upload.attempts + 1;
|
|
175
|
+
await db.put('uploads', {
|
|
176
|
+
...upload, attempts,
|
|
177
|
+
nextAttemptAt: Date.now() + Math.min(RETRY_MAX_MS, 1000 * 2 ** Math.min(attempts, 8)),
|
|
178
|
+
lastError: cause instanceof Error ? cause.message : 'Upload failed',
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (uploads.length === 3) window.setTimeout(triggerGalleryTransfers, 0);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function triggerGalleryTransfers(): void {
|
|
186
|
+
transferPromise ??= runGalleryTransfers().catch((cause) => {
|
|
187
|
+
console.error('Gallery transfer failed', cause);
|
|
188
|
+
}).finally(() => { transferPromise = null; });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function startGalleryFileCoordinator(): () => void {
|
|
192
|
+
const trigger = () => triggerGalleryTransfers();
|
|
193
|
+
const unsubscribe = commandAvailability.subscribe(trigger);
|
|
194
|
+
window.addEventListener('online', trigger);
|
|
195
|
+
window.addEventListener('focus', trigger);
|
|
196
|
+
window.addEventListener('pageshow', trigger);
|
|
197
|
+
const interval = window.setInterval(trigger, 15_000);
|
|
198
|
+
trigger();
|
|
199
|
+
return () => {
|
|
200
|
+
unsubscribe();
|
|
201
|
+
window.removeEventListener('online', trigger);
|
|
202
|
+
window.removeEventListener('focus', trigger);
|
|
203
|
+
window.removeEventListener('pageshow', trigger);
|
|
204
|
+
window.clearInterval(interval);
|
|
205
|
+
};
|
|
206
|
+
}
|