Metadata: Use "Local" as identifier for the local time zone #4946

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-04-16 17:12:37 +02:00
parent dddfee3920
commit 5f32d75700
23 changed files with 186 additions and 111 deletions

View File

@@ -174,6 +174,10 @@ export default class Config {
$event.publish("dialog.update", { values });
}
if (values.DefaultLocale && options.DefaultLocale !== values.DefaultLocale) {
options.SetDefaultLocale(values.DefaultLocale);
}
for (let key in values) {
if (values.hasOwnProperty(key) && values[key] != null) {
this.set(key, values[key]);

View File

@@ -18,7 +18,6 @@ export const Locale = () => {
export let Options = [
{
text: "English", // English
translated: "English",
value: "en",
},
{

View File

@@ -43,6 +43,7 @@ export const YearUnknown = -1;
export const MonthUnknown = -1;
export const DayUnknown = -1;
export const TimeZoneUTC = "UTC";
export const TimeZoneLocal = "Local";
export let BatchSize = 156;
@@ -788,8 +789,8 @@ export class Photo extends RestModel {
month: formats.long,
year: formats.num,
});
} else if (timeZone) {
return this.localDate().toLocaleString(showTimeZone ? formats.DATE_FULL_TZ : formats.DATE_FULL);
} else if (showTimeZone && timeZone && timeZone !== TimeZoneLocal) {
return this.localDate().toLocaleString(formats.DATE_FULL_TZ);
}
return this.localDate().toLocaleString(DateTime.DATE_HUGE);

View File

@@ -35,7 +35,7 @@ export const GmtOffsets = [
export const TimeZones = (defaultName) =>
[
{ ID: "", Name: defaultName ? defaultName : $gettext("Local Time") },
{ ID: "Local", Name: defaultName ? defaultName : $gettext("Local") },
{ ID: "UTC", Name: "UTC" },
]
.concat(timeZonesNames)
@@ -114,13 +114,34 @@ export const MonthsShort = () => {
return result;
};
// Specifies the default language locale.
export let DefaultLocale = "en";
// Change the default language locale.
export const SetDefaultLocale = (locale) => {
if (!locale || locale === DefaultLocale) {
return;
}
DefaultLocale = FindLocale(locale);
};
// Available locales sorted by region and alphabet.
export const Languages = () => (window.__LOCALES__ ? window.__LOCALES__ : locales.Options);
// Returns the language name (text) and locale (value) to use when no other choice is available.
export const FallbackLanguage = () => {
if (locales?.Options?.length > 0) {
return locales.Options[0];
}
return { text: "English", value: "en" };
};
// Finds the best matching language by locale.
export const FindLanguage = (locale) => {
if (!locale || locale.length < 2) {
return null;
locale = DefaultLocale;
}
let found;
@@ -144,8 +165,10 @@ export const FindLanguage = (locale) => {
}
};
// Specifies the default language locale string.
export var DefaultLocale = "en";
// Returns the fallback locale to use when no other choice is available.
export const FallbackLocale = () => {
return FallbackLanguage().value;
};
// Finds the best matching language locale based on the specified locale;
export const FindLocale = (locale) => {
@@ -159,8 +182,9 @@ export const FindLocale = (locale) => {
return language.value;
}
return DefaultLocale;
return FallbackLocale();
};
export const ItemsPerPage = () => [
{ text: "10", title: "10", value: 10 },
{ text: "20", title: "20", value: 20 },

View File

@@ -52,7 +52,7 @@
tabindex="2"
item-value="ID"
item-title="Name"
:items="options.TimeZones($gettext('Default'))"
:items="options.TimeZones($gettext('Local'))"
:label="$gettext('Time Zone')"
:menu-props="{ maxHeight: 346 }"
class="input-timezone"

View File

@@ -39,7 +39,6 @@ type ClientConfig struct {
ApiUri string `json:"apiUri"`
ContentUri string `json:"contentUri"`
VideoUri string `json:"videoUri"`
WallpaperUri string `json:"wallpaperUri"`
SiteUrl string `json:"siteUrl"`
SiteDomain string `json:"siteDomain"`
SiteAuthor string `json:"siteAuthor"`
@@ -53,6 +52,9 @@ type ClientConfig struct {
AppMode string `json:"appMode"`
AppIcon string `json:"appIcon"`
AppColor string `json:"appColor"`
DefaultLocale string `json:"defaultLocale"`
DefaultTimezone string `json:"defaultTimezone"`
WallpaperUri string `json:"wallpaperUri"`
Restart bool `json:"restart"`
Debug bool `json:"debug"`
Trace bool `json:"trace"`
@@ -295,6 +297,8 @@ func (c *Config) ClientPublic() *ClientConfig {
AppMode: c.AppMode(),
AppIcon: c.AppIcon(),
AppColor: c.AppColor(),
DefaultLocale: c.DefaultLocale(),
DefaultTimezone: c.DefaultTimezone().String(),
WallpaperUri: c.WallpaperUri(),
Version: c.Version(),
Copyright: c.Copyright(),
@@ -388,6 +392,8 @@ func (c *Config) ClientShare() *ClientConfig {
AppMode: c.AppMode(),
AppIcon: c.AppIcon(),
AppColor: c.AppColor(),
DefaultLocale: c.DefaultLocale(),
DefaultTimezone: c.DefaultTimezone().String(),
WallpaperUri: c.WallpaperUri(),
Version: c.Version(),
Copyright: c.Copyright(),
@@ -489,6 +495,8 @@ func (c *Config) ClientUser(withSettings bool) *ClientConfig {
AppMode: c.AppMode(),
AppIcon: c.AppIcon(),
AppColor: c.AppColor(),
DefaultLocale: c.DefaultLocale(),
DefaultTimezone: c.DefaultTimezone().String(),
WallpaperUri: c.WallpaperUri(),
Version: c.Version(),
Copyright: c.Copyright(),

View File

@@ -23,12 +23,12 @@ func (c *Config) DefaultLocale() string {
// DefaultTimezone returns the default time zone, e.g. for scheduling backups
func (c *Config) DefaultTimezone() *time.Location {
if c.options.DefaultTimezone == "" {
return time.UTC
return time.Local
}
// Returns time zone if a valid identifier name was provided and UTC otherwise.
if timeZone, err := time.LoadLocation(c.options.DefaultTimezone); err != nil {
return time.UTC
if timeZone, err := time.LoadLocation(c.options.DefaultTimezone); err != nil || timeZone == nil {
return time.Local
} else {
return timeZone
}

View File

@@ -502,7 +502,7 @@ var Flags = CliFlags{
Name: "default-timezone",
Aliases: []string{"tz"},
Usage: "default time zone `NAME`, e.g. for scheduling backups",
Value: time.UTC.String(),
Value: time.Local.String(),
EnvVars: EnvVars("DEFAULT_TIMEZONE"),
}}, {
Flag: &cli.StringFlag{

View File

@@ -213,4 +213,10 @@ var DialectMySQL = Migrations{
Stage: "pre",
Statements: []string{"ALTER TABLE auth_users_settings CHANGE COLUMN IF EXISTS default_page ui_start_page VARCHAR(64) DEFAULT 'default';"},
},
{
ID: "20250416-000001",
Dialect: "mysql",
Stage: "main",
Statements: []string{"UPDATE photos SET time_zone = 'Local' WHERE time_zone = '' OR time_zone IS NULL;"},
},
}

View File

@@ -129,4 +129,10 @@ var DialectSQLite3 = Migrations{
Stage: "pre",
Statements: []string{"ALTER TABLE auth_users_settings RENAME COLUMN default_page TO ui_start_page;"},
},
{
ID: "20250416-000001",
Dialect: "sqlite3",
Stage: "main",
Statements: []string{"UPDATE photos SET time_zone = 'Local' WHERE time_zone = '' OR time_zone IS NULL;"},
},
}

View File

@@ -0,0 +1 @@
UPDATE photos SET time_zone = 'Local' WHERE time_zone = '' OR time_zone IS NULL;

View File

@@ -0,0 +1 @@
UPDATE photos SET time_zone = 'Local' WHERE time_zone = '' OR time_zone IS NULL;

View File

@@ -61,7 +61,7 @@ type Photo struct {
PhotoPrivate bool `json:"Private" yaml:"Private,omitempty"`
PhotoScan bool `json:"Scan" yaml:"Scan,omitempty"`
PhotoPanorama bool `json:"Panorama" yaml:"Panorama,omitempty"`
TimeZone string `gorm:"type:VARBINARY(64);" json:"TimeZone" yaml:"TimeZone,omitempty"`
TimeZone string `gorm:"type:VARBINARY(64);default:'Local'" json:"TimeZone" yaml:"TimeZone,omitempty"`
PlaceID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"PlaceID" yaml:"-"`
PlaceSrc string `gorm:"type:VARBINARY(8);" json:"PlaceSrc" yaml:"PlaceSrc,omitempty"`
CellID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"CellID" yaml:"-"`

View File

@@ -13,7 +13,7 @@ func (m *Photo) TrustedTime() bool {
return false
} else if m.TakenAt.IsZero() || m.TakenAtLocal.IsZero() {
return false
} else if m.TimeZone == "" {
} else if m.TimeZone == "" || m.TimeZone == time.Local.String() {
return false
}
@@ -21,37 +21,50 @@ func (m *Photo) TrustedTime() bool {
}
// SetTakenAt changes the photo date if not empty and from the same source.
func (m *Photo) SetTakenAt(taken, local time.Time, zone, source string) {
if taken.IsZero() || taken.Year() < 1000 || taken.Year() > txt.YearMax {
func (m *Photo) SetTakenAt(utc, local time.Time, zone, source string) {
if utc.IsZero() || utc.Year() < 1000 || utc.Year() > txt.YearMax {
return
}
// Prevent the existing time from being overwritten by lower priority sources.
if SrcPriority[source] < SrcPriority[m.TakenSrc] && !m.TakenAt.IsZero() {
return
}
// Remove time zone if time was extracted from file name.
if source == SrcName {
zone = ""
}
// Round times to avoid jitter.
taken = taken.UTC().Truncate(time.Second)
utc = utc.UTC().Truncate(time.Second)
// Default local time to taken if zero or invalid.
if local.IsZero() || local.Year() < 1000 {
local = taken
local = utc
} else {
local = local.Truncate(time.Second)
}
// If no zone is specified, assume the current zone or try to determine
// the time zone based on the time offset. Otherwise, default to Local.
if source == SrcName && m.TimeZone == "" {
// Assume Local timezone if the time was extracted from a filename.
zone = time.Local.String()
} else if zone == "" {
if m.TimeZone != "" {
zone = m.TimeZone
} else if !utc.Equal(local) {
zone = txt.UtcOffset(utc, local, "")
}
if zone == "" {
zone = time.Local.String()
}
}
// Don't update older date.
if SrcPriority[source] <= SrcPriority[SrcAuto] && !m.TakenAt.IsZero() && taken.After(m.TakenAt) {
if SrcPriority[source] <= SrcPriority[SrcAuto] && !m.TakenAt.IsZero() && utc.After(m.TakenAt) {
return
}
// Set UTC time and date source.
m.TakenAt = taken
m.TakenAt = utc
m.TakenAtLocal = local
m.TakenSrc = source
@@ -66,7 +79,7 @@ func (m *Photo) SetTakenAt(taken, local time.Time, zone, source string) {
m.TimeZone = zone
// Keep UTC?
if m.TimeZoneUTC() {
m.TakenAtLocal = taken
m.TakenAtLocal = utc
}
} else if m.TimeZone != "" {
// Apply existing time zone.
@@ -87,7 +100,7 @@ func (m *Photo) UpdateTimeZone(zone string) {
return
}
if SrcPriority[m.TakenSrc] >= SrcPriority[SrcManual] && m.TimeZone != "" {
if SrcPriority[m.TakenSrc] >= SrcPriority[SrcManual] && m.TimeZone != "" && m.TimeZone != time.Local.String() {
return
}

View File

@@ -20,7 +20,7 @@ func TestPhoto_TrustedTime(t *testing.T) {
})
t.Run("MissingTimeZone", func(t *testing.T) {
n := Now()
m := Photo{ID: 1, TakenAt: n, TakenAtLocal: n, TakenSrc: SrcMeta, TimeZone: ""}
m := Photo{ID: 1, TakenAt: n, TakenAtLocal: n, TakenSrc: SrcMeta, TimeZone: time.Local.String()}
assert.False(t, m.TrustedTime())
})
t.Run("SrcAuto", func(t *testing.T) {
@@ -56,21 +56,21 @@ func TestPhoto_SetTakenAt(t *testing.T) {
})
t.Run("FromName", func(t *testing.T) {
m := PhotoFixtures.Get("Photo15")
m.TimeZone = ""
m.TimeZone = time.Local.String()
m.TakenSrc = SrcAuto
assert.Equal(t, time.Date(2013, 11, 11, 9, 7, 18, 0, time.UTC), m.TakenAt)
assert.Equal(t, time.Date(2013, 11, 11, 9, 7, 18, 0, time.UTC), m.TakenAtLocal)
assert.Equal(t, "", m.TimeZone)
assert.Equal(t, "Local", m.TimeZone)
assert.Equal(t, SrcAuto, m.TakenSrc)
m.SetTakenAt(time.Date(2011, 12, 11, 9, 7, 18, 0, time.UTC),
time.Date(2019, 11, 11, 10, 7, 18, 0, time.UTC), "America/New_York", SrcName)
assert.Equal(t, "", m.TimeZone)
assert.Equal(t, "America/New_York", m.TimeZone)
assert.Equal(t, SrcName, m.TakenSrc)
assert.Equal(t, time.Date(2011, 12, 11, 9, 7, 18, 0, time.UTC), m.TakenAt)
assert.Equal(t, time.Date(2019, 11, 11, 15, 7, 18, 0, time.UTC), m.TakenAt)
assert.Equal(t, time.Date(2019, 11, 11, 10, 7, 18, 0, time.UTC), m.TakenAtLocal)
})
t.Run("Success", func(t *testing.T) {
@@ -81,6 +81,7 @@ func TestPhoto_SetTakenAt(t *testing.T) {
m.SetTakenAt(time.Date(2019, 12, 11, 9, 7, 18, 0, time.UTC),
time.Date(2019, 12, 11, 10, 7, 18, 0, time.UTC), "", SrcMeta)
assert.Equal(t, "Europe/Berlin", m.TimeZone)
assert.Equal(t, time.Date(2019, 12, 11, 9, 7, 18, 0, time.UTC), m.TakenAt)
assert.Equal(t, time.Date(2019, 12, 11, 10, 7, 18, 0, time.UTC), m.TakenAtLocal)
})
@@ -99,30 +100,32 @@ func TestPhoto_SetTakenAt(t *testing.T) {
assert.Equal(t, time.Date(2013, time.November, 11, 9, 7, 18, 0, time.UTC), m.TakenAt)
assert.Equal(t, time.Date(2013, time.November, 11, 9, 7, 18, 0, time.UTC), m.TakenAtLocal)
newTime := time.Date(2013, time.November, 11, 9, 7, 18, 0, time.UTC)
expected := time.Date(2013, time.November, 11, 8, 7, 18, 0, time.UTC)
utcTime := time.Date(2013, time.November, 11, 8, 7, 18, 0, time.UTC)
localTime := time.Date(2013, time.November, 11, 9, 7, 18, 0, time.UTC)
m.TimeZone = "Europe/Berlin"
m.SetTakenAt(newTime, newTime, "", SrcName)
m.SetTakenAt(utcTime, localTime, "", SrcName)
assert.Equal(t, expected, m.TakenAt)
assert.Equal(t, m.TimeZone, m.TimeZone)
assert.Equal(t, utcTime, m.TakenAt)
assert.Equal(t, m.GetTakenAt(), m.TakenAt)
assert.Equal(t, localTime, m.TakenAtLocal)
assert.Equal(t, m.GetTakenAtLocal(), m.TakenAtLocal)
})
t.Run("TimeZone", func(t *testing.T) {
m := PhotoFixtures.Get("Photo15")
zone := "Europe/Berlin"
timeZone := "Europe/Berlin"
loc := txt.TimeZone(timeZone)
utcTime := time.Date(2013, 11, 11, 9, 7, 18, 0, loc)
localTime := utcTime
loc := txt.TimeZone(zone)
m.SetTakenAt(utcTime, localTime, timeZone, SrcName)
newTime := time.Date(2013, 11, 11, 9, 7, 18, 0, loc)
m.SetTakenAt(newTime, newTime, zone, SrcName)
assert.Equal(t, newTime.UTC(), m.TakenAt)
assert.Equal(t, newTime, m.TakenAtLocal)
assert.Equal(t, timeZone, m.TimeZone)
assert.Equal(t, utcTime.UTC(), m.TakenAt)
assert.Equal(t, localTime, m.TakenAtLocal)
})
t.Run("InvalidYear", func(t *testing.T) {
m := PhotoFixtures.Get("Photo15")
@@ -167,7 +170,7 @@ func TestPhoto_SetTakenAt(t *testing.T) {
photo.SetTakenAt(time.Date(2014, 12, 11, 9, 7, 18, 0, time.UTC),
time.Date(2014, 12, 11, 10, 7, 18, 0, time.UTC), "", SrcManual)
assert.Equal(t, time.Date(2014, 12, 11, 9, 7, 18, 0, time.UTC), photo.TakenAt)
assert.Equal(t, time.Date(2014, 12, 11, 10, 07, 18, 0, time.UTC), photo.TakenAtLocal)
assert.Equal(t, time.Date(2014, 12, 11, 9, 7, 18, 0, time.UTC), photo.TakenAtLocal)
})
}
@@ -179,7 +182,8 @@ func TestPhoto_UpdateTimeZone(t *testing.T) {
takenJerusalemUtc := time.Date(2015, time.May, 17, 20, 2, 46, 0, time.UTC)
takenShanghaiUtc := time.Date(2015, time.May, 17, 15, 2, 46, 0, time.UTC)
assert.Equal(t, "", m.TimeZone)
assert.Equal(t, "Local", m.TimeZone)
assert.Equal(t, time.Local.String(), m.TimeZone)
assert.Equal(t, takenLocal, m.TakenAt)
assert.Equal(t, takenLocal, m.TakenAtLocal)
@@ -275,19 +279,20 @@ func TestPhoto_UpdateTimeZone(t *testing.T) {
t.Run("Europe/Berlin", func(t *testing.T) {
m := PhotoFixtures.Get("Photo12")
zone := "Europe/Berlin"
takenAt := m.TakenAt
takenAtLocal := m.TakenAtLocal
assert.Equal(t, "Local", m.TimeZone)
assert.Equal(t, takenAt, m.TakenAt)
assert.Equal(t, takenAtLocal, m.TakenAtLocal)
assert.Equal(t, "", m.TimeZone)
m.UpdateTimeZone(zone)
timeZone := "Europe/Berlin"
m.UpdateTimeZone(timeZone)
assert.Equal(t, timeZone, m.TimeZone)
assert.Equal(t, m.GetTakenAt(), m.TakenAt)
assert.Equal(t, takenAtLocal, m.TakenAtLocal)
assert.Equal(t, m.GetTakenAtLocal(), m.TakenAtLocal)
})
t.Run("America/New_York", func(t *testing.T) {

View File

@@ -175,7 +175,7 @@ var PhotoFixtures = PhotoMap{
PhotoPrivate: false,
PhotoScan: false,
PhotoPanorama: false,
TimeZone: "",
TimeZone: "Local",
Place: &UnknownPlace,
PlaceID: UnknownPlace.ID,
PlaceSrc: "",
@@ -235,7 +235,7 @@ var PhotoFixtures = PhotoMap{
PhotoPrivate: false,
PhotoScan: false,
PhotoPanorama: false,
TimeZone: "",
TimeZone: "Local",
Place: CellFixtures.Pointer("caravan park").Place,
PlaceID: CellFixtures.Pointer("caravan park").Place.ID,
PlaceSrc: "meta",
@@ -421,7 +421,7 @@ var PhotoFixtures = PhotoMap{
PhotoPrivate: true,
PhotoScan: false,
PhotoPanorama: false,
TimeZone: "",
TimeZone: "Local",
Place: &UnknownPlace,
PlaceID: UnknownPlace.ID,
PlaceSrc: "",
@@ -480,7 +480,7 @@ var PhotoFixtures = PhotoMap{
PhotoPrivate: false,
PhotoScan: false,
PhotoPanorama: false,
TimeZone: "",
TimeZone: "Local",
Place: &UnknownPlace,
PlaceID: UnknownPlace.ID,
PlaceSrc: "",
@@ -717,7 +717,7 @@ var PhotoFixtures = PhotoMap{
PhotoPrivate: false,
PhotoScan: false,
PhotoPanorama: false,
TimeZone: "",
TimeZone: "Local",
Place: PlaceFixtures.Pointer("emptyNameLongCity"),
PlaceID: PlaceFixtures.Pointer("emptyNameLongCity").ID,
PlaceSrc: "",
@@ -776,7 +776,7 @@ var PhotoFixtures = PhotoMap{
PhotoPrivate: false,
PhotoScan: false,
PhotoPanorama: false,
TimeZone: "",
TimeZone: "Local",
Place: PlaceFixtures.Pointer("emptyNameShortCity"),
PlaceID: PlaceFixtures.Pointer("emptyNameShortCity").ID,
PlaceSrc: "",
@@ -1012,7 +1012,7 @@ var PhotoFixtures = PhotoMap{
PhotoPrivate: false,
PhotoScan: true,
PhotoPanorama: false,
TimeZone: "",
TimeZone: "Local",
Place: &UnknownPlace,
PlaceID: UnknownPlace.ID,
PlaceSrc: "",
@@ -1071,7 +1071,7 @@ var PhotoFixtures = PhotoMap{
PhotoPrivate: false,
PhotoScan: false,
PhotoPanorama: false,
TimeZone: "",
TimeZone: "Local",
Place: PlaceFixtures.Pointer("mexico"),
PlaceID: PlaceFixtures.Pointer("mexico").ID,
PlaceSrc: SrcMeta,
@@ -1132,7 +1132,7 @@ var PhotoFixtures = PhotoMap{
PhotoPrivate: false,
PhotoScan: false,
PhotoPanorama: false,
TimeZone: "",
TimeZone: "Local",
Place: PlaceFixtures.Pointer("mexico"),
PlaceID: PlaceFixtures.Pointer("mexico").ID,
PlaceSrc: SrcMeta,
@@ -1194,7 +1194,7 @@ var PhotoFixtures = PhotoMap{
PhotoPrivate: false,
PhotoScan: false,
PhotoPanorama: false,
TimeZone: "",
TimeZone: "Local",
Place: &UnknownPlace,
PlaceID: UnknownPlace.ID,
PlaceSrc: "",
@@ -1253,7 +1253,7 @@ var PhotoFixtures = PhotoMap{
PhotoPrivate: false,
PhotoScan: false,
PhotoPanorama: false,
TimeZone: "",
TimeZone: "Local",
Place: &UnknownPlace,
PlaceID: UnknownPlace.ID,
PlaceSrc: "",
@@ -1371,7 +1371,7 @@ var PhotoFixtures = PhotoMap{
PhotoPrivate: false,
PhotoScan: false,
PhotoPanorama: false,
TimeZone: "",
TimeZone: "Local",
Place: PlaceFixtures.Pointer("mexico"),
PlaceID: PlaceFixtures.Pointer("mexico").ID,
PlaceSrc: SrcMeta,
@@ -1737,7 +1737,7 @@ var PhotoFixtures = PhotoMap{
PhotoPrivate: false,
PhotoScan: false,
PhotoPanorama: false,
TimeZone: "",
TimeZone: "Local",
Place: &UnknownPlace,
PlaceID: UnknownPlace.ID,
PlaceSrc: SrcAuto,
@@ -3558,7 +3558,7 @@ var PhotoFixtures = PhotoMap{
PhotoPrivate: false,
PhotoScan: false,
PhotoPanorama: false,
TimeZone: "",
TimeZone: "Local",
Place: &UnknownPlace,
PlaceID: UnknownPlace.ID,
PlaceSrc: "",

View File

@@ -295,14 +295,15 @@ func (m *Photo) UnknownCountry() bool {
return m.CountryCode() == UnknownCountry.ID
}
// GetTimeZone uses PhotoLat and PhotoLng to guess the time zone of the photo.
func (m *Photo) GetTimeZone() string {
result := "UTC"
// LocationTimeZone uses the GPS location to determine the time zone of the photo,
// and returns "Local" if no location is set or no time zone is found.
func (m *Photo) LocationTimeZone() string {
result := time.Local.String()
if m.HasLatLng() {
zones, err := tz.GetZone(tz.Point{
Lat: float64(m.PhotoLat),
Lon: float64(m.PhotoLng),
Lat: m.PhotoLat,
Lon: m.PhotoLng,
})
if err == nil && len(zones) > 0 {
@@ -388,7 +389,8 @@ func (m *Photo) UpdateLocation() (keywords []string, labels classify.Labels) {
m.PhotoCountry = loc.CountryCode()
if changed && m.TakenSrc != SrcManual {
m.UpdateTimeZone(m.GetTimeZone())
// Use GPS location to determine time zone.
m.UpdateTimeZone(m.LocationTimeZone())
}
FirstOrCreateCountry(NewCountry(loc.CountryCode(), loc.CountryName()))

View File

@@ -343,12 +343,12 @@ func TestPhoto_HasPlace(t *testing.T) {
})
}
func TestPhoto_GetTimeZone(t *testing.T) {
func TestPhoto_LocationTimeZone(t *testing.T) {
m := Photo{}
m.PhotoLat = 48.533905555
m.PhotoLng = 9.01
result := m.GetTimeZone()
result := m.LocationTimeZone()
if result != "Europe/Berlin" {
t.Fatalf("time zone should be Europe/Berlin: %s", result)
@@ -361,7 +361,7 @@ func TestPhoto_GetTakenAt(t *testing.T) {
m.PhotoLng = 9.01
m.TakenAt, _ = time.Parse(time.RFC3339, "2020-02-04T11:54:34Z")
m.TakenAtLocal, _ = time.Parse(time.RFC3339, "2020-02-04T11:54:34Z")
m.TimeZone = m.GetTimeZone()
m.TimeZone = m.LocationTimeZone()
if m.TimeZone != "Europe/Berlin" {
t.Fatalf("time zone should be Europe/Berlin: %s", m.TimeZone)

View File

@@ -311,7 +311,7 @@ func TestPhotosResults_Merged(t *testing.T) {
TakenAt: time.Time{},
TakenAtLocal: time.Time{},
TakenSrc: "",
TimeZone: "",
TimeZone: "Local",
PhotoUID: "",
PhotoPath: "",
PhotoName: "",
@@ -371,7 +371,7 @@ func TestPhotosResults_Merged(t *testing.T) {
TakenAt: time.Time{},
TakenAtLocal: time.Time{},
TakenSrc: "",
TimeZone: "",
TimeZone: "Local",
PhotoUID: "",
PhotoPath: "",
PhotoName: "",
@@ -442,7 +442,7 @@ func TestPhotosResults_UIDs(t *testing.T) {
TakenAt: time.Time{},
TakenAtLocal: time.Time{},
TakenSrc: "",
TimeZone: "",
TimeZone: "Local",
PhotoUID: "123",
PhotoPath: "",
PhotoName: "",
@@ -502,7 +502,7 @@ func TestPhotosResults_UIDs(t *testing.T) {
TakenAt: time.Time{},
TakenAtLocal: time.Time{},
TakenSrc: "",
TimeZone: "",
TimeZone: "Local",
PhotoUID: "456",
PhotoPath: "",
PhotoName: "",
@@ -570,7 +570,7 @@ func TestPhotosResult_ShareFileName(t *testing.T) {
TakenAt: time.Date(2015, 11, 11, 9, 7, 18, 0, time.UTC),
TakenAtLocal: time.Date(2013, 11, 11, 9, 7, 18, 0, time.UTC),
TakenSrc: "",
TimeZone: "",
TimeZone: "Local",
PhotoUID: "uid123",
PhotoPath: "",
PhotoName: "",
@@ -634,7 +634,7 @@ func TestPhotosResult_ShareFileName(t *testing.T) {
TakenAt: time.Date(2013, 11, 11, 9, 7, 18, 0, time.UTC),
TakenAtLocal: time.Date(2015, 11, 11, 9, 7, 18, 0, time.UTC),
TakenSrc: "",
TimeZone: "",
TimeZone: "Local",
PhotoUID: "uid123",
PhotoPath: "",
PhotoName: "",
@@ -699,7 +699,7 @@ func TestPhotosResult_ShareFileName(t *testing.T) {
TakenAt: time.Date(2022, 11, 11, 9, 7, 18, 0, time.UTC),
TakenAtLocal: time.Date(2022, 11, 11, 9, 7, 18, 0, time.UTC),
TakenSrc: "",
TimeZone: "",
TimeZone: "Local",
PhotoUID: "uid123",
PhotoPath: "",
PhotoName: "",

View File

@@ -16,7 +16,7 @@ func TestPhotoResults_ViewerJSON(t *testing.T) {
TakenAt: time.Time{},
TakenAtLocal: time.Time{},
TakenSrc: "",
TimeZone: "",
TimeZone: "Local",
PhotoUID: "123",
PhotoPath: "",
PhotoName: "",
@@ -76,7 +76,7 @@ func TestPhotoResults_ViewerJSON(t *testing.T) {
TakenAt: time.Time{},
TakenAtLocal: time.Time{},
TakenSrc: "",
TimeZone: "",
TimeZone: "Local",
PhotoUID: "456",
PhotoPath: "",
PhotoName: "",

View File

@@ -283,7 +283,7 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) {
// Set UTC offset as time zone?
if data.TimeZone != "" && data.TimeZone != "UTC" || data.TakenAt.IsZero() {
// Don't change existing time zone.
} else if utcOffset := txt.UtcOffset(data.TakenAtLocal, data.TakenAt, data.TimeOffset); utcOffset != "" {
} else if utcOffset := txt.UtcOffset(data.TakenAt, data.TakenAtLocal, data.TimeOffset); utcOffset != "" {
data.TimeZone = utcOffset
log.Infof("metadata: %s has time offset %s (exiftool)", logName, clean.Log(utcOffset))
} else if data.TimeOffset != "" {

View File

@@ -9,9 +9,11 @@ import (
// TimeZone returns a time zone for the given UTC offset string.
func TimeZone(offset string) *time.Location {
if offset == "" || strings.EqualFold(offset, "local") {
// Local time.
if offset == "" || offset == time.Local.String() {
// Local Time.
return time.Local
} else if offset == "UTC" || offset == "Z" {
// Coordinated Universal Time.
return time.UTC
} else if seconds, err := TimeOffset(offset); err == nil {
if h := seconds / 3600; h > 0 || h < 0 {
@@ -21,7 +23,7 @@ func TimeZone(offset string) *time.Location {
return zone
}
return time.FixedZone("", 0)
return time.FixedZone("GMT", 0)
}
// NormalizeUtcOffset returns a normalized UTC time offset string.
@@ -89,7 +91,7 @@ func NormalizeUtcOffset(s string) string {
}
// UtcOffset returns the time difference as UTC offset string.
func UtcOffset(local, utc time.Time, offset string) string {
func UtcOffset(utc, local time.Time, offset string) string {
if offset = NormalizeUtcOffset(offset); offset != "" {
return offset
}
@@ -116,6 +118,8 @@ func UtcOffset(local, utc time.Time, offset string) string {
// TimeOffset returns the UTC time offset in seconds or an error if it is invalid.
func TimeOffset(utcOffset string) (seconds int, err error) {
switch utcOffset {
case "Z", "GMT", "Etc/GMT", "UTC", "UTC+0", "UTC-0", "UTC+00:00", "UTC-00:00":
seconds = 0
case "-12", "-12:00", "UTC-12", "UTC-12:00":
seconds = -12 * 3600
case "-11", "-11:00", "UTC-11", "UTC-11:00":
@@ -164,8 +168,6 @@ func TimeOffset(utcOffset string) (seconds int, err error) {
seconds = 11 * 3600
case "12:00", "+12", "+12:00", "UTC+12", "UTC+12:00":
seconds = 12 * 3600
case "Z", "UTC", "UTC+0", "UTC-0", "UTC+00:00", "UTC-00:00":
seconds = 0
default:
return 0, fmt.Errorf("invalid UTC offset")
}

View File

@@ -12,15 +12,18 @@ func TestTimeZone(t *testing.T) {
assert.Equal(t, time.UTC.String(), TimeZone(time.UTC.String()).String())
assert.Equal(t, time.UTC.String(), TimeZone("Z").String())
assert.Equal(t, time.UTC.String(), TimeZone("UTC").String())
assert.Equal(t, time.UTC, TimeZone("UTC"))
})
t.Run("LocalTime", func(t *testing.T) {
assert.Equal(t, "", TimeZone("").String())
assert.Equal(t, "", TimeZone("local").String())
assert.Equal(t, "", TimeZone("Local").String())
assert.Equal(t, "", TimeZone("LOCAL").String())
assert.Equal(t, "", TimeZone("0").String())
assert.Equal(t, "", TimeZone("UTC+0").String())
assert.Equal(t, "", TimeZone("UTC+00:00").String())
t.Run("GMT", func(t *testing.T) {
assert.Equal(t, "GMT", TimeZone("0").String())
assert.Equal(t, "GMT", TimeZone("UTC+0").String())
assert.Equal(t, "GMT", TimeZone("UTC+00:00").String())
})
t.Run("Local", func(t *testing.T) {
assert.Equal(t, "Local", TimeZone("").String())
assert.Equal(t, time.Local, TimeZone(""))
assert.Equal(t, "Local", TimeZone("Local").String())
assert.Equal(t, time.Local, TimeZone("Local"))
})
t.Run("UTC+2", func(t *testing.T) {
local, err := time.Parse("2006-01-02 15:04:05 Z07:00", "2023-10-02 13:20:17 +00:00")
@@ -35,7 +38,7 @@ func TestTimeZone(t *testing.T) {
t.Fatal(err)
}
timeZone := UtcOffset(local, utc, "")
timeZone := UtcOffset(utc, local, "")
assert.Equal(t, "UTC+2", timeZone)
@@ -90,7 +93,7 @@ func TestUtcOffset(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, "", UtcOffset(local, utc, ""))
assert.Equal(t, "", UtcOffset(utc, local, ""))
})
t.Run("UTC", func(t *testing.T) {
local, err := time.Parse("2006-01-02 15:04:05 Z07:00", "2023-10-02 13:20:17 +00:00")
@@ -105,9 +108,9 @@ func TestUtcOffset(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, "", UtcOffset(local, utc, "00:00"))
assert.Equal(t, "", UtcOffset(local, utc, "+00:00"))
assert.Equal(t, "UTC", UtcOffset(local, utc, "Z"))
assert.Equal(t, "", UtcOffset(utc, local, "00:00"))
assert.Equal(t, "", UtcOffset(utc, local, "+00:00"))
assert.Equal(t, "UTC", UtcOffset(utc, local, "Z"))
})
t.Run("UTC+2", func(t *testing.T) {
local, err := time.Parse("2006-01-02 15:04:05 Z07:00", "2023-10-02 13:20:17 +00:00")
@@ -122,7 +125,7 @@ func TestUtcOffset(t *testing.T) {
t.Fatal(err)
}
timeZone := UtcOffset(local, utc, "")
timeZone := UtcOffset(utc, local, "")
assert.Equal(t, "UTC+2", timeZone)
@@ -143,7 +146,7 @@ func TestUtcOffset(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, "UTC+2", UtcOffset(local, utc, "02:00"))
assert.Equal(t, "UTC+2", UtcOffset(utc, local, "02:00"))
})
t.Run("UTC+2.5", func(t *testing.T) {
local, err := time.Parse("2006-01-02 15:04:05 Z07:00", "2023-10-02 13:50:17 +00:00")
@@ -158,7 +161,7 @@ func TestUtcOffset(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, "", UtcOffset(local, utc, ""))
assert.Equal(t, "", UtcOffset(utc, local, ""))
})
t.Run("+02:30", func(t *testing.T) {
local, err := time.Parse("2006-01-02 15:04:05 Z07:00", "2023-10-02 13:50:17 +00:00")
@@ -173,7 +176,7 @@ func TestUtcOffset(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, "", UtcOffset(local, utc, "+02:30"))
assert.Equal(t, "", UtcOffset(utc, local, "+02:30"))
})
t.Run("UTC-14", func(t *testing.T) {
local, err := time.Parse("2006-01-02 15:04:05 Z07:00", "2023-10-02 00:20:17 +00:00")
@@ -188,7 +191,7 @@ func TestUtcOffset(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, "", UtcOffset(local, utc, ""))
assert.Equal(t, "", UtcOffset(utc, local, ""))
})
t.Run("UTC-15", func(t *testing.T) {
local, err := time.Parse("2006-01-02 15:04:05 Z07:00", "2023-10-02 00:20:17 +00:00")
@@ -203,7 +206,7 @@ func TestUtcOffset(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, "", UtcOffset(local, utc, ""))
assert.Equal(t, "", UtcOffset(utc, local, ""))
})
}