Skip to content
Merged
3 changes: 2 additions & 1 deletion defaults.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ collections:
dirs: ["./"]

# - name: Collection Name
# layout: album | timeline | wall
# layout: album | timeline | wall | flex | map | highlights
# sort: +date | -date | +shuffle-hourly | +shuffle-daily | +shuffle-weekly | +shuffle-monthly
# limit: integer number of photos to limit to (for testing large collections)
# expand_subdirs: true | false (expand subdirs of `dirs` to collections)
# expand_sort: asc | desc (order of expanded subdirs)
Expand Down
5 changes: 5 additions & 0 deletions internal/collection/collection.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type Collection struct {
Id string `json:"id"`
Name string `json:"name"`
Layout string `json:"layout"`
Sort string `json:"sort"`
Limit int `json:"limit"`
IndexLimit int `json:"index_limit"`
ExpandSubdirs bool `json:"expand_subdirs"`
Expand All @@ -30,6 +31,10 @@ type Collection struct {
func (collection *Collection) MakeValid() {
collection.Id = slug.Make(collection.Name)
collection.Layout = strings.ToUpper(collection.Layout)
// Add "+" prefix to sort if it doesn't have "+" or "-"
if collection.Sort != "" && !strings.HasPrefix(collection.Sort, "+") && !strings.HasPrefix(collection.Sort, "-") {
collection.Sort = "+" + collection.Sort
}
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MakeValid function adds a "+" prefix to the sort string if it doesn't start with "+" or "-", but it doesn't validate whether the sort value is actually valid (e.g., checking if it matches one of: date, shuffle-hourly, shuffle-daily, shuffle-weekly, shuffle-monthly). Invalid sort values will be silently prefixed and only fail later when OrderFromSort returns None.

Consider adding validation here to ensure the sort value is valid and provide early feedback to users with invalid configuration.

Suggested change
}
}
// Validate sort value to provide early feedback on invalid configuration.
if collection.Sort != "" {
sortKey := collection.Sort
if strings.HasPrefix(sortKey, "+") || strings.HasPrefix(sortKey, "-") {
sortKey = sortKey[1:]
}
switch sortKey {
case "date", "shuffle-hourly", "shuffle-daily", "shuffle-weekly", "shuffle-monthly":
// valid sort value
default:
log.Printf(
"Invalid sort value %q for collection %q; supported values are: date, shuffle-hourly, shuffle-daily, shuffle-weekly, shuffle-monthly",
sortKey,
collection.Name,
)
}
}

Copilot uses AI. Check for mistakes.
if collection.Limit > 0 && collection.IndexLimit == 0 {
collection.IndexLimit = collection.Limit
}
Expand Down
73 changes: 64 additions & 9 deletions internal/image/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,23 @@ func toUnixMs(t time.Time) int64 {
type ListOrder int32

const (
None ListOrder = iota
DateAsc ListOrder = iota
DateDesc ListOrder = iota
None ListOrder = iota
DateAsc
DateDesc
ShuffleHourly
ShuffleDaily
ShuffleWeekly
ShuffleMonthly
)

type ListOptions struct {
OrderBy ListOrder
Limit int
Expression search.Expression
Embedding clip.Embedding
Extensions []string
Batch int
OrderBy ListOrder
ShuffleSeed int64
Limit int
Expression search.Expression
Embedding clip.Embedding
Extensions []string
Batch int
}

type DirsFunc func(dirs []string)
Expand Down Expand Up @@ -1751,6 +1756,16 @@ func mergeSortedChannels(channels []<-chan SourcedInfo, order ListOrder, out cha
q = (*DateAscQueue)(&s)
case DateDesc:
q = (*DateDescQueue)(&s)
case ShuffleHourly, ShuffleDaily, ShuffleWeekly, ShuffleMonthly:
// Shuffle is already done at SQL level, no need for sorted merge
// Just concatenate the channels
for _, ch := range channels {
for info := range ch {
out <- info
}
}

return nil
default:
return fmt.Errorf("unsupported listing order")
}
Expand Down Expand Up @@ -1930,6 +1945,27 @@ func (source *Database) listWithPrefixIds(prefixIds []int64, options ListOptions
sql += `
ORDER BY created_at_unix DESC
`
case ShuffleHourly, ShuffleDaily, ShuffleWeekly, ShuffleMonthly:
// Seeded Linear Congruential Generator (LCG) shuffle formula.
// This produces a deterministic pseudorandom ordering based on a seed parameter.
//
// Formula: (id * A + seed * B) mod M
// - A = 2654435761 (Knuth's multiplicative hash constant, a large prime for good distribution)
// - B = 1664525 (LCG multiplier from Numerical Recipes, provides excellent mixing)
// - M = 4294967296 (2^32, ensures wrapping fits in SQLite's integer range)
//
// Key properties verified through testing:
// - Small seed changes (e.g., 1000 → 1001) produce completely different orderings
// - Same seed always produces identical ordering (deterministic)
// - Works with timestamp seeds from Go's time.Now().UnixMilli()
// - Handles edge cases (seed=0, very large seeds) correctly
// - Good distribution quality across the result set
//
// The seed parameter (?) is bound from options.ShuffleSeed, typically derived
// from truncated timestamps for hourly/daily/weekly/monthly shuffle granularity.
sql += `
ORDER BY (id * 2654435761 + ? * 1664525) % 4294967296
`
default:
panic("Unsupported listing order")
}
Expand Down Expand Up @@ -1976,6 +2012,13 @@ func (source *Database) listWithPrefixIds(prefixIds []int64, options ListOptions
bindIndex++
}

// Bind shuffle seed if needed
switch options.OrderBy {
case ShuffleHourly, ShuffleDaily, ShuffleWeekly, ShuffleMonthly:
stmt.BindInt64(bindIndex, options.ShuffleSeed)
bindIndex++
}

if options.Limit > 0 {
stmt.BindInt64(bindIndex, (int64)(options.Limit))
}
Expand Down Expand Up @@ -2152,6 +2195,11 @@ func (source *Database) ListWithEmbeddings(dirs []string, options ListOptions) <
sql += `
ORDER BY created_at_unix DESC
`
case ShuffleHourly, ShuffleDaily, ShuffleWeekly, ShuffleMonthly:
// Same seeded LCG shuffle formula as in listWithPrefixIds (see comment there for details)
sql += `
ORDER BY (id * 2654435761 + ? * 1664525) % 4294967296
`
default:
panic("Unsupported listing order")
}
Expand All @@ -2174,6 +2222,13 @@ func (source *Database) ListWithEmbeddings(dirs []string, options ListOptions) <
bindIndex++
}

// Bind shuffle seed if needed
switch options.OrderBy {
case ShuffleHourly, ShuffleDaily, ShuffleWeekly, ShuffleMonthly:
stmt.BindInt64(bindIndex, options.ShuffleSeed)
bindIndex++
}

if options.Limit > 0 {
stmt.BindInt64(bindIndex, (int64)(options.Limit))
}
Expand Down
47 changes: 25 additions & 22 deletions internal/layout/album.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,42 +26,45 @@ type AlbumEvent struct {

func LayoutAlbumEvent(layout Layout, rect render.Rect, event *AlbumEvent, scene *render.Scene, source *image.Source) render.Rect {

if event.FirstOnDay {
dateFormat := "Monday, Jan 2"
if event.First {
dateFormat = "Monday, Jan 2, 2006"
// Skip date/time headers when shuffle sort is active (dates are meaningless)
if !IsShuffleOrder(layout.Order) {
if event.FirstOnDay {
dateFormat := "Monday, Jan 2"
if event.First {
dateFormat = "Monday, Jan 2, 2006"
}
text := render.NewTextFromRect(
render.Rect{
X: rect.X,
Y: rect.Y,
W: rect.W,
H: 40,
},
&scene.Fonts.Header,
event.StartTime.Format(dateFormat),
)
text.VAlign = canvas.Bottom
scene.Texts = append(scene.Texts, text)
rect.Y += text.Sprite.Rect.H
}

font := scene.Fonts.Main.Face(50, canvas.Black, canvas.FontRegular, canvas.FontNormal)
time := event.StartTime.Format("15:00")
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The naming "time" shadows the imported package "time" from line 11. This variable should be renamed to avoid confusion, such as "timeStr" or "formattedTime", following Go best practices to avoid shadowing standard library packages.

Copilot uses AI. Check for mistakes.
text := render.NewTextFromRect(
render.Rect{
X: rect.X,
Y: rect.Y,
W: rect.W,
H: 40,
},
&scene.Fonts.Header,
event.StartTime.Format(dateFormat),
&font,
time,
)
text.VAlign = canvas.Bottom
scene.Texts = append(scene.Texts, text)
rect.Y += text.Sprite.Rect.H
}

font := scene.Fonts.Main.Face(50, canvas.Black, canvas.FontRegular, canvas.FontNormal)
time := event.StartTime.Format("15:00")
text := render.NewTextFromRect(
render.Rect{
X: rect.X,
Y: rect.Y,
W: rect.W,
H: 40,
},
&font,
time,
)
text.VAlign = canvas.Bottom
scene.Texts = append(scene.Texts, text)
rect.Y += text.Sprite.Rect.H

newBounds := addSectionToScene(&event.Section, scene, rect, layout, source)

rect.Y = newBounds.Y + newBounds.H
Expand Down
28 changes: 25 additions & 3 deletions internal/layout/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,13 @@ const (
type Order int

const (
None Order = iota
DateAsc Order = iota
DateDesc Order = iota
None Order = iota
DateAsc
DateDesc
ShuffleHourly
ShuffleDaily
ShuffleWeekly
ShuffleMonthly
)

func OrderFromSort(s string) Order {
Expand All @@ -42,11 +46,29 @@ func OrderFromSort(s string) Order {
return DateAsc
case "-date":
return DateDesc
case "+shuffle-hourly":
return ShuffleHourly
case "+shuffle-daily":
return ShuffleDaily
case "+shuffle-weekly":
return ShuffleWeekly
case "+shuffle-monthly":
return ShuffleMonthly
default:
return None
}
}

// IsShuffleOrder returns true if the order is any shuffle type
func IsShuffleOrder(order Order) bool {
switch order {
case ShuffleHourly, ShuffleDaily, ShuffleWeekly, ShuffleMonthly:
return true
default:
return false
}
}

type Layout struct {
Type Type `json:"type"`
Order Order `json:"order"`
Expand Down
30 changes: 30 additions & 0 deletions internal/layout/common_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package layout

import (
"photofield/internal/layout/shuffle"
"testing"
)

// TestShuffleConstantsMatchRender verifies that layout.Order shuffle constants
// match the corresponding shuffle package constants
func TestShuffleConstantsMatchRender(t *testing.T) {
tests := []struct {
name string
layoutConst Order
shuffleConst shuffle.Order
}{
{"ShuffleHourly", ShuffleHourly, shuffle.Hourly},
{"ShuffleDaily", ShuffleDaily, shuffle.Daily},
{"ShuffleWeekly", ShuffleWeekly, shuffle.Weekly},
{"ShuffleMonthly", ShuffleMonthly, shuffle.Monthly},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if int(tt.layoutConst) != int(tt.shuffleConst) {
t.Errorf("%s mismatch: layout=%d, shuffle=%d",
tt.name, int(tt.layoutConst), int(tt.shuffleConst))
}
})
}
}
3 changes: 2 additions & 1 deletion internal/layout/flex.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ func LayoutFlex(infos <-chan image.SourcedInfo, layout Layout, scene *render.Sce
var prevAuxTime time.Time
nogeo := strings.Contains(layout.Tweaks, "nogeo")
for info := range infos {
if !nogeo && source.Geo.Available() {
// Skip date/location headers when shuffle sort is active (dates are meaningless)
if !nogeo && source.Geo.Available() && !IsShuffleOrder(layout.Order) {
photoTime := info.DateTime
lastLocCheck := prevLocTime.Sub(photoTime)
if lastLocCheck < 0 {
Expand Down
3 changes: 2 additions & 1 deletion internal/layout/highlights.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ func LayoutHighlights(infos <-chan image.InfoEmb, layout Layout, scene *render.S
var prevInvNorm float32

for info := range infos {
if source.Geo.Available() {
// Skip date/location headers when shuffle sort is active (dates are meaningless)
if source.Geo.Available() && !IsShuffleOrder(layout.Order) {
photoTime := info.DateTime
lastLocCheck := prevLocTime.Sub(photoTime)
if lastLocCheck < 0 {
Expand Down
48 changes: 48 additions & 0 deletions internal/layout/shuffle/shuffle.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package shuffle

import "time"

// Order represents a shuffle interval type
type Order int

// Order constants for shuffle intervals
// These match the layout.Order enum values
const (
Hourly Order = 3
Daily Order = 4
Weekly Order = 5
Monthly Order = 6
)
Comment on lines +10 to +15
Copy link

Copilot AI Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded numeric constants for shuffle order types create a maintenance risk and tight coupling between packages. These constants must exactly match the layout.Order enum values (3, 4, 5, 6), but there's no compile-time enforcement of this requirement. If the layout.Order enum is modified in the future (e.g., by adding a new order type before the shuffle types), these constants would silently become incorrect.

Consider using the layout package constants directly instead of redefining them here, or using a more robust approach like having the layout package import from the shuffle package to define these constants in one place.

Copilot uses AI. Check for mistakes.

// TruncateTime truncates the given time based on the shuffle order type.
// Returns the truncated time for hourly, daily, weekly, or monthly intervals.
//
// The order parameter should be one of the shuffle order constants (Hourly, Daily, Weekly, Monthly).
// For invalid order values, returns zero time.
func TruncateTime(order Order, t time.Time) time.Time {
switch order {
case Hourly:
return t.Truncate(time.Hour)
case Daily:
y, m, day := t.Date()
loc := t.Location()
return time.Date(y, m, day, 0, 0, 0, 0, loc)
case Weekly:
// Truncate to Monday at midnight local time
y, m, day := t.Date()
loc := t.Location()
weekday := t.Weekday()
daysFromMonday := int(weekday) - 1
if daysFromMonday < 0 {
daysFromMonday = 6 // Sunday
}
mondayDate := time.Date(y, m, day-daysFromMonday, 0, 0, 0, 0, loc)
return mondayDate
case Monthly:
y, m, _ := t.Date()
loc := t.Location()
return time.Date(y, m, 1, 0, 0, 0, 0, loc)
default:
return time.Time{}
}
}
Loading
Loading