package gallery import ( "io/fs" "os" "path/filepath" "sort" "strings" "time" ) // Image is a gallery entry discovered on disk. type Image struct { RelPath string // e.g. templeoftechno/Anniversary/photo.jpg Album string // top-level folder, e.g. templeoftechno Collection string // event subfolder label, e.g. Anniversary CollectionKey string // slug for filters Filename string // basename URL string // served file URL (/images/…) ThumbURL string HeroURL string IsVideo bool Date time.Time Year int } // List returns gallery JPEGs and videos from dir and subfolders (newest first). // Skips generated thumbs/ and hero/ directories. func List(dir string) ([]Image, error) { var images []Image err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { if d.Name() == thumbDirName || d.Name() == heroDirName { return filepath.SkipDir } return nil } if !isGalleryMedia(d.Name()) { return nil } rel, err := filepath.Rel(dir, path) if err != nil { return err } rel = filepath.ToSlash(rel) if !safeRelPath(rel) { return nil } album := albumFromRel(rel) cKey, cLabel := collectionFromRel(rel) date, year := imageDate(path, d.Name()) video := isVideo(d.Name()) images = append(images, Image{ RelPath: rel, Album: album, Collection: cLabel, CollectionKey: cKey, Filename: d.Name(), URL: "/images/" + URLPath(rel), ThumbURL: derivativeURL(dir, thumbDirName, rel), HeroURL: derivativeURL(dir, heroDirName, rel), IsVideo: video, Date: date, Year: year, }) return nil }) if err != nil { return nil, err } sort.Slice(images, func(i, j int) bool { if images[i].Date.Equal(images[j].Date) { return images[i].RelPath < images[j].RelPath } return images[i].Date.After(images[j].Date) }) return images, nil } // Collections returns sorted collection keys for an album (empty album = all). func Collections(images []Image, album string) []string { seen := make(map[string]string) var keys []string for _, img := range images { if img.CollectionKey == "" { continue } if album != "" && img.Album != album { continue } if _, ok := seen[img.CollectionKey]; ok { continue } seen[img.CollectionKey] = img.Collection keys = append(keys, img.CollectionKey) } sort.Slice(keys, func(i, j int) bool { return seen[keys[i]] < seen[keys[j]] }) return keys } // Albums returns sorted unique album folder names. func Albums(images []Image) []string { seen := make(map[string]struct{}) var albums []string for _, img := range images { if img.Album == "" { continue } if _, ok := seen[img.Album]; ok { continue } seen[img.Album] = struct{}{} albums = append(albums, img.Album) } sort.Strings(albums) return albums } // AlbumLabel returns a display name for an album folder. func AlbumLabel(album string) string { labels := map[string]string{ "escaperoom": "Escape room", "ledlyra": "LED Lyra", "misc": "Misc", "portal": "Portal", "templeoftechno": "Temple of techno", "connectionmachine": "Connection machine", } if label, ok := labels[album]; ok { return label } return strings.ReplaceAll(album, "-", " ") } func albumFromRel(rel string) string { if i := strings.Index(rel, "/"); i >= 0 { return rel[:i] } return "" } // SafeRelPath reports whether rel is safe to serve (exported for handlers). func SafeRelPath(rel string) bool { return safeRelPath(rel) } func safeRelPath(rel string) bool { if rel == "" || strings.Contains(rel, "..") { return false } return true } func derivativeURL(imagesDir, subdir, rel string) string { lookup := rel if subdir == thumbDirName || subdir == heroDirName { lookup = thumbLookupRel(rel) } if isVideo(filepath.Base(rel)) && subdir == heroDirName { return "" } if _, err := os.Stat(filepath.Join(imagesDir, subdir, filepath.FromSlash(lookup))); err == nil { return "/images/" + subdir + "/" + URLPath(lookup) } return "" } func imageDate(fullPath, filename string) (time.Time, int) { if t, y := parseDate(filename); !t.IsZero() { return t, y } info, err := os.Stat(fullPath) if err != nil { return time.Time{}, 0 } mod := info.ModTime() return mod, mod.Year() } func parseDate(filename string) (time.Time, int) { base := strings.TrimSuffix(filename, filepath.Ext(filename)) // IMG_YYYYMMDD_HHMMSS.jpg if strings.HasPrefix(base, "IMG_") && len(base) >= 12 { raw := strings.TrimPrefix(base, "IMG_") if t, err := time.Parse("20060102", raw[:8]); err == nil { return t, t.Year() } } // 20220723_231556.jpg if len(base) >= 15 && base[8] == '_' { if t, err := time.Parse("20060102_150405", base[:15]); err == nil { return t, t.Year() } } return time.Time{}, 0 }