Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c05faa50d9 | |||
| 15c844db06 | |||
| c685a3e931 | |||
| 74ff236b51 |
@@ -1 +1 @@
|
||||
{".":"3.46.5"}
|
||||
{".":"3.46.7"}
|
||||
|
||||
@@ -5,6 +5,20 @@ All notable changes to PicPeak will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.46.7](https://github.com/PicPeak/picpeak/compare/v3.46.6...v3.46.7) (2026-08-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **admin:** the "Uncategorized" photo filter returns every photo ([#1211](https://github.com/PicPeak/picpeak/issues/1211)) ([#1215](https://github.com/PicPeak/picpeak/issues/1215)) ([15c844d](https://github.com/PicPeak/picpeak/commit/15c844db067de7bc04a88ddd407f3ed8f5df0fe2))
|
||||
|
||||
## [3.46.6](https://github.com/PicPeak/picpeak/compare/v3.46.5...v3.46.6) (2026-08-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **images:** fence the capture-date backfill on the file it read ([#1201](https://github.com/PicPeak/picpeak/issues/1201)) ([#1205](https://github.com/PicPeak/picpeak/issues/1205)) ([74ff236](https://github.com/PicPeak/picpeak/commit/74ff236b516b6df00e83d3c314fde552d18605ad))
|
||||
|
||||
## [3.46.5](https://github.com/PicPeak/picpeak/compare/v3.46.4...v3.46.5) (2026-08-26)
|
||||
|
||||
|
||||
|
||||
@@ -198,5 +198,30 @@ describe('capture date backfill (#1172)', () => {
|
||||
|
||||
expect(new Date((await db('photos').where({ id: photoId }).first()).captured_at).toISOString()).toBe(claimed);
|
||||
expect(done.body.lastResult.success).toBe(0);
|
||||
// Read but not written, so it is accounted for rather than dropped.
|
||||
expect(done.body.lastResult.skipped).toBe(1);
|
||||
});
|
||||
|
||||
it('does not date a row whose file was replaced while it was reading (#1201)', async () => {
|
||||
// replacePhoto swaps a NEW file under an existing row and rewrites
|
||||
// path/filename (reachable from replace_by_name). The replacement carries
|
||||
// no date of its own, so captured_at is still NULL and the whereNull guard
|
||||
// alone would let the previous file's EXIF date land on it. The write is
|
||||
// fenced on the identity that was read, so the row is skipped instead —
|
||||
// and not counted as updated either.
|
||||
const { photoId } = await seed({ relpath: 'orig.jpg', exifIso: '2026-06-03T09:00:00Z' });
|
||||
|
||||
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
|
||||
expect(res.body.count).toBe(1);
|
||||
// Simulate the replacement landing before the loop writes.
|
||||
await db('photos').where({ id: photoId })
|
||||
.update({ path: 'capfill/replaced.jpg', filename: 'replaced.jpg' });
|
||||
const done = await settle();
|
||||
|
||||
expect((await db('photos').where({ id: photoId }).first()).captured_at).toBeNull();
|
||||
expect(done.body.lastResult.success).toBe(0);
|
||||
// Not an error and not "no EXIF" — the date was found, another writer just
|
||||
// got there first. It stays in the backlog for the next run.
|
||||
expect(done.body.lastResult).toMatchObject({ noExif: 0, failed: 0, skipped: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* The admin photo list's category filter, and the value it answers to (#1211).
|
||||
*
|
||||
* The frontend used to send `category_id=0` for "Uncategorized". This route
|
||||
* skips `'0'` outright — the guard reads `category_id !== '0'` — so no
|
||||
* condition was applied and the whole event came back. Four lines below that
|
||||
* guard sits the branch that does the work, keyed on the literal
|
||||
* `uncategorized`, which nothing was sending.
|
||||
*
|
||||
* Reported in #1209 by someone trying to isolate a few thousand uncategorised
|
||||
* imports. The frontend half is fixed in PhotoFilters; this pins the backend
|
||||
* half of the same contract, because the failure mode was the two ends
|
||||
* disagreeing about a string and neither one being wrong on its own.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
describe('admin photo list — uncategorized filter (#1211)', () => {
|
||||
let db; let cleanup; let app;
|
||||
let eventId; let categoryId;
|
||||
let uncategorisedIds; let categorisedId;
|
||||
|
||||
const list = async (query = '') => {
|
||||
const res = await request(app).get(`/api/admin/events/${eventId}/photos${query}`);
|
||||
expect(res.status).toBe(200);
|
||||
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
|
||||
return (photos || []).map((p) => p.id).sort((a, b) => a - b);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
|
||||
}));
|
||||
jest.doMock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/middleware/ownership', () => ({
|
||||
requireEventOwnership: (_req, _res, next) => next(),
|
||||
}));
|
||||
jest.doMock('../../src/utils/logger', () => ({
|
||||
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
|
||||
const [ev] = await db('events').insert({
|
||||
slug: 'uncat-filter', event_type: 'wedding', event_name: 'Uncat Filter',
|
||||
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
|
||||
password_hash: 'x', share_link: '/gallery/uncat-filter/share',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0, created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = typeof ev === 'object' ? ev.id : ev;
|
||||
|
||||
const [cat] = await db('photo_categories')
|
||||
.insert({ name: 'Ceremony', slug: 'ceremony', event_id: eventId })
|
||||
.returning('id');
|
||||
categoryId = typeof cat === 'object' ? cat.id : cat;
|
||||
|
||||
const insertPhoto = async (filename, category) => {
|
||||
const [p] = await db('photos').insert({
|
||||
event_id: eventId, filename, path: `events/uncat/${filename}`,
|
||||
type: 'individual', category_id: category,
|
||||
uploaded_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
return typeof p === 'object' ? p.id : p;
|
||||
};
|
||||
|
||||
// Two with no category — the shape a plugin upload leaves behind — and one
|
||||
// filed properly, so a filter that does nothing is visibly different from
|
||||
// a filter that works.
|
||||
uncategorisedIds = [await insertPhoto('a.jpg', null), await insertPhoto('b.jpg', null)];
|
||||
categorisedId = await insertPhoto('c.jpg', categoryId);
|
||||
uncategorisedIds.sort((a, b) => a - b);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/events', require('../../src/routes/adminPhotos'));
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('returns only the photos with no category', async () => {
|
||||
expect(await list('?category_id=uncategorized')).toEqual(uncategorisedIds);
|
||||
});
|
||||
|
||||
it('returns everything when no category filter is given', async () => {
|
||||
expect(await list()).toEqual([...uncategorisedIds, categorisedId].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
it('still filters by a real category id', async () => {
|
||||
expect(await list(`?category_id=${categoryId}`)).toEqual([categorisedId]);
|
||||
});
|
||||
|
||||
it('treats 0 as no filter at all', async () => {
|
||||
// Pinning the behaviour that made the bug silent rather than loud: '0' is
|
||||
// not "uncategorized" and never was, it simply falls through the guard. A
|
||||
// future change that made 0 mean uncategorized here would be fine too —
|
||||
// but it must be a decision, not an accident, and this test forces it.
|
||||
expect(await list('?category_id=0')).toEqual([...uncategorisedIds, categorisedId].sort((a, b) => a - b));
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.46.5",
|
||||
"version": "3.46.7",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"engines": {
|
||||
|
||||
@@ -350,6 +350,7 @@ router.post('/repair-capture-dates', adminAuth, requirePermission('settings.edit
|
||||
let successCount = 0;
|
||||
let missingCount = 0;
|
||||
let errorCount = 0;
|
||||
let skippedCount = 0;
|
||||
let lostClaim = false;
|
||||
|
||||
// Same reasoning as the dimension repair: detached from the request, so
|
||||
@@ -424,11 +425,29 @@ router.post('/repair-capture-dates', adminAuth, requirePermission('settings.edit
|
||||
// large library, and an import or a replacement finishing meanwhile
|
||||
// has already written a date this pass would otherwise overwrite
|
||||
// with the same-or-worse value.
|
||||
//
|
||||
// Fenced on path and filename as well as the id (#1201):
|
||||
// replacePhoto — reachable from the replace_by_name upload path
|
||||
// (adminPhotos.js) — swaps a NEW file under an existing row and
|
||||
// rewrites path/filename. That replacement carries no date of its
|
||||
// own, so captured_at is still NULL and whereNull alone would let
|
||||
// the previous file's EXIF date land on it. Matching the identity
|
||||
// that was actually read means the update affects no rows and the
|
||||
// row is simply skipped.
|
||||
const updated = await db('photos')
|
||||
.where({ id: photo.id })
|
||||
.where({ id: photo.id, path: photo.path, filename: photo.filename })
|
||||
.whereNull('captured_at')
|
||||
.update({ captured_at: captured.toISOString() });
|
||||
if (updated) successCount++;
|
||||
// Counted, not dropped: without this a candidate that was read but
|
||||
// not written falls out of the run's arithmetic entirely, and
|
||||
// success + noExif + failed silently stops adding up to the count
|
||||
// the operator was shown when they started it. Two ways to land
|
||||
// here, both "another writer got there first" — the row was dated
|
||||
// meanwhile (whereNull), or its file changed under us (the fence).
|
||||
// Neither is an error and neither needs a retry: captured_at is
|
||||
// still NULL for the fenced case, so the status endpoint keeps
|
||||
// reporting it as backlog and the next run picks it up.
|
||||
if (updated) successCount++; else skippedCount++;
|
||||
|
||||
if (successCount % 50 === 0 && successCount > 0) {
|
||||
logger.info(`Capture date backfill progress: ${successCount} updated...`);
|
||||
@@ -443,12 +462,15 @@ router.post('/repair-capture-dates', adminAuth, requirePermission('settings.edit
|
||||
logger.warn(`Capture date backfill stopped: claim taken over after ${successCount} updated, ${errorCount} errors`);
|
||||
return;
|
||||
}
|
||||
await maintenanceJobs.release(JOB_CAPTURE_DATE_BACKFILL, token, { success: successCount, noExif: missingCount, failed: errorCount });
|
||||
logger.info(`Capture date backfill complete: ${successCount} updated, ${missingCount} without EXIF, ${errorCount} errors`);
|
||||
await maintenanceJobs.release(JOB_CAPTURE_DATE_BACKFILL, token, { success: successCount, noExif: missingCount, failed: errorCount, skipped: skippedCount });
|
||||
logger.info(
|
||||
`Capture date backfill complete: ${successCount} updated, ${missingCount} without EXIF, `
|
||||
+ `${errorCount} errors, ${skippedCount} skipped (dated or replaced mid-run)`
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error('Capture date backfill aborted:', err);
|
||||
await maintenanceJobs
|
||||
.release(JOB_CAPTURE_DATE_BACKFILL, token, { success: successCount, noExif: missingCount, failed: errorCount, error: err.message })
|
||||
.release(JOB_CAPTURE_DATE_BACKFILL, token, { success: successCount, noExif: missingCount, failed: errorCount, skipped: skippedCount, error: err.message })
|
||||
.catch(() => {});
|
||||
} finally {
|
||||
lease.stop();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "3.46.5",
|
||||
"version": "3.46.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -63,7 +63,12 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
className="px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
|
||||
>
|
||||
<option value="">{t('gallery.allCategories', 'All Categories')}</option>
|
||||
<option value="0">{t('gallery.uncategorized', 'Uncategorized')}</option>
|
||||
{/* The literal the backend understands, not 0 (#1211). It skips
|
||||
'0' outright — `category_id !== '0'` — so this filter used to
|
||||
apply no condition at all and quietly returned the whole event.
|
||||
The onChange below passes non-numeric values through unchanged,
|
||||
so the string arrives intact. */}
|
||||
<option value="uncategorized">{t('gallery.uncategorized', 'Uncategorized')}</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* The category filter's wire values (#1211).
|
||||
*
|
||||
* "Uncategorized" was rendered as `value="0"`, and the backend skips `'0'`
|
||||
* outright (`adminPhotos.js`: `category_id !== '0'`), so the filter applied no
|
||||
* condition and returned the whole event. The value it does understand is the
|
||||
* literal `uncategorized`, four lines below that guard, which nothing sent.
|
||||
*
|
||||
* The failure was silent — a full list reads as "nothing to narrow" rather
|
||||
* than "the filter did not run" — so this pins the wire value rather than the
|
||||
* rendered label. Reported in #1209 by someone trying to isolate a few
|
||||
* thousand uncategorised imports to re-assign them.
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { PhotoFilters } from '../PhotoFilters';
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: any) => (typeof fallback === 'string' ? fallback : _key),
|
||||
i18n: { language: 'en' }
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
const categories = [
|
||||
{ id: 3, name: 'Ceremony' },
|
||||
{ id: 4, name: 'Reception' },
|
||||
];
|
||||
|
||||
const renderFilters = (selectedCategory: number | string | null = null) => {
|
||||
const onCategoryChange = vi.fn();
|
||||
render(
|
||||
<PhotoFilters
|
||||
selectedCategory={selectedCategory}
|
||||
categories={categories as any}
|
||||
onCategoryChange={onCategoryChange}
|
||||
searchTerm=""
|
||||
onSearchChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
return { onCategoryChange };
|
||||
};
|
||||
|
||||
const categorySelect = () => screen.getAllByRole('combobox')[0];
|
||||
|
||||
describe('category filter wire values (#1211)', () => {
|
||||
it('sends the literal the backend understands for Uncategorized', async () => {
|
||||
const { onCategoryChange } = renderFilters();
|
||||
|
||||
await userEvent.selectOptions(categorySelect(), 'uncategorized');
|
||||
|
||||
// Not 0 — the backend drops that and returns everything.
|
||||
expect(onCategoryChange).toHaveBeenCalledWith('uncategorized');
|
||||
});
|
||||
|
||||
it('still sends a numeric id for a real category', async () => {
|
||||
const { onCategoryChange } = renderFilters();
|
||||
|
||||
await userEvent.selectOptions(categorySelect(), '3');
|
||||
|
||||
expect(onCategoryChange).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it('clears back to null for All Categories', async () => {
|
||||
const { onCategoryChange } = renderFilters('uncategorized');
|
||||
|
||||
await userEvent.selectOptions(categorySelect(), '');
|
||||
|
||||
expect(onCategoryChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('keeps Uncategorized selected once it is chosen', () => {
|
||||
renderFilters('uncategorized');
|
||||
// The select is controlled; if the option value and the state value ever
|
||||
// drift apart the control silently falls back to the first option.
|
||||
expect((categorySelect() as HTMLSelectElement).value).toBe('uncategorized');
|
||||
});
|
||||
});
|
||||
@@ -689,6 +689,23 @@ export const StatusTab: React.FC<StatusTabProps> = ({
|
||||
failed: captureDateStatus.lastResult.failed,
|
||||
defaultValue: 'Last run: {{success}} updated, {{noExif}} with no date found, {{failed}} unreachable',
|
||||
})}
|
||||
{/* Only when it happened. Without it the three numbers above
|
||||
silently stop adding up to the count the run started with: a
|
||||
photo that was replaced, renamed or dated by someone else
|
||||
mid-run is read but not written.
|
||||
Deliberately says "not updated" and not "will be retried":
|
||||
one of the two ways to land here is another writer having
|
||||
filled captured_at, and that photo is finished, not backlog.
|
||||
The Missing Capture Date figure above is what says whether
|
||||
anything is actually left to do. */}
|
||||
{Number(captureDateStatus.lastResult.skipped) > 0 && (
|
||||
<span className="block text-amber-600 dark:text-amber-400 mt-1">
|
||||
{t('settings.captureDates.skipped', {
|
||||
count: captureDateStatus.lastResult.skipped,
|
||||
defaultValue: '{{count}} photo(s) were changed by something else while the run was reading them and were not updated.',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2077,6 +2077,7 @@
|
||||
"running": "Wird nachgetragen...",
|
||||
"noneToFill": "Alle Fotos haben bereits ein Aufnahmedatum",
|
||||
"resultSuccess": "Letzter Lauf: {{success}} aktualisiert, {{noExif}} ohne gefundenes Datum, {{failed}} nicht erreichbar",
|
||||
"skipped": "{{count}} Foto(s) wurden während des Laufs anderweitig geändert und daher nicht aktualisiert.",
|
||||
"description": "Trägt „Aufnahmedatum\" aus den EXIF-Daten nach, für Fotos die vor dieser Auswertung importiert wurden. Externe Importe haben nie eines gespeichert, dadurch sortieren diese Galerien nach Importreihenfolge statt nach Aufnahmezeit."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1618,6 +1618,7 @@
|
||||
"running": "Backfilling...",
|
||||
"noneToFill": "All photos already have a capture date",
|
||||
"resultSuccess": "Last run: {{success}} updated, {{noExif}} with no date found, {{failed}} unreachable",
|
||||
"skipped": "{{count}} photo(s) were changed by something else while the run was reading them and were not updated.",
|
||||
"description": "Backfill \"Date Taken\" from EXIF for photos imported before capture dates were read. External/reference imports never recorded one, so their galleries sort by import order instead of when the photos were taken."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1181,6 +1181,7 @@
|
||||
"running": "Traitement...",
|
||||
"noneToFill": "Toutes les photos ont déjà une date de prise de vue",
|
||||
"resultSuccess": "Dernier passage : {{success}} mises à jour, {{noExif}} sans date trouvée, {{failed}} inaccessibles",
|
||||
"skipped": "{{count}} photo(s) ont été modifiées par autre chose pendant le passage et n'ont donc pas été mises à jour.",
|
||||
"description": "Complète la « date de prise de vue » depuis les EXIF pour les photos importées avant sa lecture. Les imports externes n'en enregistraient aucune, si bien que ces galeries se trient par ordre d'import plutôt que par date de prise de vue."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1181,6 +1181,7 @@
|
||||
"running": "Dopolnjevanje...",
|
||||
"noneToFill": "Vse fotografije že imajo datum zajema",
|
||||
"resultSuccess": "Zadnji zagon: {{success}} posodobljenih, {{noExif}} brez najdenega datuma, {{failed}} nedosegljivih",
|
||||
"skipped": "{{count}} fotografij je bilo med zagonom spremenjenih drugje in zato niso bile posodobljene.",
|
||||
"description": "Dopolni »datum zajema« iz EXIF za fotografije, uvožene pred njegovim branjem. Zunanji uvozi ga niso zabeležili, zato se te galerije razvrščajo po vrstnem redu uvoza namesto po času zajema."
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user