/
githubmirror
/
photoprism
Обзор
Документация
Войти
/
githubmirror
/
photoprism
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
internal/entity/folder.go
357 строк
10 KB
Michael Mayer
Entity: Clip album, photo, and folder paths via shared ClipPath #5615
03 июн 2026, 12:34
03 июн 2026, 12:34
f70d6c1
Код
Авторство
О чём код?
package entity import ( "path" "strings" "sync" "time" "github.com/jinzhu/gorm" "github.com/ulule/deepcopier" "github.com/photoprism/photoprism/internal/entity/sortby" "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/txt" ) var folderMutex = sync.Mutex{} type Folders []Folder // Folder represents a file system directory. type Folder struct { Path string `gorm:"type:VARBINARY(1024);unique_index:idx_folders_path_root;" json:"Path" yaml:"Path"` Root string `gorm:"type:VARBINARY(16);default:'';unique_index:idx_folders_path_root;" json:"Root" yaml:"Root,omitempty"` FolderUID string `gorm:"type:VARBINARY(42);primary_key;" json:"UID,omitempty" yaml:"UID,omitempty"` FolderType string `gorm:"type:VARBINARY(16);" json:"Type" yaml:"Type,omitempty"` FolderTitle string `gorm:"type:VARCHAR(200);" json:"Title" yaml:"Title,omitempty"` FolderCategory string `gorm:"type:VARCHAR(100);index;" json:"Category" yaml:"Category,omitempty"` FolderDescription string `gorm:"type:VARCHAR(2048);" json:"Description,omitempty" yaml:"Description,omitempty"` FolderOrder string `gorm:"type:VARBINARY(32);" json:"Order" yaml:"Order,omitempty"` FolderCountry string `gorm:"type:VARBINARY(2);index:idx_folders_country_year_month;default:'zz'" json:"Country" yaml:"Country,omitempty"` FolderYear int `gorm:"index:idx_folders_country_year_month;" json:"Year" yaml:"Year,omitempty"` FolderMonth int `gorm:"index:idx_folders_country_year_month;" json:"Month" yaml:"Month,omitempty"` FolderDay int `json:"Day" yaml:"Day,omitempty"` FolderFavorite bool `json:"Favorite" yaml:"Favorite,omitempty"` FolderPrivate bool `json:"Private" yaml:"Private,omitempty"` FolderIgnore bool `json:"Ignore" yaml:"Ignore,omitempty"` FolderWatch bool `json:"Watch" yaml:"Watch,omitempty"` FileCount int `gorm:"-" json:"FileCount" yaml:"-"` CreatedAt time.Time `json:"-" yaml:"-"` UpdatedAt time.Time `json:"-" yaml:"-"` ModifiedAt time.Time `json:"ModifiedAt" yaml:"-"` PublishedAt *time.Time `sql:"index" json:"PublishedAt,omitempty" yaml:"PublishedAt,omitempty"` DeletedAt *time.Time `sql:"index" json:"-" yaml:"DeletedAt,omitempty"` } // TableName returns the entity table name. func (Folder) TableName() string { return "folders" } // BeforeCreate creates a random UID if needed before inserting a new row to the database. func (m *Folder) BeforeCreate(scope *gorm.Scope) error { if rnd.IsUnique(m.FolderUID, 'd') { return nil } return scope.SetColumn("FolderUID", rnd.GenerateUID('d')) } // NewFolder creates a new file system directory entity. func NewFolder(root, dir string, modTime time.Time) Folder { now := Now() dir = clean.SlashPath(dir) if dir == RootPath { dir = "" } // Clip to the shared path byte budget so folders.path stays byte-exact with // album_path and photo_path. dir = ClipPath(dir) year := 0 month := 0 if !modTime.IsZero() { year = modTime.Year() month = int(modTime.Month()) } result := Folder{ FolderUID: rnd.GenerateUID('d'), Root: root, Path: dir, FolderType: MediaUnknown, FolderOrder: sortby.Name, FolderCountry: UnknownCountry.ID, FolderYear: year, FolderMonth: month, ModifiedAt: modTime.UTC(), CreatedAt: now, UpdatedAt: now, } result.SetValuesFromPath() return result } // SetValuesFromPath updates the title and other values based on the path name. func (m *Folder) SetValuesFromPath() { s := m.Path s = strings.TrimSpace(s) if s == "" || s == RootPath { if m.Root == RootOriginals { m.FolderTitle = "Originals" return } else { s = m.Root } } else { m.FolderCountry = txt.CountryCode(s) if year := txt.Year(s); year > 0 { m.FolderYear = year } s = path.Base(s) } if len(m.Path) >= 6 { if date := txt.DateFromFilePath(m.Path); !date.IsZero() { if txt.IsUInt(s) || txt.IsTime(s) { if date.Day() > 1 { m.FolderTitle = date.Format("January 2, 2006") } else { m.FolderTitle = date.Format("January 2006") } } m.FolderYear = date.Year() m.FolderMonth = int(date.Month()) m.FolderDay = date.Day() } } if m.FolderTitle == "" { m.FolderTitle = txt.Clip(txt.Title(s), txt.ClipLongName) } } // Slug returns a collision-resistant slug derived from the folder path. func (m *Folder) Slug() string { return txt.SlugUnique(m.Path) } // RootPath returns the full folder path including root. func (m *Folder) RootPath() string { return path.Join(m.Root, m.Path) } // Title returns the human-readable folder title. func (m *Folder) Title() string { return m.FolderTitle } // Create inserts the entity to the index. func (m *Folder) Create() error { folderMutex.Lock() defer folderMutex.Unlock() if err := Db().Create(m).Error; err != nil { return err } else if m.Root != RootOriginals || m.Path == "" { return nil } m.syncOriginalsAlbum() return nil } // ReconcileOriginalsFolderAlbums ensures existing originals folders have matching folder albums. // When rootPath is set, only that path and its descendants are synchronized. func ReconcileOriginalsFolderAlbums(rootPath string) (reconciled int, err error) { rootPath = originalsFolderAlbumReconcileScope(rootPath) var folders Folders // Keep this scoped to active folders only. Conflict handling uses unscoped // lookups, but bulk reconciliation must not recreate/sync albums for // soft-deleted folder rows. stmt := Db().Where("root = ? AND path <> ''", RootOriginals) if rootPath != "" { stmt = stmt.Where("path = ? OR path LIKE ?", rootPath, rootPath+"/%") } if err = stmt.Order("path ASC").Find(&folders).Error; err != nil { return reconciled, err } for i := range folders { folders[i].syncOriginalsAlbum() reconciled++ } return reconciled, nil } // originalsFolderAlbumReconcileScope returns the effective reconciliation scope for a path. // If a slug collision is detected, the scope is expanded to the highest colliding // ancestor folder path so related rows can be repaired together. func originalsFolderAlbumReconcileScope(rootPath string) string { rootPath = clean.UserPath(rootPath) if rootPath == "" || !hasOriginalsFolderPath(rootPath) { return rootPath } scopePath := rootPath for hasOriginalsFolderAlbumSlugCollision(scopePath) { parentPath := clean.UserPath(path.Dir(scopePath)) if parentPath == "" || parentPath == "." || parentPath == "/" || parentPath == scopePath { break } scopePath = parentPath } return scopePath } // hasOriginalsFolderPath reports whether an active originals folder row exists for rootPath. func hasOriginalsFolderPath(rootPath string) bool { var count int if err := Db().Model(&Folder{}).Where("root = ? AND path = ?", RootOriginals, rootPath).Count(&count).Error; err != nil { log.Debugf("folder: %s (check folder path %s)", err, clean.LogQuote(rootPath)) return false } return count > 0 } // hasOriginalsFolderAlbumSlugCollision reports whether rootPath collides with another // active folder album that shares the same slug but stores a different non-empty path. // The "different path" check runs in Go because MariaDB's utf8mb4_unicode_ci collation // collapses most emoji, so an SQL "album_path <> ?" would wrongly exclude an emoji // sibling (e.g. "ins/🪞" treated as equal to "ins/🍷") and miss the collision. func hasOriginalsFolderAlbumSlugCollision(rootPath string) bool { albumSlugs := folderAlbumSlugCandidates(rootPath) if len(albumSlugs) == 0 { return false } var albums Albums if err := Db(). Where("album_type = ? AND album_slug IN (?) AND album_path <> ''", AlbumFolder, albumSlugs). Find(&albums).Error; err != nil { log.Debugf("folder: %s (check album slug collision for %s)", err, clean.LogQuote(rootPath)) return false } for i := range albums { if albums[i].AlbumPath != rootPath { return true } } return false } // syncOriginalsAlbum ensures an originals folder has a matching folder album. func (m *Folder) syncOriginalsAlbum() { if m == nil || m.Root != RootOriginals || m.Path == "" { return } f := form.SearchPhotos{ Path: m.Path, Public: true, } if a := FindFolderAlbum(m.Path); a != nil { if a.DeletedAt != nil { // Ignore. } else if err := a.UpdateFolder(m.Path, f.Serialize(), m.Title()); err != nil { log.Errorf("folder: %s (update album)", err.Error()) } } else if a := NewFolderAlbum(m.Title(), m.Path, f.Serialize()); a != nil { a.AlbumYear = m.FolderYear a.AlbumMonth = m.FolderMonth a.AlbumDay = m.FolderDay a.AlbumCountry = m.FolderCountry if err := a.Create(); err != nil { log.Errorf("folder: %s (add album)", err) } else { log.Infof("folder: added album %s (%s)", clean.Log(a.AlbumTitle), a.AlbumFilter) } } } // FindFolder returns an existing folder, including soft-deleted rows. // This function is primarily used for create/index conflict resolution. func FindFolder(root, dir string) *Folder { dir = clean.SlashPath(dir) if dir == RootPath { dir = "" } result := Folder{} if err := UnscopedDb().Where("path = ? AND root = ?", dir, root).First(&result).Error; err == nil { if result.DeletedAt != nil { log.Debugf("folder: found soft-deleted row for path %s in root %s during conflict lookup", clean.LogQuote(dir), clean.LogQuote(root)) } return &result } return nil } // FirstOrCreateFolder returns the existing row, inserts a new row or nil in case of errors. func FirstOrCreateFolder(m *Folder) *Folder { if result := FindFolder(m.Root, m.Path); result != nil { return result } else if createErr := m.Create(); createErr == nil { return m } else if result = FindFolder(m.Root, m.Path); result != nil { return result } else { log.Errorf("folder: %s (find or create %s)", createErr, m.Path) } return nil } // Updates selected properties in the database. func (m *Folder) Updates(values any) error { return Db().Model(m).Updates(values).Error } // SetForm updates the entity properties based on form values. func (m *Folder) SetForm(frm form.Folder) error { if err := deepcopier.Copy(m).From(frm); err != nil { return err } m.FolderTitle = txt.Clip(m.FolderTitle, txt.ClipLongName) m.FolderCategory = txt.Clip(m.FolderCategory, txt.ClipCategory) return nil }