|
|
@@ -284,21 +284,82 @@ function gallery_delete(string $slug): void
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Append one image to a gallery under an exclusive lock, so parallel uploads
|
|
|
+ * Where a newly uploaded image belongs among the ones already stored.
|
|
|
+ *
|
|
|
+ * Uploads run several at a time, so they finish in an order set by file size
|
|
|
+ * and network luck, not by the order the photographer picked them. Each job
|
|
|
+ * therefore carries the batch it was selected in and its position within that
|
|
|
+ * batch ($image['batch'] / $image['seq']), and lands next to its siblings
|
|
|
+ * instead of wherever it happened to arrive.
|
|
|
+ *
|
|
|
+ * A batch occupies one contiguous run: its first arrival appends at the end,
|
|
|
+ * and every later one inserts inside that run, which only shifts the runs after
|
|
|
+ * it. So dropping a second selection while the first is still uploading keeps
|
|
|
+ * the two apart, in the order they were dropped.
|
|
|
+ *
|
|
|
+ * Returns the insert position, or null to append — for an unknown batch, and
|
|
|
+ * for images stored before this ordering existed (no batch at all).
|
|
|
+ */
|
|
|
+function gallery_image_position(array $images, array $image): ?int
|
|
|
+{
|
|
|
+ $batch = $image['batch'] ?? null;
|
|
|
+ if (!is_string($batch) || $batch === '') {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ $seq = (int)($image['seq'] ?? 0);
|
|
|
+ $pos = null;
|
|
|
+ foreach ($images as $i => $existing) {
|
|
|
+ if (($existing['batch'] ?? null) !== $batch) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if ((int)($existing['seq'] ?? 0) > $seq) {
|
|
|
+ return $i; // first sibling that belongs after us
|
|
|
+ }
|
|
|
+ $pos = $i + 1;
|
|
|
+ }
|
|
|
+ return $pos;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Insert one image into a gallery under an exclusive lock, so parallel uploads
|
|
|
* into the same gallery cannot overwrite each other's entries.
|
|
|
*
|
|
|
+ * Position comes from gallery_image_position(), so the stored order follows the
|
|
|
+ * selection order rather than the order the uploads completed in.
|
|
|
+ *
|
|
|
+ * $topic is the topic id the image should land in, or null for none. The
|
|
|
+ * special value GALLERY_GUEST_TOPIC is created on the fly if the gallery has no
|
|
|
+ * guest topic yet — inside this function's lock, so two guests uploading at the
|
|
|
+ * same moment cannot each append their own copy of it.
|
|
|
+ *
|
|
|
* Returns the new image count, or null if the gallery no longer exists — an
|
|
|
* absent gallery must not be resurrected as a stub by a late upload.
|
|
|
*/
|
|
|
-function gallery_append_image(string $slug, array $image): ?int
|
|
|
+function gallery_append_image(string $slug, array $image, ?string $topic = null): ?int
|
|
|
{
|
|
|
$missing = false;
|
|
|
- $gallery = json_update(gallery_file($slug), function (array $g) use ($image, &$missing) {
|
|
|
+ $gallery = json_update(gallery_file($slug), function (array $g) use ($image, $topic, &$missing) {
|
|
|
if ($g === []) {
|
|
|
$missing = true;
|
|
|
return null; // deleted mid-upload — do not write a stub file back
|
|
|
}
|
|
|
- $g['images'][] = $image;
|
|
|
+ if ($topic === GALLERY_GUEST_TOPIC) {
|
|
|
+ $g = gallery_with_guest_topic($g);
|
|
|
+ }
|
|
|
+ // Silently drop a topic that no longer exists rather than storing a
|
|
|
+ // dangling reference: the gallery may have been edited mid-upload.
|
|
|
+ if ($topic !== null && isset(gallery_topic_map($g)[$topic])) {
|
|
|
+ $image['topic'] = $topic;
|
|
|
+ }
|
|
|
+ $images = $g['images'] ?? [];
|
|
|
+ $at = gallery_image_position($images, $image);
|
|
|
+ if ($at === null) {
|
|
|
+ $images[] = $image;
|
|
|
+ } else {
|
|
|
+ array_splice($images, $at, 0, [$image]);
|
|
|
+ }
|
|
|
+ $g['images'] = $images;
|
|
|
return $g;
|
|
|
});
|
|
|
if ($missing) {
|
|
|
@@ -309,6 +370,306 @@ function gallery_append_image(string $slug, array $image): ?int
|
|
|
return count($gallery['images'] ?? []);
|
|
|
}
|
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// Topics — optional named sections within one gallery
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+/**
|
|
|
+ * Topics group a gallery's images into sections: the days of a trip, the stops
|
|
|
+ * of a shoot. They are entirely optional — a gallery with no topics behaves,
|
|
|
+ * renders and archives exactly as it did before they existed.
|
|
|
+ *
|
|
|
+ * The gallery record gains one key, and an image one optional key:
|
|
|
+ *
|
|
|
+ * 'topics' => [ ['id' => 't7k3f9a', 'name' => 'Day 1'], … ] // display order
|
|
|
+ * 'images' => [ ['key' => …, 'topic' => 't7k3f9a'], … ] // absent = none
|
|
|
+ *
|
|
|
+ * The images array itself stays one flat list in upload order; the grouping is
|
|
|
+ * *derived* by gallery_groups() wherever it is needed. That keeps the upload
|
|
|
+ * ordering above (batch/seq) untouched, and means a gallery file written before
|
|
|
+ * topics existed is already a valid one — nothing has to be backfilled for the
|
|
|
+ * gallery to work. See app/migrate.php for the tidy-up pass.
|
|
|
+ */
|
|
|
+
|
|
|
+/** Id of the topic guest uploads land in. Reserved; never handed out by gallery_topic_add(). */
|
|
|
+const GALLERY_GUEST_TOPIC = 'guest';
|
|
|
+const GALLERY_GUEST_TOPIC_NAME = 'Guest uploads';
|
|
|
+
|
|
|
+/** Ids are ours, but they arrive back from forms and the assign endpoint. */
|
|
|
+function gallery_topic_id_valid(string $id): bool
|
|
|
+{
|
|
|
+ return (bool)preg_match('/^[A-Za-z0-9_-]{1,32}$/', $id);
|
|
|
+}
|
|
|
+
|
|
|
+/** Trim a submitted topic name to something storable; '' means "reject". */
|
|
|
+function gallery_topic_name(string $name): string
|
|
|
+{
|
|
|
+ return substr(trim(preg_replace('/\s+/u', ' ', $name) ?? ''), 0, 80);
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * A gallery's topics, normalised: well-formed entries only, duplicate ids
|
|
|
+ * dropped, stored order preserved. Every reader goes through this, so a
|
|
|
+ * hand-edited or half-migrated file cannot break a page.
|
|
|
+ */
|
|
|
+function gallery_topics(array $gallery): array
|
|
|
+{
|
|
|
+ $out = [];
|
|
|
+ $seen = [];
|
|
|
+ foreach ($gallery['topics'] ?? [] as $topic) {
|
|
|
+ if (!is_array($topic)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $id = (string)($topic['id'] ?? '');
|
|
|
+ $name = gallery_topic_name((string)($topic['name'] ?? ''));
|
|
|
+ if ($id === '' || $name === '' || isset($seen[$id]) || !gallery_topic_id_valid($id)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $seen[$id] = true;
|
|
|
+ $out[] = ['id' => $id, 'name' => $name];
|
|
|
+ }
|
|
|
+ return $out;
|
|
|
+}
|
|
|
+
|
|
|
+/** id => name, for membership tests and label lookups. */
|
|
|
+function gallery_topic_map(array $gallery): array
|
|
|
+{
|
|
|
+ return array_column(gallery_topics($gallery), 'name', 'id');
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * A gallery's images grouped for display, in render order: the images with no
|
|
|
+ * topic first, then each topic in its stored order. Within a group the images
|
|
|
+ * keep their existing order, so uploads still land where batch/seq put them.
|
|
|
+ *
|
|
|
+ * An image whose topic id matches no existing topic reads as untopiced — a
|
|
|
+ * dangling reference must never make a photo vanish from the gallery.
|
|
|
+ *
|
|
|
+ * $includeEmpty keeps topics that hold no images (and the untopiced group when
|
|
|
+ * the gallery has topics at all): the admin editor needs them as drop targets,
|
|
|
+ * the public view does not want to show empty headings.
|
|
|
+ *
|
|
|
+ * Returns [ ['topic' => null|['id'=>…,'name'=>…], 'images' => [...]], … ].
|
|
|
+ */
|
|
|
+function gallery_groups(array $gallery, bool $includeEmpty = false): array
|
|
|
+{
|
|
|
+ $topics = gallery_topics($gallery);
|
|
|
+ $map = gallery_topic_map($gallery);
|
|
|
+
|
|
|
+ $buckets = ['' => []];
|
|
|
+ foreach ($topics as $topic) {
|
|
|
+ $buckets[$topic['id']] = [];
|
|
|
+ }
|
|
|
+ foreach ($gallery['images'] ?? [] as $image) {
|
|
|
+ $id = (string)($image['topic'] ?? '');
|
|
|
+ $buckets[isset($map[$id]) ? $id : ''][] = $image;
|
|
|
+ }
|
|
|
+
|
|
|
+ $groups = [];
|
|
|
+ if ($buckets[''] !== [] || ($includeEmpty && $topics !== [])) {
|
|
|
+ $groups[] = ['topic' => null, 'images' => $buckets['']];
|
|
|
+ }
|
|
|
+ foreach ($topics as $topic) {
|
|
|
+ if ($buckets[$topic['id']] === [] && !$includeEmpty) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $groups[] = ['topic' => $topic, 'images' => $buckets[$topic['id']]];
|
|
|
+ }
|
|
|
+ return $groups;
|
|
|
+}
|
|
|
+
|
|
|
+/** Whether topics play any part in this gallery — the pre-topics fast path. */
|
|
|
+function gallery_uses_topics(array $gallery): bool
|
|
|
+{
|
|
|
+ if (gallery_topics($gallery) !== []) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ foreach ($gallery['images'] ?? [] as $image) {
|
|
|
+ if (($image['topic'] ?? '') !== '') {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * The gallery with a guest topic guaranteed to exist. Called inside a lock by
|
|
|
+ * gallery_append_image(); the admin may rename or reorder the topic afterwards,
|
|
|
+ * but the id stays 'guest', so later guest uploads keep landing in it. Deleting
|
|
|
+ * it simply means the next guest upload creates it again.
|
|
|
+ */
|
|
|
+function gallery_with_guest_topic(array $gallery): array
|
|
|
+{
|
|
|
+ if (isset(gallery_topic_map($gallery)[GALLERY_GUEST_TOPIC])) {
|
|
|
+ return $gallery;
|
|
|
+ }
|
|
|
+ $topics = gallery_topics($gallery);
|
|
|
+ $topics[] = ['id' => GALLERY_GUEST_TOPIC, 'name' => GALLERY_GUEST_TOPIC_NAME];
|
|
|
+ $gallery['topics'] = $topics;
|
|
|
+ return $gallery;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Add a topic. Returns the stored entry, or null if the name was empty or the
|
|
|
+ * gallery is gone. Ids are random rather than derived from the name, so
|
|
|
+ * renaming a topic never has to touch the images pointing at it.
|
|
|
+ */
|
|
|
+function gallery_topic_add(string $slug, string $name): ?array
|
|
|
+{
|
|
|
+ $name = gallery_topic_name($name);
|
|
|
+ if ($name === '') {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ $added = null;
|
|
|
+ json_update(gallery_file($slug), function (array $g) use ($name, &$added): ?array {
|
|
|
+ if ($g === []) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ $topics = gallery_topics($g);
|
|
|
+ do {
|
|
|
+ $id = 't' . random_token(6);
|
|
|
+ } while (isset(gallery_topic_map($g)[$id]));
|
|
|
+ $added = ['id' => $id, 'name' => $name];
|
|
|
+ $topics[] = $added;
|
|
|
+ $g['topics'] = $topics;
|
|
|
+ return $g;
|
|
|
+ });
|
|
|
+ return $added;
|
|
|
+}
|
|
|
+
|
|
|
+/** Rename a topic in place. The archive folder is named after it, hence dirty. */
|
|
|
+function gallery_topic_rename(string $slug, string $id, string $name): bool
|
|
|
+{
|
|
|
+ $name = gallery_topic_name($name);
|
|
|
+ if ($name === '' || !gallery_topic_id_valid($id)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ $changed = false;
|
|
|
+ $gallery = json_update(gallery_file($slug), function (array $g) use ($id, $name, &$changed): ?array {
|
|
|
+ $topics = gallery_topics($g);
|
|
|
+ foreach ($topics as $i => $topic) {
|
|
|
+ if ($topic['id'] === $id && $topic['name'] !== $name) {
|
|
|
+ $topics[$i]['name'] = $name;
|
|
|
+ $g['topics'] = $topics;
|
|
|
+ $changed = true;
|
|
|
+ return $g;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ });
|
|
|
+ if ($changed) {
|
|
|
+ archive_mark_dirty($slug, $gallery);
|
|
|
+ }
|
|
|
+ return $changed;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Remove a topic. Its images are not deleted — they fall back to no topic, and
|
|
|
+ * so reappear at the top of the gallery.
|
|
|
+ */
|
|
|
+function gallery_topic_delete(string $slug, string $id): bool
|
|
|
+{
|
|
|
+ if (!gallery_topic_id_valid($id)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ $changed = false;
|
|
|
+ $gallery = json_update(gallery_file($slug), function (array $g) use ($id, &$changed): ?array {
|
|
|
+ $topics = gallery_topics($g);
|
|
|
+ $kept = array_values(array_filter($topics, fn(array $t): bool => $t['id'] !== $id));
|
|
|
+ if (count($kept) === count($topics)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ $g['topics'] = $kept;
|
|
|
+ foreach ($g['images'] ?? [] as $i => $image) {
|
|
|
+ if (($image['topic'] ?? '') === $id) {
|
|
|
+ unset($g['images'][$i]['topic']);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ $changed = true;
|
|
|
+ return $g;
|
|
|
+ });
|
|
|
+ if ($changed) {
|
|
|
+ archive_mark_dirty($slug, $gallery);
|
|
|
+ }
|
|
|
+ return $changed;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Move a topic one place up or down in the display order. Mirrors the showreel
|
|
|
+ * reordering in admin/showreel.php: a swap with the neighbour, no-op at the end.
|
|
|
+ */
|
|
|
+function gallery_topic_move(string $slug, string $id, string $dir): bool
|
|
|
+{
|
|
|
+ if (!gallery_topic_id_valid($id)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ $changed = false;
|
|
|
+ $gallery = json_update(gallery_file($slug), function (array $g) use ($id, $dir, &$changed): ?array {
|
|
|
+ $topics = gallery_topics($g);
|
|
|
+ $at = array_search($id, array_column($topics, 'id'), true);
|
|
|
+ if ($at === false) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ $to = $dir === 'up' ? $at - 1 : $at + 1;
|
|
|
+ if ($to < 0 || $to >= count($topics)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ [$topics[$at], $topics[$to]] = [$topics[$to], $topics[$at]];
|
|
|
+ $g['topics'] = $topics;
|
|
|
+ $changed = true;
|
|
|
+ return $g;
|
|
|
+ });
|
|
|
+ if ($changed) {
|
|
|
+ archive_mark_dirty($slug, $gallery);
|
|
|
+ }
|
|
|
+ return $changed;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Put one image into a topic, or back into none ($topicId null or '').
|
|
|
+ *
|
|
|
+ * Matched by S3 key, the image record's de-facto identity. Under the same lock
|
|
|
+ * as uploads, so assigning while an upload is in flight cannot lose either one.
|
|
|
+ * Returns false for an unknown image or an unknown topic; a no-op assignment
|
|
|
+ * (already in that topic) counts as success and skips the write.
|
|
|
+ */
|
|
|
+function gallery_assign_topic(string $slug, string $key, ?string $topicId): bool
|
|
|
+{
|
|
|
+ $topicId = (string)$topicId;
|
|
|
+ if ($topicId !== '' && !gallery_topic_id_valid($topicId)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ $ok = false;
|
|
|
+ $changed = false;
|
|
|
+ $gallery = json_update(gallery_file($slug), function (array $g) use ($key, $topicId, &$ok, &$changed): ?array {
|
|
|
+ if ($topicId !== '' && !isset(gallery_topic_map($g)[$topicId])) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ foreach ($g['images'] ?? [] as $i => $image) {
|
|
|
+ if (($image['key'] ?? '') !== $key) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $ok = true;
|
|
|
+ if ((string)($image['topic'] ?? '') === $topicId) {
|
|
|
+ return null; // already there
|
|
|
+ }
|
|
|
+ if ($topicId === '') {
|
|
|
+ unset($g['images'][$i]['topic']);
|
|
|
+ } else {
|
|
|
+ $g['images'][$i]['topic'] = $topicId;
|
|
|
+ }
|
|
|
+ $changed = true;
|
|
|
+ return $g;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ });
|
|
|
+ if ($changed) {
|
|
|
+ // The image now belongs in a different folder of the ZIP.
|
|
|
+ archive_mark_dirty($slug, $gallery);
|
|
|
+ }
|
|
|
+ return $ok;
|
|
|
+}
|
|
|
+
|
|
|
/** Counter name => the timestamp field recording when it last moved. */
|
|
|
const GALLERY_COUNTERS = [
|
|
|
'views' => 'last_viewed_at',
|