This commit is contained in:
WithoutPants 2025-12-04 18:27:04 -05:00 committed by GitHub
commit 71b074e25c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 285 additions and 154 deletions

View file

@ -1,31 +1,63 @@
package models package models
import ( import (
"fmt"
"time" "time"
"github.com/stashapp/stash/pkg/utils" "github.com/stashapp/stash/pkg/utils"
) )
type DatePrecision int
const (
// default precision is day
DatePrecisionDay DatePrecision = iota
DatePrecisionMonth
DatePrecisionYear
)
// Date wraps a time.Time with a format of "YYYY-MM-DD" // Date wraps a time.Time with a format of "YYYY-MM-DD"
type Date struct { type Date struct {
time.Time time.Time
Precision DatePrecision
} }
const dateFormat = "2006-01-02" var dateFormatPrecision = []string{
"2006-01-02",
"2006-01",
"2006",
}
func (d Date) String() string { func (d Date) String() string {
return d.Format(dateFormat) return d.Format(dateFormatPrecision[d.Precision])
} }
func (d Date) After(o Date) bool { func (d Date) After(o Date) bool {
return d.Time.After(o.Time) return d.Time.After(o.Time)
} }
// ParseDate uses utils.ParseDateStringAsTime to parse a string into a date. // ParseDate tries to parse the input string into a date using utils.ParseDateStringAsTime.
// If that fails, it attempts to parse the string with decreasing precision (month, then year).
// It returns a Date struct with the appropriate precision set, or an error if all parsing attempts fail.
func ParseDate(s string) (Date, error) { func ParseDate(s string) (Date, error) {
var errs []error
// default parse to day precision
ret, err := utils.ParseDateStringAsTime(s) ret, err := utils.ParseDateStringAsTime(s)
if err != nil { if err == nil {
return Date{}, err return Date{Time: ret, Precision: DatePrecisionDay}, nil
} }
return Date{Time: ret}, nil
errs = append(errs, err)
// try month and year precision
for i, format := range dateFormatPrecision[1:] {
ret, err := time.Parse(format, s)
if err == nil {
return Date{Time: ret, Precision: DatePrecision(i + 1)}, nil
}
errs = append(errs, err)
}
return Date{}, fmt.Errorf("failed to parse date %q: %v", s, errs)
} }

50
pkg/models/date_test.go Normal file
View file

@ -0,0 +1,50 @@
package models
import (
"testing"
"time"
)
func TestParseDateStringAsTime(t *testing.T) {
tests := []struct {
name string
input string
output Date
expectError bool
}{
// Full date formats (existing support)
{"RFC3339", "2014-01-02T15:04:05Z", Date{Time: time.Date(2014, 1, 2, 15, 4, 5, 0, time.UTC), Precision: DatePrecisionDay}, false},
{"Date only", "2014-01-02", Date{Time: time.Date(2014, 1, 2, 0, 0, 0, 0, time.UTC), Precision: DatePrecisionDay}, false},
{"Date with time", "2014-01-02 15:04:05", Date{Time: time.Date(2014, 1, 2, 15, 4, 5, 0, time.UTC), Precision: DatePrecisionDay}, false},
// Partial date formats (new support)
{"Year-Month", "2006-08", Date{Time: time.Date(2006, 8, 1, 0, 0, 0, 0, time.UTC), Precision: DatePrecisionMonth}, false},
{"Year only", "2014", Date{Time: time.Date(2014, 1, 1, 0, 0, 0, 0, time.UTC), Precision: DatePrecisionYear}, false},
// Invalid formats
{"Invalid format", "not-a-date", Date{}, true},
{"Empty string", "", Date{}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ParseDate(tt.input)
if tt.expectError {
if err == nil {
t.Errorf("Expected error for input %q, but got none", tt.input)
}
return
}
if err != nil {
t.Errorf("Unexpected error for input %q: %v", tt.input, err)
return
}
if !result.Time.Equal(tt.output.Time) || result.Precision != tt.output.Precision {
t.Errorf("For input %q, expected output %+v, got %+v", tt.input, tt.output, result)
}
})
}
}

View file

@ -34,7 +34,7 @@ const (
cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE"
) )
var appSchemaVersion uint = 74 var appSchemaVersion uint = 75
//go:embed migrations/*.sql //go:embed migrations/*.sql
var migrationsBox embed.FS var migrationsBox embed.FS

View file

@ -5,6 +5,7 @@ import (
"time" "time"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"gopkg.in/guregu/null.v4"
) )
const sqliteDateLayout = "2006-01-02" const sqliteDateLayout = "2006-01-02"
@ -54,12 +55,12 @@ func (d NullDate) Value() (driver.Value, error) {
return d.Date.Format(sqliteDateLayout), nil return d.Date.Format(sqliteDateLayout), nil
} }
func (d *NullDate) DatePtr() *models.Date { func (d *NullDate) DatePtr(precision null.Int) *models.Date {
if d == nil || !d.Valid { if d == nil || !d.Valid {
return nil return nil
} }
return &models.Date{Time: d.Date} return &models.Date{Time: d.Date, Precision: models.DatePrecision(precision.Int64)}
} }
func NullDateFromDatePtr(d *models.Date) NullDate { func NullDateFromDatePtr(d *models.Date) NullDate {
@ -68,3 +69,11 @@ func NullDateFromDatePtr(d *models.Date) NullDate {
} }
return NullDate{Date: d.Time, Valid: true} return NullDate{Date: d.Time, Valid: true}
} }
func datePrecisionFromDatePtr(d *models.Date) null.Int {
if d == nil {
// default to day precision
return null.Int{}
}
return null.IntFrom(int64(d.Precision))
}

View file

@ -30,12 +30,13 @@ const (
) )
type galleryRow struct { type galleryRow struct {
ID int `db:"id" goqu:"skipinsert"` ID int `db:"id" goqu:"skipinsert"`
Title zero.String `db:"title"` Title zero.String `db:"title"`
Code zero.String `db:"code"` Code zero.String `db:"code"`
Date NullDate `db:"date"` Date NullDate `db:"date"`
Details zero.String `db:"details"` DatePrecision null.Int `db:"date_precision"`
Photographer zero.String `db:"photographer"` Details zero.String `db:"details"`
Photographer zero.String `db:"photographer"`
// expressed as 1-100 // expressed as 1-100
Rating null.Int `db:"rating"` Rating null.Int `db:"rating"`
Organized bool `db:"organized"` Organized bool `db:"organized"`
@ -50,6 +51,7 @@ func (r *galleryRow) fromGallery(o models.Gallery) {
r.Title = zero.StringFrom(o.Title) r.Title = zero.StringFrom(o.Title)
r.Code = zero.StringFrom(o.Code) r.Code = zero.StringFrom(o.Code)
r.Date = NullDateFromDatePtr(o.Date) r.Date = NullDateFromDatePtr(o.Date)
r.DatePrecision = datePrecisionFromDatePtr(o.Date)
r.Details = zero.StringFrom(o.Details) r.Details = zero.StringFrom(o.Details)
r.Photographer = zero.StringFrom(o.Photographer) r.Photographer = zero.StringFrom(o.Photographer)
r.Rating = intFromPtr(o.Rating) r.Rating = intFromPtr(o.Rating)
@ -74,7 +76,7 @@ func (r *galleryQueryRow) resolve() *models.Gallery {
ID: r.ID, ID: r.ID,
Title: r.Title.String, Title: r.Title.String,
Code: r.Code.String, Code: r.Code.String,
Date: r.Date.DatePtr(), Date: r.Date.DatePtr(r.DatePrecision),
Details: r.Details.String, Details: r.Details.String,
Photographer: r.Photographer.String, Photographer: r.Photographer.String,
Rating: nullIntPtr(r.Rating), Rating: nullIntPtr(r.Rating),
@ -102,7 +104,7 @@ type galleryRowRecord struct {
func (r *galleryRowRecord) fromPartial(o models.GalleryPartial) { func (r *galleryRowRecord) fromPartial(o models.GalleryPartial) {
r.setNullString("title", o.Title) r.setNullString("title", o.Title)
r.setNullString("code", o.Code) r.setNullString("code", o.Code)
r.setNullDate("date", o.Date) r.setNullDate("date", "date_precision", o.Date)
r.setNullString("details", o.Details) r.setNullString("details", o.Details)
r.setNullString("photographer", o.Photographer) r.setNullString("photographer", o.Photographer)
r.setNullInt("rating", o.Rating) r.setNullInt("rating", o.Rating)

View file

@ -32,11 +32,12 @@ const (
) )
type groupRow struct { type groupRow struct {
ID int `db:"id" goqu:"skipinsert"` ID int `db:"id" goqu:"skipinsert"`
Name zero.String `db:"name"` Name zero.String `db:"name"`
Aliases zero.String `db:"aliases"` Aliases zero.String `db:"aliases"`
Duration null.Int `db:"duration"` Duration null.Int `db:"duration"`
Date NullDate `db:"date"` Date NullDate `db:"date"`
DatePrecision null.Int `db:"date_precision"`
// expressed as 1-100 // expressed as 1-100
Rating null.Int `db:"rating"` Rating null.Int `db:"rating"`
StudioID null.Int `db:"studio_id,omitempty"` StudioID null.Int `db:"studio_id,omitempty"`
@ -56,6 +57,7 @@ func (r *groupRow) fromGroup(o models.Group) {
r.Aliases = zero.StringFrom(o.Aliases) r.Aliases = zero.StringFrom(o.Aliases)
r.Duration = intFromPtr(o.Duration) r.Duration = intFromPtr(o.Duration)
r.Date = NullDateFromDatePtr(o.Date) r.Date = NullDateFromDatePtr(o.Date)
r.DatePrecision = datePrecisionFromDatePtr(o.Date)
r.Rating = intFromPtr(o.Rating) r.Rating = intFromPtr(o.Rating)
r.StudioID = intFromPtr(o.StudioID) r.StudioID = intFromPtr(o.StudioID)
r.Director = zero.StringFrom(o.Director) r.Director = zero.StringFrom(o.Director)
@ -70,7 +72,7 @@ func (r *groupRow) resolve() *models.Group {
Name: r.Name.String, Name: r.Name.String,
Aliases: r.Aliases.String, Aliases: r.Aliases.String,
Duration: nullIntPtr(r.Duration), Duration: nullIntPtr(r.Duration),
Date: r.Date.DatePtr(), Date: r.Date.DatePtr(r.DatePrecision),
Rating: nullIntPtr(r.Rating), Rating: nullIntPtr(r.Rating),
StudioID: nullIntPtr(r.StudioID), StudioID: nullIntPtr(r.StudioID),
Director: r.Director.String, Director: r.Director.String,
@ -90,7 +92,7 @@ func (r *groupRowRecord) fromPartial(o models.GroupPartial) {
r.setNullString("name", o.Name) r.setNullString("name", o.Name)
r.setNullString("aliases", o.Aliases) r.setNullString("aliases", o.Aliases)
r.setNullInt("duration", o.Duration) r.setNullInt("duration", o.Duration)
r.setNullDate("date", o.Date) r.setNullDate("date", "date_precision", o.Date)
r.setNullInt("rating", o.Rating) r.setNullInt("rating", o.Rating)
r.setNullInt("studio_id", o.StudioID) r.setNullInt("studio_id", o.StudioID)
r.setNullString("director", o.Director) r.setNullString("director", o.Director)

View file

@ -34,15 +34,16 @@ type imageRow struct {
Title zero.String `db:"title"` Title zero.String `db:"title"`
Code zero.String `db:"code"` Code zero.String `db:"code"`
// expressed as 1-100 // expressed as 1-100
Rating null.Int `db:"rating"` Rating null.Int `db:"rating"`
Date NullDate `db:"date"` Date NullDate `db:"date"`
Details zero.String `db:"details"` DatePrecision null.Int `db:"date_precision"`
Photographer zero.String `db:"photographer"` Details zero.String `db:"details"`
Organized bool `db:"organized"` Photographer zero.String `db:"photographer"`
OCounter int `db:"o_counter"` Organized bool `db:"organized"`
StudioID null.Int `db:"studio_id,omitempty"` OCounter int `db:"o_counter"`
CreatedAt Timestamp `db:"created_at"` StudioID null.Int `db:"studio_id,omitempty"`
UpdatedAt Timestamp `db:"updated_at"` CreatedAt Timestamp `db:"created_at"`
UpdatedAt Timestamp `db:"updated_at"`
} }
func (r *imageRow) fromImage(i models.Image) { func (r *imageRow) fromImage(i models.Image) {
@ -51,6 +52,7 @@ func (r *imageRow) fromImage(i models.Image) {
r.Code = zero.StringFrom(i.Code) r.Code = zero.StringFrom(i.Code)
r.Rating = intFromPtr(i.Rating) r.Rating = intFromPtr(i.Rating)
r.Date = NullDateFromDatePtr(i.Date) r.Date = NullDateFromDatePtr(i.Date)
r.DatePrecision = datePrecisionFromDatePtr(i.Date)
r.Details = zero.StringFrom(i.Details) r.Details = zero.StringFrom(i.Details)
r.Photographer = zero.StringFrom(i.Photographer) r.Photographer = zero.StringFrom(i.Photographer)
r.Organized = i.Organized r.Organized = i.Organized
@ -74,7 +76,7 @@ func (r *imageQueryRow) resolve() *models.Image {
Title: r.Title.String, Title: r.Title.String,
Code: r.Code.String, Code: r.Code.String,
Rating: nullIntPtr(r.Rating), Rating: nullIntPtr(r.Rating),
Date: r.Date.DatePtr(), Date: r.Date.DatePtr(r.DatePrecision),
Details: r.Details.String, Details: r.Details.String,
Photographer: r.Photographer.String, Photographer: r.Photographer.String,
Organized: r.Organized, Organized: r.Organized,
@ -103,7 +105,7 @@ func (r *imageRowRecord) fromPartial(i models.ImagePartial) {
r.setNullString("title", i.Title) r.setNullString("title", i.Title)
r.setNullString("code", i.Code) r.setNullString("code", i.Code)
r.setNullInt("rating", i.Rating) r.setNullInt("rating", i.Rating)
r.setNullDate("date", i.Date) r.setNullDate("date", "date_precision", i.Date)
r.setNullString("details", i.Details) r.setNullString("details", i.Details)
r.setNullString("photographer", i.Photographer) r.setNullString("photographer", i.Photographer)
r.setBool("organized", i.Organized) r.setBool("organized", i.Organized)

View file

@ -0,0 +1,13 @@
ALTER TABLE "scenes" ADD COLUMN "date_precision" TINYINT;
ALTER TABLE "images" ADD COLUMN "date_precision" TINYINT;
ALTER TABLE "galleries" ADD COLUMN "date_precision" TINYINT;
ALTER TABLE "groups" ADD COLUMN "date_precision" TINYINT;
ALTER TABLE "performers" ADD COLUMN "birthdate_precision" TINYINT;
ALTER TABLE "performers" ADD COLUMN "death_date_precision" TINYINT;
UPDATE "scenes" SET "date_precision" = 0 WHERE "date" IS NOT NULL;
UPDATE "images" SET "date_precision" = 0 WHERE "date" IS NOT NULL;
UPDATE "galleries" SET "date_precision" = 0 WHERE "date" IS NOT NULL;
UPDATE "groups" SET "date_precision" = 0 WHERE "date" IS NOT NULL;
UPDATE "performers" SET "birthdate_precision" = 0 WHERE "birthdate" IS NOT NULL;
UPDATE "performers" SET "death_date_precision" = 0 WHERE "death_date" IS NOT NULL;

View file

@ -30,32 +30,34 @@ const (
) )
type performerRow struct { type performerRow struct {
ID int `db:"id" goqu:"skipinsert"` ID int `db:"id" goqu:"skipinsert"`
Name null.String `db:"name"` // TODO: make schema non-nullable Name null.String `db:"name"` // TODO: make schema non-nullable
Disambigation zero.String `db:"disambiguation"` Disambigation zero.String `db:"disambiguation"`
Gender zero.String `db:"gender"` Gender zero.String `db:"gender"`
Birthdate NullDate `db:"birthdate"` Birthdate NullDate `db:"birthdate"`
Ethnicity zero.String `db:"ethnicity"` BirthdatePrecision null.Int `db:"birthdate_precision"`
Country zero.String `db:"country"` Ethnicity zero.String `db:"ethnicity"`
EyeColor zero.String `db:"eye_color"` Country zero.String `db:"country"`
Height null.Int `db:"height"` EyeColor zero.String `db:"eye_color"`
Measurements zero.String `db:"measurements"` Height null.Int `db:"height"`
FakeTits zero.String `db:"fake_tits"` Measurements zero.String `db:"measurements"`
PenisLength null.Float `db:"penis_length"` FakeTits zero.String `db:"fake_tits"`
Circumcised zero.String `db:"circumcised"` PenisLength null.Float `db:"penis_length"`
CareerLength zero.String `db:"career_length"` Circumcised zero.String `db:"circumcised"`
Tattoos zero.String `db:"tattoos"` CareerLength zero.String `db:"career_length"`
Piercings zero.String `db:"piercings"` Tattoos zero.String `db:"tattoos"`
Favorite bool `db:"favorite"` Piercings zero.String `db:"piercings"`
CreatedAt Timestamp `db:"created_at"` Favorite bool `db:"favorite"`
UpdatedAt Timestamp `db:"updated_at"` CreatedAt Timestamp `db:"created_at"`
UpdatedAt Timestamp `db:"updated_at"`
// expressed as 1-100 // expressed as 1-100
Rating null.Int `db:"rating"` Rating null.Int `db:"rating"`
Details zero.String `db:"details"` Details zero.String `db:"details"`
DeathDate NullDate `db:"death_date"` DeathDate NullDate `db:"death_date"`
HairColor zero.String `db:"hair_color"` DeathDatePrecision null.Int `db:"death_date_precision"`
Weight null.Int `db:"weight"` HairColor zero.String `db:"hair_color"`
IgnoreAutoTag bool `db:"ignore_auto_tag"` Weight null.Int `db:"weight"`
IgnoreAutoTag bool `db:"ignore_auto_tag"`
// not used in resolution or updates // not used in resolution or updates
ImageBlob zero.String `db:"image_blob"` ImageBlob zero.String `db:"image_blob"`
@ -69,6 +71,7 @@ func (r *performerRow) fromPerformer(o models.Performer) {
r.Gender = zero.StringFrom(o.Gender.String()) r.Gender = zero.StringFrom(o.Gender.String())
} }
r.Birthdate = NullDateFromDatePtr(o.Birthdate) r.Birthdate = NullDateFromDatePtr(o.Birthdate)
r.BirthdatePrecision = datePrecisionFromDatePtr(o.Birthdate)
r.Ethnicity = zero.StringFrom(o.Ethnicity) r.Ethnicity = zero.StringFrom(o.Ethnicity)
r.Country = zero.StringFrom(o.Country) r.Country = zero.StringFrom(o.Country)
r.EyeColor = zero.StringFrom(o.EyeColor) r.EyeColor = zero.StringFrom(o.EyeColor)
@ -88,6 +91,7 @@ func (r *performerRow) fromPerformer(o models.Performer) {
r.Rating = intFromPtr(o.Rating) r.Rating = intFromPtr(o.Rating)
r.Details = zero.StringFrom(o.Details) r.Details = zero.StringFrom(o.Details)
r.DeathDate = NullDateFromDatePtr(o.DeathDate) r.DeathDate = NullDateFromDatePtr(o.DeathDate)
r.DeathDatePrecision = datePrecisionFromDatePtr(o.DeathDate)
r.HairColor = zero.StringFrom(o.HairColor) r.HairColor = zero.StringFrom(o.HairColor)
r.Weight = intFromPtr(o.Weight) r.Weight = intFromPtr(o.Weight)
r.IgnoreAutoTag = o.IgnoreAutoTag r.IgnoreAutoTag = o.IgnoreAutoTag
@ -98,7 +102,7 @@ func (r *performerRow) resolve() *models.Performer {
ID: r.ID, ID: r.ID,
Name: r.Name.String, Name: r.Name.String,
Disambiguation: r.Disambigation.String, Disambiguation: r.Disambigation.String,
Birthdate: r.Birthdate.DatePtr(), Birthdate: r.Birthdate.DatePtr(r.BirthdatePrecision),
Ethnicity: r.Ethnicity.String, Ethnicity: r.Ethnicity.String,
Country: r.Country.String, Country: r.Country.String,
EyeColor: r.EyeColor.String, EyeColor: r.EyeColor.String,
@ -115,7 +119,7 @@ func (r *performerRow) resolve() *models.Performer {
// expressed as 1-100 // expressed as 1-100
Rating: nullIntPtr(r.Rating), Rating: nullIntPtr(r.Rating),
Details: r.Details.String, Details: r.Details.String,
DeathDate: r.DeathDate.DatePtr(), DeathDate: r.DeathDate.DatePtr(r.DeathDatePrecision),
HairColor: r.HairColor.String, HairColor: r.HairColor.String,
Weight: nullIntPtr(r.Weight), Weight: nullIntPtr(r.Weight),
IgnoreAutoTag: r.IgnoreAutoTag, IgnoreAutoTag: r.IgnoreAutoTag,
@ -142,7 +146,7 @@ func (r *performerRowRecord) fromPartial(o models.PerformerPartial) {
r.setString("name", o.Name) r.setString("name", o.Name)
r.setNullString("disambiguation", o.Disambiguation) r.setNullString("disambiguation", o.Disambiguation)
r.setNullString("gender", o.Gender) r.setNullString("gender", o.Gender)
r.setNullDate("birthdate", o.Birthdate) r.setNullDate("birthdate", "birthdate_precision", o.Birthdate)
r.setNullString("ethnicity", o.Ethnicity) r.setNullString("ethnicity", o.Ethnicity)
r.setNullString("country", o.Country) r.setNullString("country", o.Country)
r.setNullString("eye_color", o.EyeColor) r.setNullString("eye_color", o.EyeColor)
@ -159,7 +163,7 @@ func (r *performerRowRecord) fromPartial(o models.PerformerPartial) {
r.setTimestamp("updated_at", o.UpdatedAt) r.setTimestamp("updated_at", o.UpdatedAt)
r.setNullInt("rating", o.Rating) r.setNullInt("rating", o.Rating)
r.setNullString("details", o.Details) r.setNullString("details", o.Details)
r.setNullDate("death_date", o.DeathDate) r.setNullDate("death_date", "death_date_precision", o.DeathDate)
r.setNullString("hair_color", o.HairColor) r.setNullString("hair_color", o.HairColor)
r.setNullInt("weight", o.Weight) r.setNullInt("weight", o.Weight)
r.setBool("ignore_auto_tag", o.IgnoreAutoTag) r.setBool("ignore_auto_tag", o.IgnoreAutoTag)

View file

@ -100,8 +100,9 @@ func (r *updateRecord) setNullTimestamp(destField string, v models.OptionalTime)
} }
} }
func (r *updateRecord) setNullDate(destField string, v models.OptionalDate) { func (r *updateRecord) setNullDate(destField string, precisionField string, v models.OptionalDate) {
if v.Set { if v.Set {
r.set(destField, NullDateFromDatePtr(v.Ptr())) r.set(destField, NullDateFromDatePtr(v.Ptr()))
r.set(precisionField, datePrecisionFromDatePtr(v.Ptr()))
} }
} }

View file

@ -76,12 +76,13 @@ ORDER BY files.size DESC;
` `
type sceneRow struct { type sceneRow struct {
ID int `db:"id" goqu:"skipinsert"` ID int `db:"id" goqu:"skipinsert"`
Title zero.String `db:"title"` Title zero.String `db:"title"`
Code zero.String `db:"code"` Code zero.String `db:"code"`
Details zero.String `db:"details"` Details zero.String `db:"details"`
Director zero.String `db:"director"` Director zero.String `db:"director"`
Date NullDate `db:"date"` Date NullDate `db:"date"`
DatePrecision null.Int `db:"date_precision"`
// expressed as 1-100 // expressed as 1-100
Rating null.Int `db:"rating"` Rating null.Int `db:"rating"`
Organized bool `db:"organized"` Organized bool `db:"organized"`
@ -102,6 +103,7 @@ func (r *sceneRow) fromScene(o models.Scene) {
r.Details = zero.StringFrom(o.Details) r.Details = zero.StringFrom(o.Details)
r.Director = zero.StringFrom(o.Director) r.Director = zero.StringFrom(o.Director)
r.Date = NullDateFromDatePtr(o.Date) r.Date = NullDateFromDatePtr(o.Date)
r.DatePrecision = datePrecisionFromDatePtr(o.Date)
r.Rating = intFromPtr(o.Rating) r.Rating = intFromPtr(o.Rating)
r.Organized = o.Organized r.Organized = o.Organized
r.StudioID = intFromPtr(o.StudioID) r.StudioID = intFromPtr(o.StudioID)
@ -127,7 +129,7 @@ func (r *sceneQueryRow) resolve() *models.Scene {
Code: r.Code.String, Code: r.Code.String,
Details: r.Details.String, Details: r.Details.String,
Director: r.Director.String, Director: r.Director.String,
Date: r.Date.DatePtr(), Date: r.Date.DatePtr(r.DatePrecision),
Rating: nullIntPtr(r.Rating), Rating: nullIntPtr(r.Rating),
Organized: r.Organized, Organized: r.Organized,
StudioID: nullIntPtr(r.StudioID), StudioID: nullIntPtr(r.StudioID),
@ -159,7 +161,7 @@ func (r *sceneRowRecord) fromPartial(o models.ScenePartial) {
r.setNullString("code", o.Code) r.setNullString("code", o.Code)
r.setNullString("details", o.Details) r.setNullString("details", o.Details)
r.setNullString("director", o.Director) r.setNullString("director", o.Director)
r.setNullDate("date", o.Date) r.setNullDate("date", "date_precision", o.Date)
r.setNullInt("rating", o.Rating) r.setNullInt("rating", o.Rating)
r.setBool("organized", o.Organized) r.setBool("organized", o.Organized)
r.setNullInt("studio_id", o.StudioID) r.setNullInt("studio_id", o.StudioID)

View file

@ -23,17 +23,5 @@ func ParseDateStringAsTime(dateString string) (time.Time, error) {
return t, nil return t, nil
} }
// Support partial dates: year-month format
t, e = time.Parse("2006-01", dateString)
if e == nil {
return t, nil
}
// Support partial dates: year only format
t, e = time.Parse("2006", dateString)
if e == nil {
return t, nil
}
return time.Time{}, fmt.Errorf("ParseDateStringAsTime failed: dateString <%s>", dateString) return time.Time{}, fmt.Errorf("ParseDateStringAsTime failed: dateString <%s>", dateString)
} }

View file

@ -2,7 +2,6 @@ package utils
import ( import (
"testing" "testing"
"time"
) )
func TestParseDateStringAsTime(t *testing.T) { func TestParseDateStringAsTime(t *testing.T) {
@ -16,13 +15,11 @@ func TestParseDateStringAsTime(t *testing.T) {
{"Date only", "2014-01-02", false}, {"Date only", "2014-01-02", false},
{"Date with time", "2014-01-02 15:04:05", false}, {"Date with time", "2014-01-02 15:04:05", false},
// Partial date formats (new support)
{"Year-Month", "2006-08", false},
{"Year only", "2014", false},
// Invalid formats // Invalid formats
{"Invalid format", "not-a-date", true}, {"Invalid format", "not-a-date", true},
{"Empty string", "", true}, {"Empty string", "", true},
{"Year-Month", "2006-08", true},
{"Year only", "2014", true},
} }
for _, tt := range tests { for _, tt := range tests {
@ -44,37 +41,3 @@ func TestParseDateStringAsTime(t *testing.T) {
}) })
} }
} }
func TestParseDateStringAsTime_YearOnly(t *testing.T) {
result, err := ParseDateStringAsTime("2014")
if err != nil {
t.Fatalf("Failed to parse year-only date: %v", err)
}
if result.Year() != 2014 {
t.Errorf("Expected year 2014, got %d", result.Year())
}
if result.Month() != time.January {
t.Errorf("Expected month January, got %s", result.Month())
}
if result.Day() != 1 {
t.Errorf("Expected day 1, got %d", result.Day())
}
}
func TestParseDateStringAsTime_YearMonth(t *testing.T) {
result, err := ParseDateStringAsTime("2006-08")
if err != nil {
t.Fatalf("Failed to parse year-month date: %v", err)
}
if result.Year() != 2006 {
t.Errorf("Expected year 2006, got %d", result.Year())
}
if result.Month() != time.August {
t.Errorf("Expected month August, got %s", result.Month())
}
if result.Day() != 1 {
t.Errorf("Expected day 1, got %d", result.Day())
}
}

View file

@ -6,7 +6,7 @@ import {
RouteComponentProps, RouteComponentProps,
Redirect, Redirect,
} from "react-router-dom"; } from "react-router-dom";
import { FormattedDate, FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { import {
@ -44,6 +44,7 @@ import { useRatingKeybinds } from "src/hooks/keybinds";
import { useConfigurationContext } from "src/hooks/Config"; import { useConfigurationContext } from "src/hooks/Config";
import { TruncatedText } from "src/components/Shared/TruncatedText"; import { TruncatedText } from "src/components/Shared/TruncatedText";
import { goBackOrReplace } from "src/utils/history"; import { goBackOrReplace } from "src/utils/history";
import { FormattedDate } from "src/components/Shared/Date";
interface IProps { interface IProps {
gallery: GQL.GalleryDataFragment; gallery: GQL.GalleryDataFragment;
@ -410,11 +411,7 @@ export const GalleryPage: React.FC<IProps> = ({ gallery, add }) => {
<div className="gallery-subheader"> <div className="gallery-subheader">
{!!gallery.date && ( {!!gallery.date && (
<span className="date" data-value={gallery.date}> <span className="date" data-value={gallery.date}>
<FormattedDate <FormattedDate value={gallery.date} />
value={gallery.date}
format="long"
timeZone="utc"
/>
</span> </span>
)} )}
</div> </div>

View file

@ -102,7 +102,7 @@ const GalleryWallCard: React.FC<IProps> = ({ gallery }) => {
)} )}
<TruncatedText text={performers.join(", ")} /> <TruncatedText text={performers.join(", ")} />
<div> <div>
{gallery.date && TextUtils.formatDate(intl, gallery.date)} {gallery.date && TextUtils.formatFuzzyDate(intl, gallery.date)}
</div> </div>
</Link> </Link>
</footer> </footer>

View file

@ -65,7 +65,7 @@ export const GroupDetailsPanel: React.FC<IGroupDetailsPanel> = ({
/> />
<DetailItem <DetailItem
id="date" id="date"
value={group.date ? TextUtils.formatDate(intl, group.date) : ""} value={group.date ? TextUtils.formatFuzzyDate(intl, group.date) : ""}
fullWidth={fullWidth} fullWidth={fullWidth}
/> />
<DetailItem <DetailItem

View file

@ -1,6 +1,6 @@
import { Tab, Nav, Dropdown } from "react-bootstrap"; import { Tab, Nav, Dropdown } from "react-bootstrap";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { FormattedDate, FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { useHistory, Link, RouteComponentProps } from "react-router-dom"; import { useHistory, Link, RouteComponentProps } from "react-router-dom";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { import {
@ -35,6 +35,7 @@ import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import cx from "classnames"; import cx from "classnames";
import { TruncatedText } from "src/components/Shared/TruncatedText"; import { TruncatedText } from "src/components/Shared/TruncatedText";
import { goBackOrReplace } from "src/utils/history"; import { goBackOrReplace } from "src/utils/history";
import { FormattedDate } from "src/components/Shared/Date";
interface IProps { interface IProps {
image: GQL.ImageDataFragment; image: GQL.ImageDataFragment;
@ -319,13 +320,7 @@ const ImagePage: React.FC<IProps> = ({ image }) => {
<div className="image-subheader"> <div className="image-subheader">
<span className="date" data-value={image.date}> <span className="date" data-value={image.date}>
{!!image.date && ( {!!image.date && <FormattedDate value={image.date} />}
<FormattedDate
value={image.date}
format="long"
timeZone="utc"
/>
)}
</span> </span>
{resolution ? ( {resolution ? (
<span className="resolution" data-value={resolution}> <span className="resolution" data-value={resolution}>

View file

@ -89,7 +89,10 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> =
} }
title={ title={
!fullWidth !fullWidth
? TextUtils.formatDate(intl, performer.birthdate ?? undefined) ? TextUtils.formatFuzzyDate(
intl,
performer.birthdate ?? undefined
)
: "" : ""
} }
fullWidth={fullWidth} fullWidth={fullWidth}
@ -218,7 +221,7 @@ export const CompressedPerformerDetailsPanel: React.FC<IPerformerDetails> =
<span className="detail-divider">/</span> <span className="detail-divider">/</span>
<span <span
className="performer-age" className="performer-age"
title={TextUtils.formatDate( title={TextUtils.formatFuzzyDate(
intl, intl,
performer.birthdate ?? undefined performer.birthdate ?? undefined
)} )}

View file

@ -116,7 +116,7 @@ export const PerformerListTable: React.FC<IPerformerListTableProps> = (
<span <span
title={ title={
performer.birthdate performer.birthdate
? TextUtils.formatDate(intl, performer.birthdate ?? undefined) ? TextUtils.formatFuzzyDate(intl, performer.birthdate ?? undefined)
: "" : ""
} }
> >

View file

@ -6,7 +6,7 @@ import React, {
useRef, useRef,
useLayoutEffect, useLayoutEffect,
} from "react"; } from "react";
import { FormattedDate, FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { Link, RouteComponentProps } from "react-router-dom"; import { Link, RouteComponentProps } from "react-router-dom";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
@ -51,6 +51,7 @@ import cx from "classnames";
import { TruncatedText } from "src/components/Shared/TruncatedText"; import { TruncatedText } from "src/components/Shared/TruncatedText";
import { PatchComponent, PatchContainerComponent } from "src/patch"; import { PatchComponent, PatchContainerComponent } from "src/patch";
import { goBackOrReplace } from "src/utils/history"; import { goBackOrReplace } from "src/utils/history";
import { FormattedDate } from "src/components/Shared/Date";
const SubmitStashBoxDraft = lazyComponent( const SubmitStashBoxDraft = lazyComponent(
() => import("src/components/Dialogs/SubmitDraft") () => import("src/components/Dialogs/SubmitDraft")
@ -613,13 +614,7 @@ const ScenePage: React.FC<IProps> = PatchComponent("ScenePage", (props) => {
<div className="scene-subheader"> <div className="scene-subheader">
<span className="date" data-value={scene.date}> <span className="date" data-value={scene.date}>
{!!scene.date && ( {!!scene.date && <FormattedDate value={scene.date} />}
<FormattedDate
value={scene.date}
format="long"
timeZone="utc"
/>
)}
</span> </span>
<VideoFrameRateResolution <VideoFrameRateResolution
width={file?.width} width={file?.width}

View file

@ -107,7 +107,9 @@ export const SceneWallItem: React.FC<
/> />
)} )}
<TruncatedText text={performers.join(", ")} /> <TruncatedText text={performers.join(", ")} />
<div>{scene.date && TextUtils.formatDate(intl, scene.date)}</div> <div>
{scene.date && TextUtils.formatFuzzyDate(intl, scene.date)}
</div>
</Link> </Link>
</footer> </footer>
</div> </div>

View file

@ -0,0 +1,36 @@
import React from "react";
import { FormattedDate as IntlDate } from "react-intl";
import { PatchComponent } from "src/patch";
// wraps FormattedDate to handle year or year/month dates
export const FormattedDate: React.FC<{
value: string | number | Date | undefined;
}> = PatchComponent("Date", ({ value }) => {
if (typeof value === "string") {
// try parsing as year or year/month
const yearMatch = value.match(/^(\d{4})$/);
if (yearMatch) {
const year = parseInt(yearMatch[1], 10);
return (
<IntlDate value={Date.UTC(year, 0)} year="numeric" timeZone="utc" />
);
}
const yearMonthMatch = value.match(/^(\d{4})-(\d{2})$/);
if (yearMonthMatch) {
const year = parseInt(yearMonthMatch[1], 10);
const month = parseInt(yearMonthMatch[2], 10) - 1;
return (
<IntlDate
value={Date.UTC(year, month)}
year="numeric"
month="long"
timeZone="utc"
/>
);
}
}
return <IntlDate value={value} format="long" timeZone="utc" />;
});

View file

@ -336,8 +336,10 @@ function dateTimeToString(date: Date) {
const getAge = (dateString?: string | null, fromDateString?: string | null) => { const getAge = (dateString?: string | null, fromDateString?: string | null) => {
if (!dateString) return 0; if (!dateString) return 0;
const birthdate = stringToDate(dateString); const birthdate = stringToFuzzyDate(dateString);
const fromDate = fromDateString ? stringToDate(fromDateString) : new Date(); const fromDate = fromDateString
? stringToFuzzyDate(fromDateString)
: new Date();
if (!birthdate || !fromDate) return 0; if (!birthdate || !fromDate) return 0;
@ -459,6 +461,38 @@ const formatDate = (intl: IntlShape, date?: string, utc = true) => {
}); });
}; };
const formatFuzzyDate = (intl: IntlShape, date?: string, utc = true) => {
if (!date) {
return "";
}
// handle year or year/month dates
const yearMatch = date.match(/^(\d{4})$/);
if (yearMatch) {
const year = parseInt(yearMatch[1], 10);
return intl.formatDate(Date.UTC(year, 0), {
year: "numeric",
timeZone: utc ? "utc" : undefined,
});
}
const yearMonthMatch = date.match(/^(\d{4})-(\d{2})$/);
if (yearMonthMatch) {
const year = parseInt(yearMonthMatch[1], 10);
const month = parseInt(yearMonthMatch[2], 10) - 1;
return intl.formatDate(Date.UTC(year, month), {
year: "numeric",
month: "long",
timeZone: utc ? "utc" : undefined,
});
}
return intl.formatDate(date, {
format: "long",
timeZone: utc ? "utc" : undefined,
});
};
const formatDateTime = (intl: IntlShape, dateTime?: string, utc = false) => const formatDateTime = (intl: IntlShape, dateTime?: string, utc = false) =>
`${formatDate(intl, dateTime, utc)} ${intl.formatTime(dateTime, { `${formatDate(intl, dateTime, utc)} ${intl.formatTime(dateTime, {
timeZone: utc ? "utc" : undefined, timeZone: utc ? "utc" : undefined,
@ -519,6 +553,7 @@ const TextUtils = {
sanitiseURL, sanitiseURL,
domainFromURL, domainFromURL,
formatDate, formatDate,
formatFuzzyDate,
formatDateTime, formatDateTime,
secondsAsTimeString, secondsAsTimeString,
abbreviateCounter, abbreviateCounter,