mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(photos): record when and where a photo was taken
The foundation the Journey map needs before photos can sit on it by their own coordinates, and the gallery before it can order by when a picture was taken rather than when it happened to be added (#1614). Three nullable columns on trek_photos plus a partial index over the coordinate pair; a photo with neither is the normal case, not a failure. recordCaptureMetadata merges rather than overwrites, for the same reason recordLocalThumbnail does: the sources disagree about how much they know. A provider search answers with coordinates, the same provider's album listing does not, and a local file gives them up only once its EXIF has been read. Whichever arrives first wins, and a later, emptier answer must not blank it. Coordinates are stored as a pair or not at all — half a pair is not a place, it is the null island.
This commit is contained in:
@@ -3955,6 +3955,21 @@ function runMigrations(db: Database.Database): void {
|
||||
upsert.run(column, row.value);
|
||||
}
|
||||
},
|
||||
// Capture metadata on the photo itself (#1614): when it was taken and where.
|
||||
// Both are needed before photos can sit on the Journey map by their own
|
||||
// coordinates, and before a gallery can be ordered by when a picture was
|
||||
// taken rather than when it happened to be added. Nullable throughout —
|
||||
// most providers answer with neither, and a photo without them is normal.
|
||||
// Guarded so re-running the migration tail is a no-op.
|
||||
() => {
|
||||
const cols = db.prepare("SELECT name FROM pragma_table_info('trek_photos')").all() as Array<{ name: string }>;
|
||||
const has = (name: string) => cols.some(c => c.name === name);
|
||||
if (!has('taken_at')) db.exec('ALTER TABLE trek_photos ADD COLUMN taken_at TEXT');
|
||||
if (!has('lat')) db.exec('ALTER TABLE trek_photos ADD COLUMN lat REAL');
|
||||
if (!has('lng')) db.exec('ALTER TABLE trek_photos ADD COLUMN lng REAL');
|
||||
// Answering "which photos of this journey have coordinates" without a scan.
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_trek_photos_geo ON trek_photos(lat, lng) WHERE lat IS NOT NULL AND lng IS NOT NULL');
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -89,6 +89,36 @@ export class TrekPhotosRepository {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where and when the picture was taken (#1614).
|
||||
*
|
||||
* COALESCE throughout, for the same reason recordLocalThumbnail does it: the
|
||||
* three sources disagree in how much they know. A provider search answers with
|
||||
* coordinates, the same provider's album listing does not, and a local file
|
||||
* only gives them up once its EXIF has been read. Whichever arrives first wins,
|
||||
* and a later, emptier answer must not blank what is already there.
|
||||
*
|
||||
* A photo with neither is the normal case, not a failure.
|
||||
*/
|
||||
recordCaptureMetadata(
|
||||
photoId: number,
|
||||
meta: { takenAt?: string | null; lat?: number | null; lng?: number | null },
|
||||
): void {
|
||||
const { takenAt = null, lat = null, lng = null } = meta;
|
||||
if (takenAt == null && lat == null && lng == null) return;
|
||||
// Coordinates are stored as a pair or not at all — a lone latitude is not a
|
||||
// place, and half a pair would put the photo on the null island.
|
||||
const hasPair = Number.isFinite(lat) && Number.isFinite(lng);
|
||||
this.db.run(
|
||||
`UPDATE trek_photos
|
||||
SET taken_at = COALESCE(taken_at, ?),
|
||||
lat = COALESCE(lat, ?),
|
||||
lng = COALESCE(lng, ?)
|
||||
WHERE id = ?`,
|
||||
takenAt, hasPair ? lat : null, hasPair ? lng : null, photoId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the row once no trip and no journey references it. Local photos are
|
||||
* kept: their bytes are ours, and the file would outlive the row.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* TrekPhotosRepository — capture metadata (#1614).
|
||||
*
|
||||
* The three sources of a photo's "when and where" disagree in how much they know:
|
||||
* a provider search answers with coordinates, the same provider's album listing
|
||||
* does not, and a local file gives them up only once its EXIF has been read. These
|
||||
* pin the merge rule that keeps a later, emptier answer from erasing an earlier one.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: () => null, canAccessTrip: () => undefined, isOwner: () => false } };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-secret',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
|
||||
|
||||
let repo: TrekPhotosRepository;
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
repo = new TrekPhotosRepository(new DatabaseService(testDb));
|
||||
});
|
||||
|
||||
beforeEach(() => resetTestDb(testDb));
|
||||
afterAll(() => testDb.close());
|
||||
|
||||
// A fresh path per call: getOrCreateLocal is keyed on file_path and resetTestDb
|
||||
// leaves trek_photos alone, so a shared path would hand every test the same row.
|
||||
let seq = 0;
|
||||
function makePhoto(): number {
|
||||
return repo.getOrCreateLocal(`/uploads/journey/${++seq}.jpg`, null, null, null, 'image', null);
|
||||
}
|
||||
|
||||
function read(id: number) {
|
||||
return testDb.prepare('SELECT taken_at, lat, lng FROM trek_photos WHERE id = ?').get(id) as {
|
||||
taken_at: string | null; lat: number | null; lng: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
describe('TrekPhotosRepository.recordCaptureMetadata', () => {
|
||||
it('TREKPHOTO-001: stores the capture time and coordinates', () => {
|
||||
const id = makePhoto();
|
||||
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
|
||||
|
||||
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
|
||||
});
|
||||
|
||||
it('TREKPHOTO-002: a later, emptier answer does not erase what is known', () => {
|
||||
const id = makePhoto();
|
||||
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
|
||||
// The album listing knows the date but not the place.
|
||||
repo.recordCaptureMetadata(id, { takenAt: '2020-01-01T00:00:00Z', lat: null, lng: null });
|
||||
|
||||
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
|
||||
});
|
||||
|
||||
it('TREKPHOTO-003: fills only the half that was still missing', () => {
|
||||
const id = makePhoto();
|
||||
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z' });
|
||||
repo.recordCaptureMetadata(id, { lat: 48.8584, lng: 2.2945 });
|
||||
|
||||
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
|
||||
});
|
||||
|
||||
it('TREKPHOTO-004: refuses half a coordinate pair rather than landing on null island', () => {
|
||||
const id = makePhoto();
|
||||
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: null });
|
||||
|
||||
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: null, lng: null });
|
||||
});
|
||||
|
||||
it('TREKPHOTO-005: an answer with nothing in it touches no row', () => {
|
||||
const id = makePhoto();
|
||||
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
|
||||
repo.recordCaptureMetadata(id, {});
|
||||
|
||||
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user