diff --git a/docker/develop/bookworm-slim/Dockerfile b/docker/develop/bookworm-slim/Dockerfile index 8d63d76cf..d252c1f59 100644 --- a/docker/develop/bookworm-slim/Dockerfile +++ b/docker/develop/bookworm-slim/Dockerfile @@ -31,30 +31,13 @@ RUN echo 'APT::Acquire::Retries "3";' > /etc/apt/apt.conf.d/80retries && \ useradd -m -g 1000 -u 1000 -d /photoprism -G video,render photoprism && \ chmod 777 /photoprism && \ apt-get update && apt-get -qq dist-upgrade && apt-get -qq install --no-install-recommends \ - ca-certificates \ - jq \ - zip \ - gpg \ - lshw \ - wget \ - curl \ - make \ - sudo \ - bash \ - sqlite3 \ - tzdata \ - libc6 \ - libatomic1 \ - libheif-examples \ - librsvg2-bin \ - exiftool \ - darktable \ - rawtherapee \ - ffmpeg \ - ffmpegthumbnailer \ - libavcodec-extra \ - mariadb-client \ - && \ + libc6 ca-certificates sudo bash tzdata \ + gpg zip unzip wget curl rsync make nano \ + jq lsof lshw sqlite3 mariadb-client \ + exiftool darktable rawtherapee libheif-examples librsvg2-bin \ + ffmpeg ffmpegthumbnailer libavcodec-extra libwebm1 \ + libmatroska7 libdvdread8 libebml5 libgav1-0 libatomic1 \ + libx264-163 libx265-199 && \ install -d -m 0777 -o 1000 -g 1000 \ /var/lib/photoprism \ /tmp/photoprism \ diff --git a/docker/develop/bookworm/Dockerfile b/docker/develop/bookworm/Dockerfile index f424c642a..0224b7110 100644 --- a/docker/develop/bookworm/Dockerfile +++ b/docker/develop/bookworm/Dockerfile @@ -38,60 +38,20 @@ RUN echo 'APT::Acquire::Retries "3";' > /etc/apt/apt.conf.d/80retries && \ useradd -m -g 1000 -u 1000 -d /photoprism -G video,render photoprism && \ chmod 777 /photoprism && \ apt-get update && apt-get -qq dist-upgrade && apt-get -qq install --no-install-recommends \ - apt-utils \ - gpg \ - pkg-config \ - software-properties-common \ - ca-certificates \ - build-essential \ - gcc \ - g++ \ - sudo \ - bash \ - make \ - nano \ - lsof \ - lshw \ - wget \ - curl \ - rsync \ - jq \ - git \ - zip \ - unzip \ - gettext \ - chromium \ - chromium-driver \ - chromium-sandbox \ - firefox-esr \ - sqlite3 \ - libc6-dev \ - libssl-dev \ - libxft-dev \ - libhdf5-serial-dev \ - libpng-dev \ - libheif-examples \ - librsvg2-bin \ - libzmq3-dev \ - libx264-dev \ - libx265-dev \ - libnss3 \ - libfreetype6 \ - libfreetype6-dev \ - libfontconfig1 \ - libfontconfig1-dev \ - fonts-roboto \ - tzdata \ - exiftool \ - rawtherapee \ - ffmpeg \ - darktable \ - ffmpegthumbnailer \ - libavcodec-extra \ - davfs2 \ - chrpath \ - apache2-utils \ - mariadb-client \ + libc6 ca-certificates sudo bash tzdata \ + gpg zip unzip wget curl rsync make nano \ + jq lsof lshw sqlite3 mariadb-client \ + exiftool darktable rawtherapee libheif-examples librsvg2-bin \ + ffmpeg ffmpegthumbnailer libavcodec-extra libwebm1 \ + libmatroska7 libdvdread8 libebml5 libgav1-0 libatomic1 \ + libx264-163 libx265-199 && \ + apt-get -qq install --no-install-recommends \ + apt-utils pkg-config software-properties-common \ + build-essential gcc g++ git gettext davfs2 chrpath apache2-utils \ + chromium chromium-driver chromium-sandbox firefox-esr \ + libx264-dev libx265-dev libpng-dev libxft-dev \ + libc6-dev libhdf5-serial-dev libzmq3-dev libssl-dev libnss3 \ + libfreetype6 libfreetype6-dev libfontconfig1 libfontconfig1-dev fonts-roboto \ && \ /scripts/install-nodejs.sh && \ /scripts/install-tensorflow.sh && \ diff --git a/frontend/src/common/util.js b/frontend/src/common/util.js index 02e240e62..a3252199c 100644 --- a/frontend/src/common/util.js +++ b/frontend/src/common/util.js @@ -145,25 +145,100 @@ export default class Util { start = now; } + static capitalize(s) { + if (!s || s === "") { + return ""; + } + + return s.replace(/\w\S*/g, (w) => w.replace(/^\w/, (c) => c.toUpperCase())); + } + + static fileType(value) { + if (!value || typeof value !== "string") { + return ""; + } + + switch (value) { + case "raw": + return "Unprocessed Sensor Data (RAW)"; + case "mov": + case "qt": + return "Apple QuickTime"; + case "bmp": + return "Bitmap"; + case "png": + return "Portable Network Graphics"; + case "tiff": + return "TIFF"; + case "gif": + return "GIF"; + case "avc": + case "avc1": + return "Advanced Video Coding (AVC) / H.264"; + case "hevc": + case "hvc": + case "hvc1": + return "High Efficiency Video Coding (HEVC) / H.265"; + case "mkv": + return "Matroska Multimedia Container"; + case "webp": + return "Google WebP"; + case "webm": + return "Google WebM"; + case "flv": + return "Flash"; + case "mpg": + return "MPEG"; + case "mjpg": + return "Motion JPEG"; + case "ogg": + case "ogv": + return "Ogg Media"; + case "wmv": + return "Windows Media"; + default: + return value.toUpperCase(); + } + } + static codecName(value) { if (!value || typeof value !== "string") { return ""; } switch (value) { + case "raw": + return "Unprocessed Sensor Data (RAW)"; + case "mov": + case "qt": + return "Apple QuickTime (MOV)"; + case "avc": case "avc1": return "Advanced Video Coding (AVC) / H.264"; + case "hevc": + case "hvc": case "hvc1": return "High Efficiency Video Coding (HEVC) / H.265"; + case "vvc": + return "Versatile Video Coding (VVC) / H.266"; case "av01": return "AOMedia Video 1 (AV1)"; + case "gif": + return "Graphics Interchange Format (GIF)"; + case "mkv": + return "Matroska Multimedia Container (MKV)"; + case "webp": + return "Google WebP"; + case "webm": + return "Google WebM"; case "mpeg": return "Moving Picture Experts Group (MPEG)"; case "mjpg": return "Motion JPEG (M-JPEG)"; case "heif": - case "heic": return "High Efficiency Image File Format (HEIF)"; + case "heic": + return "High Efficiency Image Container (HEIC)"; case "1": return "Uncompressed"; case "2": diff --git a/frontend/src/dialog/photo/files.vue b/frontend/src/dialog/photo/files.vue index 5df3da551..cfe8874de 100644 --- a/frontend/src/dialog/photo/files.vue +++ b/frontend/src/dialog/photo/files.vue @@ -82,18 +82,18 @@ {{ file.Hash }} - - - Storage Folder - - {{ file.Root | capitalize }} - - Name + Filename {{ file.Name }} + + + Storage + + {{ file.storageInfo() }} + Original Name @@ -112,12 +112,18 @@ {{ file.typeInfo() }} - + Codec {{ codecName(file) }} + + + Duration + + {{ formatDuration(file) }} + Frames @@ -278,6 +284,20 @@ export default { }, computed: {}, methods: { + formatDuration(file) { + if (!file || !file.Duration) { + return ""; + } + + return Util.duration(file.Duration); + }, + fileType(file) { + if (!file || !file.FileType) { + return ""; + } + + return Util.fileType(file.FileType); + }, codecName(file) { if (!file || !file.Codec) { return ""; diff --git a/frontend/src/model/file.js b/frontend/src/model/file.js index 37001778a..e291e7c17 100644 --- a/frontend/src/model/file.js +++ b/frontend/src/model/file.js @@ -30,7 +30,7 @@ import Util from "common/util"; import { config } from "app/session"; import { $gettext } from "common/vm"; import download from "common/download"; -import { MediaAnimated } from "./photo"; +import { MediaImage } from "./photo"; export class File extends RestModel { getDefaults() { @@ -38,6 +38,9 @@ export class File extends RestModel { UID: "", PhotoUID: "", InstanceID: "", + MediaID: "", + MediaUTC: 0, + TakenAt: "", Root: "/", Name: "", OriginalName: "", @@ -184,18 +187,54 @@ export class File extends RestModel { return info.join(", "); } + storageInfo() { + if (!this.Root || this.Root === "") { + return ""; + } + + if (this.Root.length === 1) { + return $gettext("Originals"); + } else { + return Util.capitalize(this.Root); + } + } + typeInfo() { - if (this.Type === MediaAnimated) { - return $gettext("Animation"); - } + let info = []; - if (this.Video) { - return $gettext("Video"); + if ( + this.MediaType && + this.Frames && + this.MediaType === MediaImage && + this.Frames && + this.Frames > 0 + ) { + info.push($gettext("Animated")); } else if (this.Sidecar) { - return $gettext("Sidecar"); + info.push($gettext("Sidecar")); } - return this.FileType.toUpperCase(); + if (this.Primary && !this.MediaType) { + info.push($gettext("Image")); + return info.join(" "); + } else if (this.Video && !this.MediaType) { + info.push($gettext("Video")); + return info.join(" "); + } else { + const format = Util.fileType(this.FileType); + if (format) { + info.push(format); + } + + if (this.MediaType && this.MediaType !== this.FileType) { + const media = Util.capitalize(this.MediaType); + if (media) { + info.push(media); + } + } + + return info.join(" "); + } } sizeInfo() { diff --git a/frontend/src/model/photo.js b/frontend/src/model/photo.js index ed66b606f..8cf3563ad 100644 --- a/frontend/src/model/photo.js +++ b/frontend/src/model/photo.js @@ -43,6 +43,7 @@ export const FormatGif = "gif"; export const FormatJpeg = "jpg"; export const MediaImage = "image"; export const MediaAnimated = "animated"; +export const MediaSidecar = "sidecar"; export const MediaVideo = "video"; export const MediaLive = "live"; export const MediaRaw = "raw"; @@ -535,9 +536,10 @@ export class Photo extends RestModel { } downloadAll() { - const settings = config.settings(); + const s = config.settings(); - if (!settings || !settings.features || !settings.download || !settings.features.download) { + if (!s || !s.features || !s.download || !s.features.download || s.download.disabled) { + console.log("download: disabled in settings", s.features, s.download); return; } @@ -560,23 +562,30 @@ export class Photo extends RestModel { return; } - // Skip sidecar files. - if (file.Sidecar) { + // Originals only? + if (s.download.originals && file.Root.length > 1) { // Don't download broken files and sidecars. - if (config.debug) console.log("download: skipped sidecar", file); + if (config.debug) console.log(`download: skipped ${file.Root} file ${file.Name}`); return; } - // Skip RAW images. - if (!settings.download.raw && file.FileType === MediaRaw) { - if (config.debug) console.log("download: skipped raw", file); + // Skip metadata sidecar files? + if (!s.download.mediaSidecar && (file.MediaType === MediaSidecar || file.Sidecar)) { + // Don't download broken files and sidecars. + if (config.debug) console.log(`download: skipped sidecar file ${file.Name}`); return; } - // Skip related images if video. + // Skip RAW images? + if (!s.download.mediaRaw && (file.MediaType === MediaRaw || file.FileType === MediaRaw)) { + if (config.debug) console.log(`download: skipped raw file ${file.Name}`); + return; + } + + // If this is a video, always skip stacked images... // see https://github.com/photoprism/photoprism/issues/1436 - if (this.Type === MediaVideo && !file.Video) { - if (config.debug) console.log("download: skipped image", file); + if (this.Type === MediaVideo && !(file.MediaType === MediaVideo || file.Video)) { + if (config.debug) console.log(`download: skipped video sidecar ${file.Name}`); return; } diff --git a/frontend/tests/unit/model/file_test.js b/frontend/tests/unit/model/file_test.js index 206752fe5..ccc4f729e 100644 --- a/frontend/tests/unit/model/file_test.js +++ b/frontend/tests/unit/model/file_test.js @@ -236,7 +236,21 @@ describe("model/file", () => { UpdatedAt: "2012-07-08T14:45:39Z", }; const file3 = new File(values3); - assert.equal(file3.typeInfo(), "Sidecar"); + assert.equal(file3.typeInfo(), "Sidecar JPG"); + const values4 = { + InstanceID: 5, + UID: "ABC123", + Hash: "54ghtfd", + FileType: "gif", + MediaType: "image", + Duration: 8009, + Name: "1/2/IMG123.jpg", + Sidecar: true, + CreatedAt: "2012-07-08T14:45:39Z", + UpdatedAt: "2012-07-08T14:45:39Z", + }; + const file4 = new File(values4); + assert.equal(file4.typeInfo(), "Sidecar GIF Image"); }); it("should get size info", () => { diff --git a/internal/api/account.go b/internal/api/account.go index ae3285ee9..948c96fd2 100644 --- a/internal/api/account.go +++ b/internal/api/account.go @@ -18,8 +18,8 @@ import ( "github.com/photoprism/photoprism/internal/service" "github.com/photoprism/photoprism/internal/workers" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // Namespaces for caching and logs. @@ -49,7 +49,7 @@ func GetAccount(router *gin.RouterGroup) { return } - id := sanitize.IdUint(c.Param("id")) + id := clean.IdUint(c.Param("id")) if m, err := query.AccountByID(id); err == nil { c.JSON(http.StatusOK, m) @@ -82,7 +82,7 @@ func GetAccountFolders(router *gin.RouterGroup) { } start := time.Now() - id := sanitize.IdUint(c.Param("id")) + id := clean.IdUint(c.Param("id")) cache := service.FolderCache() cacheKey := fmt.Sprintf("%s:%d", accountFolder, id) @@ -132,7 +132,7 @@ func ShareWithAccount(router *gin.RouterGroup) { return } - id := sanitize.IdUint(c.Param("id")) + id := clean.IdUint(c.Param("id")) m, err := query.AccountByID(id) @@ -150,13 +150,9 @@ func ShareWithAccount(router *gin.RouterGroup) { folder := f.Folder - // Select files to be shared. - o := query.FileSelection{ - Video: true, - OriginalsOnly: m.ShareOriginals(), - PrimaryOnly: !m.ShareOriginals(), - } - files, err := query.SelectedFiles(f.Selection, o) + // Find files to share. + selection := query.ShareSelection(m.ShareOriginals()) + files, err := query.SelectedFiles(f.Selection, selection) if err != nil { AbortEntityNotFound(c) @@ -252,7 +248,7 @@ func UpdateAccount(router *gin.RouterGroup) { return } - id := sanitize.IdUint(c.Param("id")) + id := clean.IdUint(c.Param("id")) m, err := query.AccountByID(id) @@ -323,7 +319,7 @@ func DeleteAccount(router *gin.RouterGroup) { return } - id := sanitize.IdUint(c.Param("id")) + id := clean.IdUint(c.Param("id")) m, err := query.AccountByID(id) diff --git a/internal/api/album.go b/internal/api/album.go index b39473068..871dfd20b 100644 --- a/internal/api/album.go +++ b/internal/api/album.go @@ -16,7 +16,7 @@ import ( "github.com/photoprism/photoprism/internal/search" "github.com/photoprism/photoprism/internal/service" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) var albumMutex = sync.Mutex{} @@ -35,7 +35,7 @@ func SaveAlbumAsYaml(a entity.Album) { if err := a.SaveAsYaml(fileName); err != nil { log.Errorf("album: %s (update yaml)", err) } else { - log.Debugf("album: updated yaml file %s", sanitize.Log(filepath.Base(fileName))) + log.Debugf("album: updated yaml file %s", clean.Log(filepath.Base(fileName))) } } @@ -51,7 +51,7 @@ func GetAlbum(router *gin.RouterGroup) { return } - id := sanitize.IdString(c.Param("uid")) + id := clean.IdString(c.Param("uid")) a, err := query.AlbumByUID(id) if err != nil { @@ -96,7 +96,7 @@ func CreateAlbum(router *gin.RouterGroup) { // Create new album. if err := a.Create(); err != nil { - AbortAlreadyExists(c, sanitize.Log(a.AlbumTitle)) + AbortAlreadyExists(c, clean.Log(a.AlbumTitle)) return } @@ -124,7 +124,7 @@ func UpdateAlbum(router *gin.RouterGroup) { return } - uid := sanitize.IdString(c.Param("uid")) + uid := clean.IdString(c.Param("uid")) a, err := query.AlbumByUID(uid) if err != nil { @@ -179,7 +179,7 @@ func DeleteAlbum(router *gin.RouterGroup) { return } - id := sanitize.IdString(c.Param("uid")) + id := clean.IdString(c.Param("uid")) a, err := query.AlbumByUID(id) @@ -212,7 +212,7 @@ func DeleteAlbum(router *gin.RouterGroup) { SaveAlbumAsYaml(a) - event.SuccessMsg(i18n.MsgAlbumDeleted, sanitize.Log(a.AlbumTitle)) + event.SuccessMsg(i18n.MsgAlbumDeleted, clean.Log(a.AlbumTitle)) c.JSON(http.StatusOK, a) }) @@ -233,7 +233,7 @@ func LikeAlbum(router *gin.RouterGroup) { return } - id := sanitize.IdString(c.Param("uid")) + id := clean.IdString(c.Param("uid")) a, err := query.AlbumByUID(id) if err != nil { @@ -271,7 +271,7 @@ func DislikeAlbum(router *gin.RouterGroup) { return } - id := sanitize.IdString(c.Param("uid")) + id := clean.IdString(c.Param("uid")) a, err := query.AlbumByUID(id) if err != nil { @@ -306,7 +306,7 @@ func CloneAlbums(router *gin.RouterGroup) { return } - a, err := query.AlbumByUID(sanitize.IdString(c.Param("uid"))) + a, err := query.AlbumByUID(clean.IdString(c.Param("uid"))) if err != nil { Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound) @@ -341,7 +341,7 @@ func CloneAlbums(router *gin.RouterGroup) { } if len(added) > 0 { - event.SuccessMsg(i18n.MsgSelectionAddedTo, sanitize.Log(a.Title())) + event.SuccessMsg(i18n.MsgSelectionAddedTo, clean.Log(a.Title())) PublishAlbumEvent(EntityUpdated, a.AlbumUID, c) @@ -371,7 +371,7 @@ func AddPhotosToAlbum(router *gin.RouterGroup) { return } - uid := sanitize.IdString(c.Param("uid")) + uid := clean.IdString(c.Param("uid")) a, err := query.AlbumByUID(uid) if err != nil { @@ -392,9 +392,9 @@ func AddPhotosToAlbum(router *gin.RouterGroup) { if len(added) > 0 { if len(added) == 1 { - event.SuccessMsg(i18n.MsgEntryAddedTo, sanitize.Log(a.Title())) + event.SuccessMsg(i18n.MsgEntryAddedTo, clean.Log(a.Title())) } else { - event.SuccessMsg(i18n.MsgEntriesAddedTo, len(added), sanitize.Log(a.Title())) + event.SuccessMsg(i18n.MsgEntriesAddedTo, len(added), clean.Log(a.Title())) } RemoveFromAlbumCoverCache(a.AlbumUID) @@ -432,7 +432,7 @@ func RemovePhotosFromAlbum(router *gin.RouterGroup) { return } - a, err := query.AlbumByUID(sanitize.IdString(c.Param("uid"))) + a, err := query.AlbumByUID(clean.IdString(c.Param("uid"))) if err != nil { Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound) @@ -443,9 +443,9 @@ func RemovePhotosFromAlbum(router *gin.RouterGroup) { if len(removed) > 0 { if len(removed) == 1 { - event.SuccessMsg(i18n.MsgEntryRemovedFrom, sanitize.Log(a.Title())) + event.SuccessMsg(i18n.MsgEntryRemovedFrom, clean.Log(a.Title())) } else { - event.SuccessMsg(i18n.MsgEntriesRemovedFrom, len(removed), sanitize.Log(sanitize.Log(a.Title()))) + event.SuccessMsg(i18n.MsgEntriesRemovedFrom, len(removed), clean.Log(clean.Log(a.Title()))) } RemoveFromAlbumCoverCache(a.AlbumUID) diff --git a/internal/api/api.go b/internal/api/api.go index dadc55a09..bf49fdb56 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -34,7 +34,7 @@ import ( "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/i18n" "github.com/photoprism/photoprism/internal/service" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) var log = event.Log @@ -60,7 +60,7 @@ func UpdateClientConfig() { func Abort(c *gin.Context, code int, id i18n.Message, params ...interface{}) { resp := i18n.NewResponse(code, id, params...) - log.Debugf("api: abort %s with code %d (%s)", sanitize.Log(c.FullPath()), code, resp.String()) + log.Debugf("api: abort %s with code %d (%s)", clean.Log(c.FullPath()), code, resp.String()) c.AbortWithStatusJSON(code, resp) } @@ -70,7 +70,7 @@ func Error(c *gin.Context, code int, err error, id i18n.Message, params ...inter if err != nil { resp.Details = err.Error() - log.Errorf("api: error %s with code %d in %s (%s)", sanitize.Log(err.Error()), code, sanitize.Log(c.FullPath()), resp.String()) + log.Errorf("api: error %s with code %d in %s (%s)", clean.Log(err.Error()), code, clean.Log(c.FullPath()), resp.String()) } c.AbortWithStatusJSON(code, resp) diff --git a/internal/api/batch.go b/internal/api/batch.go index 3a2b55876..b199bfe3f 100644 --- a/internal/api/batch.go +++ b/internal/api/batch.go @@ -14,7 +14,7 @@ import ( "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/service" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // BatchPhotosArchive moves multiple photos to the archive. @@ -41,7 +41,7 @@ func BatchPhotosArchive(router *gin.RouterGroup) { return } - log.Infof("photos: archiving %s", sanitize.Log(f.String())) + log.Infof("photos: archiving %s", clean.Log(f.String())) if service.Config().BackupYaml() { // Fetch selection from index. @@ -105,7 +105,7 @@ func BatchPhotosRestore(router *gin.RouterGroup) { return } - log.Infof("photos: restoring %s", sanitize.Log(f.String())) + log.Infof("photos: restoring %s", clean.Log(f.String())) if service.Config().BackupYaml() { // Fetch selection from index. @@ -168,7 +168,7 @@ func BatchPhotosApprove(router *gin.RouterGroup) { return } - log.Infof("photos: approving %s", sanitize.Log(f.String())) + log.Infof("photos: approving %s", clean.Log(f.String())) // Fetch selection from index. photos, err := query.SelectedPhotos(f) @@ -221,7 +221,7 @@ func BatchAlbumsDelete(router *gin.RouterGroup) { return } - log.Infof("albums: deleting %s", sanitize.Log(f.String())) + log.Infof("albums: deleting %s", clean.Log(f.String())) entity.Db().Where("album_uid IN (?)", f.Albums).Delete(&entity.Album{}) entity.Db().Where("album_uid IN (?)", f.Albums).Delete(&entity.PhotoAlbum{}) @@ -258,7 +258,7 @@ func BatchPhotosPrivate(router *gin.RouterGroup) { return } - log.Infof("photos: updating private flag for %s", sanitize.Log(f.String())) + log.Infof("photos: updating private flag for %s", clean.Log(f.String())) if err := entity.Db().Model(entity.Photo{}).Where("photo_uid IN (?)", f.Photos).UpdateColumn("photo_private", gorm.Expr("CASE WHEN photo_private > 0 THEN 0 ELSE 1 END")).Error; err != nil { @@ -312,7 +312,7 @@ func BatchLabelsDelete(router *gin.RouterGroup) { return } - log.Infof("labels: deleting %s", sanitize.Log(f.String())) + log.Infof("labels: deleting %s", clean.Log(f.String())) var labels entity.Labels @@ -364,7 +364,7 @@ func BatchPhotosDelete(router *gin.RouterGroup) { return } - log.Infof("photos: deleting %s", sanitize.Log(f.String())) + log.Infof("photos: deleting %s", clean.Log(f.String())) // Fetch selection from index. photos, err := query.SelectedPhotos(f) diff --git a/internal/api/config.go b/internal/api/config.go index 00ed29b10..47d36c31c 100644 --- a/internal/api/config.go +++ b/internal/api/config.go @@ -5,7 +5,7 @@ import ( "os" "path/filepath" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/gin-gonic/gin" "gopkg.in/yaml.v2" @@ -87,13 +87,13 @@ func SaveConfigOptions(router *gin.RouterGroup) { yamlData, err := os.ReadFile(fileName) if err != nil { - log.Errorf("config: failed loading values from %s (%s)", sanitize.Log(fileName), err) + log.Errorf("config: failed loading values from %s (%s)", clean.Log(fileName), err) c.AbortWithStatusJSON(http.StatusInternalServerError, err) return } if err := yaml.Unmarshal(yamlData, v); err != nil { - log.Warnf("config: failed parsing values in %s (%s)", sanitize.Log(fileName), err) + log.Warnf("config: failed parsing values in %s (%s)", clean.Log(fileName), err) c.AbortWithStatusJSON(http.StatusInternalServerError, err) return } @@ -122,14 +122,14 @@ func SaveConfigOptions(router *gin.RouterGroup) { // Write YAML data to file. if err := os.WriteFile(fileName, yamlData, os.ModePerm); err != nil { - log.Errorf("config: failed writing values to %s (%s)", sanitize.Log(fileName), err) + log.Errorf("config: failed writing values to %s (%s)", clean.Log(fileName), err) c.AbortWithStatusJSON(http.StatusInternalServerError, err) return } // Reload options. if err := conf.Options().Load(fileName); err != nil { - log.Warnf("config: failed loading values from %s (%s)", sanitize.Log(fileName), err) + log.Warnf("config: failed loading values from %s (%s)", clean.Log(fileName), err) c.AbortWithStatusJSON(http.StatusInternalServerError, err) return } diff --git a/internal/api/covers.go b/internal/api/covers.go index dd0080a69..d7230a04a 100644 --- a/internal/api/covers.go +++ b/internal/api/covers.go @@ -5,7 +5,7 @@ import ( "path/filepath" "time" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/gin-gonic/gin" "github.com/photoprism/photoprism/internal/photoprism" @@ -38,13 +38,13 @@ func AlbumCover(router *gin.RouterGroup) { start := time.Now() conf := service.Config() - thumbName := thumb.Name(sanitize.Token(c.Param("size"))) - uid := sanitize.IdString(c.Param("uid")) + thumbName := thumb.Name(clean.Token(c.Param("size"))) + uid := clean.IdString(c.Param("uid")) size, ok := thumb.Sizes[thumbName] if !ok { - log.Errorf("%s: invalid size %s", albumCover, sanitize.Log(thumbName.String())) + log.Errorf("%s: invalid size %s", albumCover, clean.Log(thumbName.String())) c.Data(http.StatusOK, "image/svg+xml", albumIconSvg) return } @@ -85,11 +85,11 @@ func AlbumCover(router *gin.RouterGroup) { fileName := photoprism.FileName(f.FileRoot, f.FileName) if !fs.FileExists(fileName) { - log.Errorf("%s: found no original for %s", albumCover, sanitize.Log(fileName)) + log.Errorf("%s: found no original for %s", albumCover, clean.Log(fileName)) c.Data(http.StatusOK, "image/svg+xml", albumIconSvg) // Set missing flag so that the file doesn't show up in search results anymore. - log.Warnf("%s: %s is missing", albumCover, sanitize.Log(f.FileName)) + log.Warnf("%s: %s is missing", albumCover, clean.Log(f.FileName)) logError(albumCover, f.Update("FileMissing", true)) return } @@ -150,13 +150,13 @@ func LabelCover(router *gin.RouterGroup) { start := time.Now() conf := service.Config() - thumbName := thumb.Name(sanitize.Token(c.Param("size"))) - uid := sanitize.IdString(c.Param("uid")) + thumbName := thumb.Name(clean.Token(c.Param("size"))) + uid := clean.IdString(c.Param("uid")) size, ok := thumb.Sizes[thumbName] if !ok { - log.Errorf("%s: invalid size %s", labelCover, sanitize.Log(thumbName.String())) + log.Errorf("%s: invalid size %s", labelCover, clean.Log(thumbName.String())) c.Data(http.StatusOK, "image/svg+xml", labelIconSvg) return } @@ -197,7 +197,7 @@ func LabelCover(router *gin.RouterGroup) { fileName := photoprism.FileName(f.FileRoot, f.FileName) if !fs.FileExists(fileName) { - log.Errorf("%s: file %s is missing", labelCover, sanitize.Log(f.FileName)) + log.Errorf("%s: file %s is missing", labelCover, clean.Log(f.FileName)) c.Data(http.StatusOK, "image/svg+xml", labelIconSvg) // Set missing flag so that the file doesn't show up in search results anymore. diff --git a/internal/api/download_album.go b/internal/api/download_album.go index addcaf7fc..0624af84b 100644 --- a/internal/api/download_album.go +++ b/internal/api/download_album.go @@ -14,8 +14,8 @@ import ( "github.com/photoprism/photoprism/internal/search" "github.com/photoprism/photoprism/internal/service" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // DownloadAlbum streams the album contents as zip archive. @@ -36,7 +36,7 @@ func DownloadAlbum(router *gin.RouterGroup) { } start := time.Now() - a, err := query.AlbumByUID(sanitize.IdString(c.Param("uid"))) + a, err := query.AlbumByUID(clean.IdString(c.Param("uid"))) if err != nil { Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound) @@ -57,26 +57,19 @@ func DownloadAlbum(router *gin.RouterGroup) { zipWriter := zip.NewWriter(c.Writer) defer zipWriter.Close() - skipRaw := !conf.Settings().Download.Raw - var aliases = make(map[string]int) for _, file := range files { if file.FileHash == "" { - log.Warnf("download: empty file hash, skipped %s", sanitize.Log(file.FileName)) + log.Warnf("download: empty file hash, skipped %s", clean.Log(file.FileName)) continue } else if file.FileName == "" { - log.Warnf("download: empty file name, skipped %s", sanitize.Log(file.FileUID)) + log.Warnf("download: empty file name, skipped %s", clean.Log(file.FileUID)) continue } if file.FileSidecar { - log.Debugf("download: skipped sidecar %s", sanitize.Log(file.FileName)) - continue - } - - if skipRaw && fs.FormatRaw.Is(file.FileType) { - log.Debugf("download: skipped raw %s", sanitize.Log(file.FileName)) + log.Debugf("download: skipped sidecar %s", clean.Log(file.FileName)) continue } @@ -92,17 +85,17 @@ func DownloadAlbum(router *gin.RouterGroup) { if fs.FileExists(fileName) { if err := addFileToZip(zipWriter, fileName, alias); err != nil { - log.Errorf("download: failed adding %s to album zip (%s)", sanitize.Log(file.FileName), err) + log.Errorf("download: failed adding %s to album zip (%s)", clean.Log(file.FileName), err) Abort(c, http.StatusInternalServerError, i18n.ErrZipFailed) return } - log.Infof("download: added %s as %s", sanitize.Log(file.FileName), sanitize.Log(alias)) + log.Infof("download: added %s as %s", clean.Log(file.FileName), clean.Log(alias)) } else { - log.Warnf("download: album file %s is missing", sanitize.Log(file.FileName)) + log.Warnf("download: album file %s is missing", clean.Log(file.FileName)) } } - log.Infof("download: created %s [%s]", sanitize.Log(zipFileName), time.Since(start)) + log.Infof("download: created %s [%s]", clean.Log(zipFileName), time.Since(start)) }) } diff --git a/internal/api/download_file.go b/internal/api/download_file.go index 4488c1d96..2c9ec7015 100644 --- a/internal/api/download_file.go +++ b/internal/api/download_file.go @@ -10,8 +10,8 @@ import ( "github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/service" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // TODO: GET /api/v1/dl/file/:hash @@ -45,7 +45,7 @@ func GetDownload(router *gin.RouterGroup) { return } - fileHash := sanitize.Token(c.Param("hash")) + fileHash := clean.Token(c.Param("hash")) f, err := query.FileByHash(fileHash) @@ -57,7 +57,7 @@ func GetDownload(router *gin.RouterGroup) { fileName := photoprism.FileName(f.FileRoot, f.FileName) if !fs.FileExists(fileName) { - log.Errorf("download: file %s is missing", sanitize.Log(f.FileName)) + log.Errorf("download: file %s is missing", clean.Log(f.FileName)) c.Data(404, "image/svg+xml", brokenIconSvg) // Set missing flag so that the file doesn't show up in search results anymore. diff --git a/internal/api/download_zip.go b/internal/api/download_zip.go index 1350a11db..29faf4109 100644 --- a/internal/api/download_zip.go +++ b/internal/api/download_zip.go @@ -20,9 +20,9 @@ import ( "github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/service" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/rnd" - "github.com/photoprism/photoprism/pkg/sanitize" ) // CreateZip creates a zip file archive for download. @@ -57,8 +57,17 @@ func CreateZip(router *gin.RouterGroup) { return } - // Select files to be downloaded. - files, err := query.SelectedFiles(f, query.FileSelectionAll()) + // Configure file selection based on user settings. + var selection query.FileSelection + if dl := conf.Settings().Download; dl.Disabled { + AbortFeatureDisabled(c) + return + } else { + selection = query.DownloadSelection(dl.MediaRaw, dl.MediaSidecar, dl.Originals) + } + + // Find files to download. + files, err := query.SelectedFiles(f, selection) if err != nil { Error(c, http.StatusBadRequest, err, i18n.ErrZipFailed) @@ -68,53 +77,36 @@ func CreateZip(router *gin.RouterGroup) { return } + // Configure file names. + dlName := DownloadName(c) zipPath := path.Join(conf.TempPath(), "zip") - zipToken := rnd.Token(8) + zipToken := rnd.GenerateToken(8) zipBaseName := fmt.Sprintf("photoprism-download-%s-%s.zip", time.Now().Format("20060102-150405"), zipToken) zipFileName := path.Join(zipPath, zipBaseName) + // Create temp directory. if err := os.MkdirAll(zipPath, 0700); err != nil { Error(c, http.StatusInternalServerError, err, i18n.ErrZipFailed) return } - newZipFile, err := os.Create(zipFileName) - - if err != nil { + // Create new zip file. + var newZipFile *os.File + if newZipFile, err = os.Create(zipFileName); err != nil { Error(c, http.StatusInternalServerError, err, i18n.ErrZipFailed) return + } else { + defer newZipFile.Close() } - defer newZipFile.Close() - + // Create zip writer. zipWriter := zip.NewWriter(newZipFile) defer zipWriter.Close() - dlName := DownloadName(c) - - skipRaw := !conf.Settings().Download.Raw - var aliases = make(map[string]int) + // Add files to zip. for _, file := range files { - if file.FileHash == "" { - log.Warnf("download: empty file hash, skipped %s", sanitize.Log(file.FileName)) - continue - } else if file.FileName == "" { - log.Warnf("download: empty file name, skipped %s", sanitize.Log(file.FileUID)) - continue - } - - if file.FileSidecar { - log.Debugf("download: skipped sidecar %s", sanitize.Log(file.FileName)) - continue - } - - if skipRaw && fs.FormatRaw.Is(file.FileType) { - log.Debugf("download: skipped raw %s", sanitize.Log(file.FileName)) - continue - } - fileName := photoprism.FileName(file.FileRoot, file.FileName) alias := file.DownloadName(dlName, 0) key := strings.ToLower(alias) @@ -127,21 +119,21 @@ func CreateZip(router *gin.RouterGroup) { if fs.FileExists(fileName) { if err := addFileToZip(zipWriter, fileName, alias); err != nil { - log.Errorf("download: failed adding %s to zip (%s)", sanitize.Log(file.FileName), err) + log.Errorf("download: failed adding %s to zip (%s)", clean.Log(file.FileName), err) Abort(c, http.StatusInternalServerError, i18n.ErrZipFailed) return } - log.Infof("download: added %s as %s", sanitize.Log(file.FileName), sanitize.Log(alias)) + log.Infof("download: added %s as %s", clean.Log(file.FileName), clean.Log(alias)) } else { - log.Warnf("download: media file %s is missing", sanitize.Log(file.FileName)) + log.Warnf("download: media file %s is missing", clean.Log(file.FileName)) logError("download", file.Update("FileMissing", true)) } } elapsed := int(time.Since(start).Seconds()) - log.Infof("download: created %s [%s]", sanitize.Log(zipBaseName), time.Since(start)) + log.Infof("download: created %s [%s]", clean.Log(zipBaseName), time.Since(start)) c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "message": i18n.Msg(i18n.MsgZipCreatedIn, elapsed), "filename": zipBaseName}) }) @@ -158,12 +150,12 @@ func DownloadZip(router *gin.RouterGroup) { } conf := service.Config() - zipBaseName := sanitize.FileName(filepath.Base(c.Param("filename"))) + zipBaseName := clean.FileName(filepath.Base(c.Param("filename"))) zipPath := path.Join(conf.TempPath(), "zip") zipFileName := path.Join(zipPath, zipBaseName) if !fs.FileExists(zipFileName) { - log.Errorf("could not find zip file: %s", sanitize.Log(zipFileName)) + log.Errorf("could not find zip file: %s", clean.Log(zipFileName)) c.Data(404, "image/svg+xml", photoIconSvg) return } @@ -171,7 +163,7 @@ func DownloadZip(router *gin.RouterGroup) { c.FileAttachment(zipFileName, zipBaseName) if err := os.Remove(zipFileName); err != nil { - log.Errorf("download: failed removing %s (%s)", sanitize.Log(zipFileName), err.Error()) + log.Errorf("download: failed removing %s (%s)", clean.Log(zipFileName), err.Error()) } }) } diff --git a/internal/api/errors.go b/internal/api/errors.go index 5959c2d7d..0149e99cf 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -31,7 +31,7 @@ func GetErrors(router *gin.RouterGroup) { // Find and return matching logs. if resp, err := query.Errors(limit, offset, c.Query("q")); err != nil { - c.AbortWithStatusJSON(400, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(400, gin.H{"error": txt.UpperFirst(err.Error())}) return } else { AddCountHeader(c, len(resp)) diff --git a/internal/api/face.go b/internal/api/face.go index 60d028979..3e118be99 100644 --- a/internal/api/face.go +++ b/internal/api/face.go @@ -3,7 +3,7 @@ package api import ( "net/http" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/gin-gonic/gin" @@ -58,7 +58,7 @@ func UpdateFace(router *gin.RouterGroup) { return } - faceId := sanitize.Token(c.Param("id")) + faceId := clean.Token(c.Param("id")) m := entity.FindFace(faceId) if m == nil { @@ -70,7 +70,7 @@ func UpdateFace(router *gin.RouterGroup) { if !f.FaceHidden && f.FaceHidden == m.FaceHidden { // Do nothing. } else if err := m.Update("FaceHidden", f.FaceHidden); err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UpperFirst(err.Error())}) return } @@ -78,7 +78,7 @@ func UpdateFace(router *gin.RouterGroup) { if f.SubjUID == "" { // Do nothing. } else if err := m.SetSubjectUID(f.SubjUID); err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UpperFirst(err.Error())}) return } diff --git a/internal/api/file.go b/internal/api/file.go index aba4da250..bc8b3181f 100644 --- a/internal/api/file.go +++ b/internal/api/file.go @@ -3,7 +3,7 @@ package api import ( "net/http" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/gin-gonic/gin" "github.com/photoprism/photoprism/internal/acl" @@ -24,7 +24,7 @@ func GetFile(router *gin.RouterGroup) { return } - p, err := query.FileByHash(sanitize.Token(c.Param("hash"))) + p, err := query.FileByHash(clean.Token(c.Param("hash"))) if err != nil { AbortEntityNotFound(c) diff --git a/internal/api/file_delete.go b/internal/api/file_delete.go index bfb7839ab..9375f71df 100644 --- a/internal/api/file_delete.go +++ b/internal/api/file_delete.go @@ -4,7 +4,7 @@ import ( "net/http" "path/filepath" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/gin-gonic/gin" "github.com/photoprism/photoprism/internal/acl" @@ -36,8 +36,8 @@ func DeleteFile(router *gin.RouterGroup) { return } - photoUID := sanitize.IdString(c.Param("uid")) - fileUID := sanitize.IdString(c.Param("file_uid")) + photoUID := clean.IdString(c.Param("uid")) + fileUID := clean.IdString(c.Param("file_uid")) file, err := query.FileByUID(fileUID) @@ -59,17 +59,17 @@ func DeleteFile(router *gin.RouterGroup) { mediaFile, err := photoprism.NewMediaFile(fileName) if err != nil { - log.Errorf("photo: %s (delete %s)", err, sanitize.Log(baseName)) + log.Errorf("photo: %s (delete %s)", err, clean.Log(baseName)) AbortEntityNotFound(c) return } if err := mediaFile.Remove(); err != nil { - log.Errorf("photo: %s (delete %s from folder)", err, sanitize.Log(baseName)) + log.Errorf("photo: %s (delete %s from folder)", err, clean.Log(baseName)) } if err := file.Delete(true); err != nil { - log.Errorf("photo: %s (delete %s from index)", err, sanitize.Log(baseName)) + log.Errorf("photo: %s (delete %s from index)", err, clean.Log(baseName)) AbortDeleteFailed(c) return } diff --git a/internal/api/folder_cover.go b/internal/api/folder_cover.go index 3fd787858..266ee1171 100644 --- a/internal/api/folder_cover.go +++ b/internal/api/folder_cover.go @@ -5,7 +5,7 @@ import ( "path/filepath" "time" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/gin-gonic/gin" "github.com/photoprism/photoprism/internal/photoprism" @@ -37,7 +37,7 @@ func FolderCover(router *gin.RouterGroup) { start := time.Now() conf := service.Config() uid := c.Param("uid") - thumbName := thumb.Name(sanitize.Token(c.Param("size"))) + thumbName := thumb.Name(clean.Token(c.Param("size"))) download := c.Query("download") != "" size, ok := thumb.Sizes[thumbName] @@ -98,7 +98,7 @@ func FolderCover(router *gin.RouterGroup) { c.Data(http.StatusOK, "image/svg+xml", folderIconSvg) // Set missing flag so that the file doesn't show up in search results anymore. - log.Warnf("%s: %s is missing", folderCover, sanitize.Log(f.FileName)) + log.Warnf("%s: %s is missing", folderCover, clean.Log(f.FileName)) logError(folderCover, f.Update("FileMissing", true)) return } diff --git a/internal/api/import.go b/internal/api/import.go index 447e44ef2..cb1f6c8bf 100644 --- a/internal/api/import.go +++ b/internal/api/import.go @@ -18,8 +18,8 @@ import ( "github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/service" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // StartImport imports media files from a directory and converts/indexes them as needed. @@ -53,7 +53,7 @@ func StartImport(router *gin.RouterGroup) { subPath := "" path := conf.ImportPath() - if subPath = sanitize.Path(c.Param("path")); subPath != "" && subPath != "/" { + if subPath = clean.Path(c.Param("path")); subPath != "" && subPath != "/" { subPath = strings.Replace(subPath, ".", "", -1) path = filepath.Join(path, subPath) } else if f.Path != "" { @@ -70,15 +70,15 @@ func StartImport(router *gin.RouterGroup) { var opt photoprism.ImportOptions if f.Move { - event.InfoMsg(i18n.MsgMovingFilesFrom, sanitize.Log(filepath.Base(path))) + event.InfoMsg(i18n.MsgMovingFilesFrom, clean.Log(filepath.Base(path))) opt = photoprism.ImportOptionsMove(path) } else { - event.InfoMsg(i18n.MsgCopyingFilesFrom, sanitize.Log(filepath.Base(path))) + event.InfoMsg(i18n.MsgCopyingFilesFrom, clean.Log(filepath.Base(path))) opt = photoprism.ImportOptionsCopy(path) } if len(f.Albums) > 0 { - log.Debugf("import: adding files to album %s", sanitize.Log(strings.Join(f.Albums, " and "))) + log.Debugf("import: adding files to album %s", clean.Log(strings.Join(f.Albums, " and "))) opt.Albums = f.Albums } @@ -86,9 +86,9 @@ func StartImport(router *gin.RouterGroup) { if subPath != "" && path != conf.ImportPath() && fs.IsEmpty(path) { if err := os.Remove(path); err != nil { - log.Errorf("import: failed deleting empty folder %s: %s", sanitize.Log(path), err) + log.Errorf("import: failed deleting empty folder %s: %s", clean.Log(path), err) } else { - log.Infof("import: deleted empty folder %s", sanitize.Log(path)) + log.Infof("import: deleted empty folder %s", clean.Log(path)) } } diff --git a/internal/api/index.go b/internal/api/index.go index 96a4a65b1..c5a50815d 100644 --- a/internal/api/index.go +++ b/internal/api/index.go @@ -13,7 +13,7 @@ import ( "github.com/photoprism/photoprism/internal/i18n" "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/service" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/txt" ) @@ -53,7 +53,7 @@ func StartIndexing(router *gin.RouterGroup) { indOpt := photoprism.NewIndexOptions(filepath.Clean(f.Path), f.Rescan, convert, true, false) if len(indOpt.Path) > 1 { - event.InfoMsg(i18n.MsgIndexingFiles, sanitize.Log(indOpt.Path)) + event.InfoMsg(i18n.MsgIndexingFiles, clean.Log(indOpt.Path)) } else { event.InfoMsg(i18n.MsgIndexingOriginals) } @@ -70,7 +70,7 @@ func StartIndexing(router *gin.RouterGroup) { } if files, photos, err := prg.Start(prgOpt); err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UpperFirst(err.Error())}) return } else if len(files) > 0 || len(photos) > 0 { event.InfoMsg(i18n.MsgRemovedFilesAndPhotos, len(files), len(photos)) diff --git a/internal/api/label.go b/internal/api/label.go index e3ffee49c..8ab258ee6 100644 --- a/internal/api/label.go +++ b/internal/api/label.go @@ -3,7 +3,7 @@ package api import ( "net/http" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/gin-gonic/gin" @@ -35,7 +35,7 @@ func UpdateLabel(router *gin.RouterGroup) { return } - id := sanitize.IdString(c.Param("uid")) + id := clean.IdString(c.Param("uid")) m, err := query.LabelByUID(id) if err != nil { @@ -69,16 +69,16 @@ func LikeLabel(router *gin.RouterGroup) { return } - id := sanitize.IdString(c.Param("uid")) + id := clean.IdString(c.Param("uid")) label, err := query.LabelByUID(id) if err != nil { - c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UpperFirst(err.Error())}) return } if err := label.Update("LabelFavorite", true); err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UpperFirst(err.Error())}) return } @@ -109,16 +109,16 @@ func DislikeLabel(router *gin.RouterGroup) { return } - id := sanitize.IdString(c.Param("uid")) + id := clean.IdString(c.Param("uid")) label, err := query.LabelByUID(id) if err != nil { - c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UpperFirst(err.Error())}) return } if err := label.Update("LabelFavorite", false); err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UpperFirst(err.Error())}) return } diff --git a/internal/api/link.go b/internal/api/link.go index 3a1a3a647..ea9a8d624 100644 --- a/internal/api/link.go +++ b/internal/api/link.go @@ -13,7 +13,7 @@ import ( "github.com/photoprism/photoprism/internal/i18n" "github.com/photoprism/photoprism/internal/query" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/txt" ) @@ -33,7 +33,7 @@ func UpdateLink(c *gin.Context) { return } - link := entity.FindLink(sanitize.Token(c.Param("link"))) + link := entity.FindLink(clean.Token(c.Param("link"))) link.SetSlug(f.ShareSlug) link.MaxViews = f.MaxViews @@ -45,13 +45,13 @@ func UpdateLink(c *gin.Context) { if f.Password != "" { if err := link.SetPassword(f.Password); err != nil { - c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UpperFirst(err.Error())}) return } } if err := link.Save(); err != nil { - c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UpperFirst(err.Error())}) return } @@ -73,10 +73,10 @@ func DeleteLink(c *gin.Context) { return } - link := entity.FindLink(sanitize.Token(c.Param("link"))) + link := entity.FindLink(clean.Token(c.Param("link"))) if err := link.Delete(); err != nil { - c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UpperFirst(err.Error())}) return } @@ -105,7 +105,7 @@ func CreateLink(c *gin.Context) { return } - link := entity.NewLink(sanitize.IdString(c.Param("uid")), f.CanComment, f.CanEdit) + link := entity.NewLink(clean.IdString(c.Param("uid")), f.CanComment, f.CanEdit) link.SetSlug(f.ShareSlug) link.MaxViews = f.MaxViews @@ -113,13 +113,13 @@ func CreateLink(c *gin.Context) { if f.Password != "" { if err := link.SetPassword(f.Password); err != nil { - c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UpperFirst(err.Error())}) return } } if err := link.Save(); err != nil { - c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UpperFirst(err.Error())}) return } @@ -135,7 +135,7 @@ func CreateLink(c *gin.Context) { // POST /api/v1/albums/:uid/links func CreateAlbumLink(router *gin.RouterGroup) { router.POST("/albums/:uid/links", func(c *gin.Context) { - if _, err := query.AlbumByUID(sanitize.IdString(c.Param("uid"))); err != nil { + if _, err := query.AlbumByUID(clean.IdString(c.Param("uid"))); err != nil { Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound) return } @@ -161,7 +161,7 @@ func DeleteAlbumLink(router *gin.RouterGroup) { // GET /api/v1/albums/:uid/links func GetAlbumLinks(router *gin.RouterGroup) { router.GET("/albums/:uid/links", func(c *gin.Context) { - m, err := query.AlbumByUID(sanitize.IdString(c.Param("uid"))) + m, err := query.AlbumByUID(clean.IdString(c.Param("uid"))) if err != nil { Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound) @@ -175,7 +175,7 @@ func GetAlbumLinks(router *gin.RouterGroup) { // POST /api/v1/photos/:uid/links func CreatePhotoLink(router *gin.RouterGroup) { router.POST("/photos/:uid/links", func(c *gin.Context) { - if _, err := query.PhotoByUID(sanitize.IdString(c.Param("uid"))); err != nil { + if _, err := query.PhotoByUID(clean.IdString(c.Param("uid"))); err != nil { AbortEntityNotFound(c) return } @@ -201,7 +201,7 @@ func DeletePhotoLink(router *gin.RouterGroup) { // GET /api/v1/photos/:uid/links func GetPhotoLinks(router *gin.RouterGroup) { router.GET("/photos/:uid/links", func(c *gin.Context) { - m, err := query.PhotoByUID(sanitize.IdString(c.Param("uid"))) + m, err := query.PhotoByUID(clean.IdString(c.Param("uid"))) if err != nil { Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound) @@ -215,7 +215,7 @@ func GetPhotoLinks(router *gin.RouterGroup) { // POST /api/v1/labels/:uid/links func CreateLabelLink(router *gin.RouterGroup) { router.POST("/labels/:uid/links", func(c *gin.Context) { - if _, err := query.LabelByUID(sanitize.IdString(c.Param("uid"))); err != nil { + if _, err := query.LabelByUID(clean.IdString(c.Param("uid"))); err != nil { Abort(c, http.StatusNotFound, i18n.ErrLabelNotFound) return } @@ -241,7 +241,7 @@ func DeleteLabelLink(router *gin.RouterGroup) { // GET /api/v1/labels/:uid/links func GetLabelLinks(router *gin.RouterGroup) { router.GET("/labels/:uid/links", func(c *gin.Context) { - m, err := query.LabelByUID(sanitize.IdString(c.Param("uid"))) + m, err := query.LabelByUID(clean.IdString(c.Param("uid"))) if err != nil { Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound) diff --git a/internal/api/moments_time.go b/internal/api/moments_time.go index 86a9412cd..564ba1b3b 100644 --- a/internal/api/moments_time.go +++ b/internal/api/moments_time.go @@ -23,7 +23,7 @@ func GetMomentsTime(router *gin.RouterGroup) { result, err := query.MomentsTime(1) if err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UpperFirst(err.Error())}) return } diff --git a/internal/api/photo.go b/internal/api/photo.go index cd34f2e11..8236134cb 100644 --- a/internal/api/photo.go +++ b/internal/api/photo.go @@ -15,8 +15,8 @@ import ( "github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/service" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // SavePhotoAsYaml saves photo data as YAML file. @@ -33,7 +33,7 @@ func SavePhotoAsYaml(p entity.Photo) { if err := p.SaveAsYaml(fileName); err != nil { log.Errorf("photo: %s (update yaml)", err) } else { - log.Debugf("photo: updated yaml file %s", sanitize.Log(filepath.Base(fileName))) + log.Debugf("photo: updated yaml file %s", clean.Log(filepath.Base(fileName))) } } @@ -51,7 +51,7 @@ func GetPhoto(router *gin.RouterGroup) { return } - p, err := query.PhotoPreloadByUID(sanitize.IdString(c.Param("uid"))) + p, err := query.PhotoPreloadByUID(clean.IdString(c.Param("uid"))) if err != nil { AbortEntityNotFound(c) @@ -74,7 +74,7 @@ func UpdatePhoto(router *gin.RouterGroup) { return } - uid := sanitize.IdString(c.Param("uid")) + uid := clean.IdString(c.Param("uid")) m, err := query.PhotoByUID(uid) if err != nil { @@ -136,7 +136,7 @@ func GetPhotoDownload(router *gin.RouterGroup) { return } - f, err := query.FileByPhotoUID(sanitize.IdString(c.Param("uid"))) + f, err := query.FileByPhotoUID(clean.IdString(c.Param("uid"))) if err != nil { c.Data(http.StatusNotFound, "image/svg+xml", photoIconSvg) @@ -146,7 +146,7 @@ func GetPhotoDownload(router *gin.RouterGroup) { fileName := photoprism.FileName(f.FileRoot, f.FileName) if !fs.FileExists(fileName) { - log.Errorf("photo: file %s is missing", sanitize.Log(f.FileName)) + log.Errorf("photo: file %s is missing", clean.Log(f.FileName)) c.Data(http.StatusNotFound, "image/svg+xml", photoIconSvg) // Set missing flag so that the file doesn't show up in search results anymore. @@ -172,7 +172,7 @@ func GetPhotoYaml(router *gin.RouterGroup) { return } - p, err := query.PhotoPreloadByUID(sanitize.IdString(c.Param("uid"))) + p, err := query.PhotoPreloadByUID(clean.IdString(c.Param("uid"))) if err != nil { c.AbortWithStatus(http.StatusNotFound) @@ -187,7 +187,7 @@ func GetPhotoYaml(router *gin.RouterGroup) { } if c.Query("download") != "" { - AddDownloadHeader(c, sanitize.IdString(c.Param("uid"))+fs.YamlExt) + AddDownloadHeader(c, clean.IdString(c.Param("uid"))+fs.ExtYAML) } c.Data(http.StatusOK, "text/x-yaml; charset=utf-8", data) @@ -207,7 +207,7 @@ func ApprovePhoto(router *gin.RouterGroup) { return } - id := sanitize.IdString(c.Param("uid")) + id := clean.IdString(c.Param("uid")) m, err := query.PhotoByUID(id) if err != nil { @@ -242,7 +242,7 @@ func LikePhoto(router *gin.RouterGroup) { return } - id := sanitize.IdString(c.Param("uid")) + id := clean.IdString(c.Param("uid")) m, err := query.PhotoByUID(id) if err != nil { @@ -277,7 +277,7 @@ func DislikePhoto(router *gin.RouterGroup) { return } - id := sanitize.IdString(c.Param("uid")) + id := clean.IdString(c.Param("uid")) m, err := query.PhotoByUID(id) if err != nil { @@ -313,8 +313,8 @@ func PhotoPrimary(router *gin.RouterGroup) { return } - uid := sanitize.IdString(c.Param("uid")) - fileUID := sanitize.IdString(c.Param("file_uid")) + uid := clean.IdString(c.Param("uid")) + fileUID := clean.IdString(c.Param("file_uid")) err := query.SetPhotoPrimary(uid, fileUID) if err != nil { diff --git a/internal/api/photo_label.go b/internal/api/photo_label.go index 6b069b3a4..781321da2 100644 --- a/internal/api/photo_label.go +++ b/internal/api/photo_label.go @@ -4,7 +4,7 @@ import ( "net/http" "strconv" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/gin-gonic/gin" "github.com/photoprism/photoprism/internal/acl" @@ -29,7 +29,7 @@ func AddPhotoLabel(router *gin.RouterGroup) { return } - m, err := query.PhotoByUID(sanitize.IdString(c.Param("uid"))) + m, err := query.PhotoByUID(clean.IdString(c.Param("uid"))) if err != nil { AbortEntityNotFound(c) @@ -70,7 +70,7 @@ func AddPhotoLabel(router *gin.RouterGroup) { } } - p, err := query.PhotoPreloadByUID(sanitize.IdString(c.Param("uid"))) + p, err := query.PhotoPreloadByUID(clean.IdString(c.Param("uid"))) if err != nil { AbortEntityNotFound(c) @@ -78,7 +78,7 @@ func AddPhotoLabel(router *gin.RouterGroup) { } if err := p.SaveLabels(); err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UpperFirst(err.Error())}) return } @@ -104,24 +104,24 @@ func RemovePhotoLabel(router *gin.RouterGroup) { return } - m, err := query.PhotoByUID(sanitize.IdString(c.Param("uid"))) + m, err := query.PhotoByUID(clean.IdString(c.Param("uid"))) if err != nil { AbortEntityNotFound(c) return } - labelId, err := strconv.Atoi(sanitize.Token(c.Param("id"))) + labelId, err := strconv.Atoi(clean.Token(c.Param("id"))) if err != nil { - c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UpperFirst(err.Error())}) return } label, err := query.PhotoLabel(m.ID, uint(labelId)) if err != nil { - c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UpperFirst(err.Error())}) return } @@ -132,7 +132,7 @@ func RemovePhotoLabel(router *gin.RouterGroup) { logError("label", entity.Db().Save(&label).Error) } - p, err := query.PhotoPreloadByUID(sanitize.IdString(c.Param("uid"))) + p, err := query.PhotoPreloadByUID(clean.IdString(c.Param("uid"))) if err != nil { AbortEntityNotFound(c) @@ -142,11 +142,11 @@ func RemovePhotoLabel(router *gin.RouterGroup) { logError("label", p.RemoveKeyword(label.Label.LabelName)) if err := p.SaveLabels(); err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UpperFirst(err.Error())}) return } - PublishPhotoEvent(EntityUpdated, sanitize.IdString(c.Param("uid")), c) + PublishPhotoEvent(EntityUpdated, clean.IdString(c.Param("uid")), c) event.Success("label removed") @@ -170,24 +170,24 @@ func UpdatePhotoLabel(router *gin.RouterGroup) { // TODO: Code clean-up, simplify - m, err := query.PhotoByUID(sanitize.IdString(c.Param("uid"))) + m, err := query.PhotoByUID(clean.IdString(c.Param("uid"))) if err != nil { AbortEntityNotFound(c) return } - labelId, err := strconv.Atoi(sanitize.Token(c.Param("id"))) + labelId, err := strconv.Atoi(clean.Token(c.Param("id"))) if err != nil { - c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UpperFirst(err.Error())}) return } label, err := query.PhotoLabel(m.ID, uint(labelId)) if err != nil { - c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UpperFirst(err.Error())}) return } @@ -197,11 +197,11 @@ func UpdatePhotoLabel(router *gin.RouterGroup) { } if err := label.Save(); err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UpperFirst(err.Error())}) return } - p, err := query.PhotoPreloadByUID(sanitize.IdString(c.Param("uid"))) + p, err := query.PhotoPreloadByUID(clean.IdString(c.Param("uid"))) if err != nil { AbortEntityNotFound(c) @@ -209,11 +209,11 @@ func UpdatePhotoLabel(router *gin.RouterGroup) { } if err := p.SaveLabels(); err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UpperFirst(err.Error())}) return } - PublishPhotoEvent(EntityUpdated, sanitize.IdString(c.Param("uid")), c) + PublishPhotoEvent(EntityUpdated, clean.IdString(c.Param("uid")), c) event.Success("label saved") diff --git a/internal/api/photo_unstack.go b/internal/api/photo_unstack.go index cf705dbb2..8ebf21c89 100644 --- a/internal/api/photo_unstack.go +++ b/internal/api/photo_unstack.go @@ -5,7 +5,7 @@ import ( "net/http" "path/filepath" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/gin-gonic/gin" "github.com/photoprism/photoprism/internal/acl" @@ -34,7 +34,7 @@ func PhotoUnstack(router *gin.RouterGroup) { } conf := service.Config() - fileUID := sanitize.IdString(c.Param("file_uid")) + fileUID := clean.IdString(c.Param("file_uid")) file, err := query.FileByUID(fileUID) if err != nil { @@ -63,7 +63,7 @@ func PhotoUnstack(router *gin.RouterGroup) { unstackFile, err := photoprism.NewMediaFile(fileName) if err != nil { - log.Errorf("photo: %s (unstack %s)", err, sanitize.Log(baseName)) + log.Errorf("photo: %s (unstack %s)", err, clean.Log(baseName)) AbortEntityNotFound(c) return } else if file.Photo == nil { @@ -76,7 +76,7 @@ func PhotoUnstack(router *gin.RouterGroup) { stackPrimary, err := stackPhoto.PrimaryFile() if err != nil { - log.Errorf("photo: cannot find primary file for %s (unstack)", sanitize.Log(baseName)) + log.Errorf("photo: cannot find primary file for %s (unstack)", clean.Log(baseName)) AbortUnexpected(c) return } @@ -87,15 +87,15 @@ func PhotoUnstack(router *gin.RouterGroup) { related, err := unstackFile.RelatedFiles(false) if err != nil { - log.Errorf("photo: %s (unstack %s)", err, sanitize.Log(baseName)) + log.Errorf("photo: %s (unstack %s)", err, clean.Log(baseName)) AbortEntityNotFound(c) return } else if related.Len() == 0 { - log.Errorf("photo: found no files for %s (unstack)", sanitize.Log(baseName)) + log.Errorf("photo: found no files for %s (unstack)", clean.Log(baseName)) AbortEntityNotFound(c) return } else if related.Main == nil { - log.Errorf("photo: found no main file for %s (unstack)", sanitize.Log(baseName)) + log.Errorf("photo: found no main file for %s (unstack)", clean.Log(baseName)) AbortEntityNotFound(c) return } @@ -105,7 +105,7 @@ func PhotoUnstack(router *gin.RouterGroup) { if unstackFile.BasePrefix(false) == stackPhoto.PhotoName { if conf.ReadOnly() { - log.Errorf("photo: cannot rename files in read only mode (unstack %s)", sanitize.Log(baseName)) + log.Errorf("photo: cannot rename files in read only mode (unstack %s)", clean.Log(baseName)) AbortFeatureDisabled(c) return } @@ -113,7 +113,7 @@ func PhotoUnstack(router *gin.RouterGroup) { destName := fmt.Sprintf("%s.%s%s", unstackFile.AbsPrefix(false), unstackFile.Checksum(), unstackFile.Extension()) if err := unstackFile.Move(destName); err != nil { - log.Errorf("photo: cannot rename %s to %s (unstack)", sanitize.Log(unstackFile.BaseName()), sanitize.Log(filepath.Base(destName))) + log.Errorf("photo: cannot rename %s to %s (unstack)", clean.Log(unstackFile.BaseName()), clean.Log(filepath.Base(destName))) AbortUnexpected(c) return } @@ -130,7 +130,7 @@ func PhotoUnstack(router *gin.RouterGroup) { newPhoto.PhotoName = unstackFile.BasePrefix(false) if err := newPhoto.Create(); err != nil { - log.Errorf("photo: %s (unstack %s)", err.Error(), sanitize.Log(baseName)) + log.Errorf("photo: %s (unstack %s)", err.Error(), clean.Log(baseName)) AbortSaveFailed(c) return } @@ -150,17 +150,17 @@ func PhotoUnstack(router *gin.RouterGroup) { newPhoto.ID, newPhoto.PhotoUID, r.RootRelName(), relName, relRoot).Error; err != nil { // Handle error... - log.Errorf("photo: %s (unstack %s)", err.Error(), sanitize.Log(r.BaseName())) + log.Errorf("photo: %s (unstack %s)", err.Error(), clean.Log(r.BaseName())) // Remove new photo from index. if _, err := newPhoto.Delete(true); err != nil { - log.Errorf("photo: %s (unstack %s)", err.Error(), sanitize.Log(r.BaseName())) + log.Errorf("photo: %s (unstack %s)", err.Error(), clean.Log(r.BaseName())) } // Revert file rename. if unstackSingle { if err := r.Move(photoprism.FileName(relRoot, relName)); err != nil { - log.Errorf("photo: %s (unstack %s)", err.Error(), sanitize.Log(r.BaseName())) + log.Errorf("photo: %s (unstack %s)", err.Error(), clean.Log(r.BaseName())) } } @@ -173,21 +173,21 @@ func PhotoUnstack(router *gin.RouterGroup) { // Index unstacked files. if res := ind.FileName(unstackFile.FileName(), photoprism.IndexOptionsSingle()); res.Failed() { - log.Errorf("photo: %s (unstack %s)", res.Err, sanitize.Log(baseName)) + log.Errorf("photo: %s (unstack %s)", res.Err, clean.Log(baseName)) AbortSaveFailed(c) return } // Reset type for existing photo stack to image. if err := stackPhoto.Update("PhotoType", entity.MediaImage); err != nil { - log.Errorf("photo: %s (unstack %s)", err, sanitize.Log(baseName)) + log.Errorf("photo: %s (unstack %s)", err, clean.Log(baseName)) AbortUnexpected(c) return } // Re-index existing photo stack. if res := ind.FileName(photoprism.FileName(stackPrimary.FileRoot, stackPrimary.FileName), photoprism.IndexOptionsSingle()); res.Failed() { - log.Errorf("photo: %s (unstack %s)", res.Err, sanitize.Log(baseName)) + log.Errorf("photo: %s (unstack %s)", res.Err, clean.Log(baseName)) AbortSaveFailed(c) return } diff --git a/internal/api/search_albums.go b/internal/api/search_albums.go index 427cb7501..c216e5d60 100644 --- a/internal/api/search_albums.go +++ b/internal/api/search_albums.go @@ -41,7 +41,7 @@ func SearchAlbums(router *gin.RouterGroup) { result, err := search.Albums(f) if err != nil { - c.AbortWithStatusJSON(400, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(400, gin.H{"error": txt.UpperFirst(err.Error())}) return } diff --git a/internal/api/search_faces.go b/internal/api/search_faces.go index b09b30ebb..140ffe590 100644 --- a/internal/api/search_faces.go +++ b/internal/api/search_faces.go @@ -36,7 +36,7 @@ func SearchFaces(router *gin.RouterGroup) { result, err := search.Faces(f) if err != nil { - c.AbortWithStatusJSON(400, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(400, gin.H{"error": txt.UpperFirst(err.Error())}) return } diff --git a/internal/api/search_folders.go b/internal/api/search_folders.go index 2887d9f1a..4499efbd7 100644 --- a/internal/api/search_folders.go +++ b/internal/api/search_folders.go @@ -6,7 +6,7 @@ import ( "path/filepath" "time" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/gin-gonic/gin" "github.com/gin-gonic/gin/binding" @@ -68,7 +68,7 @@ func SearchFolders(router *gin.RouterGroup, urlPath, rootName, rootPath string) listFiles := f.Files uncached := listFiles || f.Uncached resp := FoldersResponse{Root: rootName, Recursive: recursive, Cached: !uncached} - path := sanitize.Path(c.Param("path")) + path := clean.Path(c.Param("path")) cacheKey := fmt.Sprintf("folder:%s:%t:%t", filepath.Join(rootName, path), recursive, listFiles) diff --git a/internal/api/search_geojson.go b/internal/api/search_geojson.go index 7b4ff4eaf..91e18b419 100644 --- a/internal/api/search_geojson.go +++ b/internal/api/search_geojson.go @@ -11,7 +11,7 @@ import ( "github.com/photoprism/photoprism/internal/search" "github.com/photoprism/photoprism/internal/service" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/txt" ) @@ -66,7 +66,7 @@ func SearchGeo(router *gin.RouterGroup) { var resp []byte // Render JSON response. - switch sanitize.Token(c.Param("format")) { + switch clean.Token(c.Param("format")) { case "view": conf := service.Config() resp, err = photos.ViewerJSON(conf.ContentUri(), conf.ApiUri(), conf.PreviewToken(), conf.DownloadToken()) @@ -75,7 +75,7 @@ func SearchGeo(router *gin.RouterGroup) { } if err != nil { - c.AbortWithStatusJSON(400, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(400, gin.H{"error": txt.UpperFirst(err.Error())}) return } diff --git a/internal/api/search_labels.go b/internal/api/search_labels.go index 59f4b667f..5f6ce8c19 100644 --- a/internal/api/search_labels.go +++ b/internal/api/search_labels.go @@ -35,7 +35,7 @@ func SearchLabels(router *gin.RouterGroup) { result, err := search.Labels(f) if err != nil { - c.AbortWithStatusJSON(400, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(400, gin.H{"error": txt.UpperFirst(err.Error())}) return } diff --git a/internal/api/search_subjects.go b/internal/api/search_subjects.go index e507f5a4d..a48538172 100644 --- a/internal/api/search_subjects.go +++ b/internal/api/search_subjects.go @@ -36,7 +36,7 @@ func SearchSubjects(router *gin.RouterGroup) { result, err := search.Subjects(f) if err != nil { - c.AbortWithStatusJSON(400, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(400, gin.H{"error": txt.UpperFirst(err.Error())}) return } diff --git a/internal/api/session.go b/internal/api/session.go index 8877164e3..842fb1829 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -3,7 +3,7 @@ package api import ( "net/http" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/gin-gonic/gin" "github.com/photoprism/photoprism/internal/acl" @@ -91,7 +91,7 @@ func CreateSession(router *gin.RouterGroup) { // DELETE /api/v1/session/:id func DeleteSession(router *gin.RouterGroup) { router.DELETE("/session/:id", func(c *gin.Context) { - id := sanitize.Token(c.Param("id")) + id := clean.Token(c.Param("id")) service.Session().Delete(id) @@ -128,10 +128,10 @@ func Auth(id string, resource acl.Resource, action acl.Action) session.Data { // InvalidPreviewToken returns true if the token is invalid. func InvalidPreviewToken(c *gin.Context) bool { - token := sanitize.Token(c.Param("token")) + token := clean.Token(c.Param("token")) if token == "" { - token = sanitize.Token(c.Query("t")) + token = clean.Token(c.Query("t")) } return service.Config().InvalidPreviewToken(token) @@ -139,5 +139,5 @@ func InvalidPreviewToken(c *gin.Context) bool { // InvalidDownloadToken returns true if the token is invalid. func InvalidDownloadToken(c *gin.Context) bool { - return service.Config().InvalidDownloadToken(sanitize.Token(c.Query("t"))) + return service.Config().InvalidDownloadToken(clean.Token(c.Query("t"))) } diff --git a/internal/api/share.go b/internal/api/share.go index df6aefb40..8a7d54522 100644 --- a/internal/api/share.go +++ b/internal/api/share.go @@ -9,7 +9,7 @@ import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/service" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // GET /s/:token/... @@ -17,7 +17,7 @@ func Shares(router *gin.RouterGroup) { router.GET("/:token", func(c *gin.Context) { conf := service.Config() - token := sanitize.Token(c.Param("token")) + token := clean.Token(c.Param("token")) links := entity.FindValidLinks(token, "") @@ -36,8 +36,8 @@ func Shares(router *gin.RouterGroup) { router.GET("/:token/:share", func(c *gin.Context) { conf := service.Config() - token := sanitize.Token(c.Param("token")) - share := sanitize.Token(c.Param("share")) + token := clean.Token(c.Param("token")) + share := clean.Token(c.Param("share")) links := entity.FindValidLinks(token, share) diff --git a/internal/api/share_preview.go b/internal/api/share_preview.go index f330d4089..40bb968e4 100644 --- a/internal/api/share_preview.go +++ b/internal/api/share_preview.go @@ -9,7 +9,7 @@ import ( "path" "time" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/disintegration/imaging" "github.com/gin-gonic/gin" @@ -30,8 +30,8 @@ func SharePreview(router *gin.RouterGroup) { router.GET("/:token/:share/preview", func(c *gin.Context) { conf := service.Config() - token := sanitize.Token(c.Param("token")) - share := sanitize.Token(c.Param("share")) + token := clean.Token(c.Param("token")) + share := clean.Token(c.Param("share")) links := entity.FindLinks(token, share) if len(links) != 1 { @@ -52,13 +52,13 @@ func SharePreview(router *gin.RouterGroup) { yesterday := time.Now().Add(-24 * time.Hour) if info, err := os.Stat(previewFilename); err != nil { - log.Debugf("share: creating new preview for %s", sanitize.Log(share)) + log.Debugf("share: creating new preview for %s", clean.Log(share)) } else if info.ModTime().After(yesterday) { - log.Debugf("share: using cached preview for %s", sanitize.Log(share)) + log.Debugf("share: using cached preview for %s", clean.Log(share)) c.File(previewFilename) return } else if err := os.Remove(previewFilename); err != nil { - log.Errorf("share: could not remove old preview of %s", sanitize.Log(share)) + log.Errorf("share: could not remove old preview of %s", clean.Log(share)) c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview()) return } @@ -96,7 +96,7 @@ func SharePreview(router *gin.RouterGroup) { fileName := photoprism.FileName(f.FileRoot, f.FileName) if !fs.FileExists(fileName) { - log.Errorf("share: file %s is missing (preview)", sanitize.Log(f.FileName)) + log.Errorf("share: file %s is missing (preview)", clean.Log(f.FileName)) c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview()) return } @@ -126,7 +126,7 @@ func SharePreview(router *gin.RouterGroup) { fileName := photoprism.FileName(f.FileRoot, f.FileName) if !fs.FileExists(fileName) { - log.Errorf("share: file %s is missing (preview)", sanitize.Log(f.FileName)) + log.Errorf("share: file %s is missing (preview)", clean.Log(f.FileName)) c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview()) return } diff --git a/internal/api/subject.go b/internal/api/subject.go index 7e8077d47..d149a528b 100644 --- a/internal/api/subject.go +++ b/internal/api/subject.go @@ -3,7 +3,7 @@ package api import ( "net/http" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/gin-gonic/gin" @@ -28,7 +28,7 @@ func GetSubject(router *gin.RouterGroup) { return } - if subj := entity.FindSubject(sanitize.IdString(c.Param("uid"))); subj == nil { + if subj := entity.FindSubject(clean.IdString(c.Param("uid"))); subj == nil { Abort(c, http.StatusNotFound, i18n.ErrSubjectNotFound) return } else { @@ -56,7 +56,7 @@ func UpdateSubject(router *gin.RouterGroup) { return } - uid := sanitize.IdString(c.Param("uid")) + uid := clean.IdString(c.Param("uid")) m := entity.FindSubject(uid) if m == nil { @@ -109,7 +109,7 @@ func LikeSubject(router *gin.RouterGroup) { return } - uid := sanitize.IdString(c.Param("uid")) + uid := clean.IdString(c.Param("uid")) subj := entity.FindSubject(uid) if subj == nil { @@ -118,7 +118,7 @@ func LikeSubject(router *gin.RouterGroup) { } if err := subj.Update("SubjFavorite", true); err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UpperFirst(err.Error())}) return } @@ -143,7 +143,7 @@ func DislikeSubject(router *gin.RouterGroup) { return } - uid := sanitize.IdString(c.Param("uid")) + uid := clean.IdString(c.Param("uid")) subj := entity.FindSubject(uid) if subj == nil { @@ -152,7 +152,7 @@ func DislikeSubject(router *gin.RouterGroup) { } if err := subj.Update("SubjFavorite", false); err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())}) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UpperFirst(err.Error())}) return } diff --git a/internal/api/thumbnails.go b/internal/api/thumbnails.go index a2ad6ddeb..e6b234608 100644 --- a/internal/api/thumbnails.go +++ b/internal/api/thumbnails.go @@ -13,8 +13,8 @@ import ( "github.com/photoprism/photoprism/internal/service" "github.com/photoprism/photoprism/internal/thumb" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // GetThumb returns a thumbnail image matching the file hash, crop area, and type. @@ -37,16 +37,16 @@ func GetThumb(router *gin.RouterGroup) { start := time.Now() conf := service.Config() download := c.Query("download") != "" - fileHash, cropArea := crop.ParseThumb(sanitize.Token(c.Param("thumb"))) + fileHash, cropArea := crop.ParseThumb(clean.Token(c.Param("thumb"))) // Is cropped thumbnail? if cropArea != "" { - cropName := crop.Name(sanitize.Token(c.Param("size"))) + cropName := crop.Name(clean.Token(c.Param("size"))) cropSize, ok := crop.Sizes[cropName] if !ok { - log.Errorf("%s: invalid size %s", logPrefix, sanitize.Log(string(cropName))) + log.Errorf("%s: invalid size %s", logPrefix, clean.Log(string(cropName))) c.Data(http.StatusOK, "image/svg+xml", photoIconSvg) return } @@ -73,12 +73,12 @@ func GetThumb(router *gin.RouterGroup) { return } - thumbName := thumb.Name(sanitize.Token(c.Param("size"))) + thumbName := thumb.Name(clean.Token(c.Param("size"))) size, ok := thumb.Sizes[thumbName] if !ok { - log.Errorf("%s: invalid size %s", logPrefix, sanitize.Log(thumbName.String())) + log.Errorf("%s: invalid size %s", logPrefix, clean.Log(thumbName.String())) c.Data(http.StatusOK, "image/svg+xml", photoIconSvg) return } @@ -153,17 +153,17 @@ func GetThumb(router *gin.RouterGroup) { fileName := photoprism.FileName(f.FileRoot, f.FileName) if !fs.FileExists(fileName) { - log.Errorf("%s: file %s is missing", logPrefix, sanitize.Log(f.FileName)) + log.Errorf("%s: file %s is missing", logPrefix, clean.Log(f.FileName)) c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg) // Set missing flag so that the file doesn't show up in search results anymore. logError(logPrefix, f.Update("FileMissing", true)) if f.AllFilesMissing() { - log.Infof("%s: deleting photo, all files missing for %s", logPrefix, sanitize.Log(f.FileName)) + log.Infof("%s: deleting photo, all files missing for %s", logPrefix, clean.Log(f.FileName)) if _, err := f.RelatedPhoto().Delete(false); err != nil { - log.Errorf("%s: %s while deleting %s", logPrefix, err, sanitize.Log(f.FileName)) + log.Errorf("%s: %s while deleting %s", logPrefix, err, clean.Log(f.FileName)) } } diff --git a/internal/api/upload.go b/internal/api/upload.go index 1a9644e32..70aa56687 100644 --- a/internal/api/upload.go +++ b/internal/api/upload.go @@ -12,7 +12,7 @@ import ( "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/i18n" "github.com/photoprism/photoprism/internal/service" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // POST /api/v1/upload/:path @@ -32,7 +32,7 @@ func Upload(router *gin.RouterGroup) { } start := time.Now() - subPath := sanitize.Path(c.Param("path")) + subPath := clean.Path(c.Param("path")) f, err := c.MultipartForm() @@ -51,7 +51,7 @@ func Upload(router *gin.RouterGroup) { p := path.Join(conf.ImportPath(), "upload", subPath) if err := os.MkdirAll(p, os.ModePerm); err != nil { - log.Errorf("upload: failed creating folder %s", sanitize.Log(subPath)) + log.Errorf("upload: failed creating folder %s", clean.Log(subPath)) AbortBadRequest(c) return } @@ -59,10 +59,10 @@ func Upload(router *gin.RouterGroup) { for _, file := range files { filename := path.Join(p, filepath.Base(file.Filename)) - log.Debugf("upload: saving file %s", sanitize.Log(file.Filename)) + log.Debugf("upload: saving file %s", clean.Log(file.Filename)) if err := c.SaveUploadedFile(file, filename); err != nil { - log.Errorf("upload: failed saving file %s", sanitize.Log(filepath.Base(file.Filename))) + log.Errorf("upload: failed saving file %s", clean.Log(filepath.Base(file.Filename))) AbortBadRequest(c) return } @@ -87,7 +87,7 @@ func Upload(router *gin.RouterGroup) { continue } - log.Infof("nsfw: %s might be offensive", sanitize.Log(filename)) + log.Infof("nsfw: %s might be offensive", clean.Log(filename)) containsNSFW = true } @@ -95,7 +95,7 @@ func Upload(router *gin.RouterGroup) { if containsNSFW { for _, filename := range uploads { if err := os.Remove(filename); err != nil { - log.Errorf("nsfw: could not delete %s", sanitize.Log(filename)) + log.Errorf("nsfw: could not delete %s", clean.Log(filename)) } } diff --git a/internal/api/user_password.go b/internal/api/user_password.go index cf024cd02..ad07903a9 100644 --- a/internal/api/user_password.go +++ b/internal/api/user_password.go @@ -3,7 +3,7 @@ package api import ( "net/http" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/gin-gonic/gin" "github.com/photoprism/photoprism/internal/acl" @@ -30,7 +30,7 @@ func ChangePassword(router *gin.RouterGroup) { return } - uid := sanitize.IdString(c.Param("uid")) + uid := clean.IdString(c.Param("uid")) m := entity.FindUserByUID(uid) if s.User.UserUID != m.UserUID { diff --git a/internal/api/video.go b/internal/api/video.go index 0c4dc6365..2bcb8f675 100644 --- a/internal/api/video.go +++ b/internal/api/video.go @@ -3,13 +3,14 @@ package api import ( "net/http" + "github.com/photoprism/photoprism/pkg/video" + "github.com/gin-gonic/gin" "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/service" - "github.com/photoprism/photoprism/internal/video" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // GetVideo streams videos. @@ -26,13 +27,13 @@ func GetVideo(router *gin.RouterGroup) { return } - fileHash := sanitize.Token(c.Param("hash")) - formatName := sanitize.Token(c.Param("format")) + fileHash := clean.Token(c.Param("hash")) + formatName := clean.Token(c.Param("format")) - format, ok := video.Formats[formatName] + format, ok := video.Types[formatName] if !ok { - log.Errorf("video: invalid format %s", sanitize.Log(formatName)) + log.Errorf("video: invalid format %s", clean.Log(formatName)) c.Data(http.StatusOK, "image/svg+xml", videoIconSvg) return } @@ -64,7 +65,7 @@ func GetVideo(router *gin.RouterGroup) { fileName := photoprism.FileName(f.FileRoot, f.FileName) if mf, err := photoprism.NewMediaFile(fileName); err != nil { - log.Errorf("video: file %s is missing", sanitize.Log(f.FileName)) + log.Errorf("video: file %s is missing", clean.Log(f.FileName)) c.Data(http.StatusOK, "image/svg+xml", videoIconSvg) // Set missing flag so that the file doesn't show up in search results anymore. @@ -75,7 +76,7 @@ func GetVideo(router *gin.RouterGroup) { conv := service.Convert() if avcFile, err := conv.ToAvc(mf, service.Config().FFmpegEncoder(), false, false); err != nil { - log.Errorf("video: transcoding %s failed", sanitize.Log(f.FileName)) + log.Errorf("video: transcoding %s failed", clean.Log(f.FileName)) c.Data(http.StatusOK, "image/svg+xml", videoIconSvg) return } else { diff --git a/internal/auto/import.go b/internal/auto/import.go index 019be0eb5..9a964c75c 100644 --- a/internal/auto/import.go +++ b/internal/auto/import.go @@ -12,7 +12,7 @@ import ( "github.com/photoprism/photoprism/internal/mutex" "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/service" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) var autoImport = time.Time{} @@ -66,7 +66,7 @@ func Import() error { api.RemoveFromFolderCache(entity.RootImport) - event.InfoMsg(i18n.MsgCopyingFilesFrom, sanitize.Log(filepath.Base(path))) + event.InfoMsg(i18n.MsgCopyingFilesFrom, clean.Log(filepath.Base(path))) var opt photoprism.ImportOptions diff --git a/internal/classify/gen.go b/internal/classify/gen.go index 25e2f5697..df357249d 100644 --- a/internal/classify/gen.go +++ b/internal/classify/gen.go @@ -11,8 +11,8 @@ import ( "text/template" "unicode" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" "gopkg.in/yaml.v2" ) @@ -34,7 +34,7 @@ func main() { fileName := "rules.yml" if !fs.FileExists(fileName) { - log.Panicf("classify: found no label rules in %s", sanitize.Log(filepath.Base(fileName))) + log.Panicf("classify: found no label rules in %s", clean.Log(filepath.Base(fileName))) } yamlConfig, err := os.ReadFile(fileName) diff --git a/internal/classify/tensorflow.go b/internal/classify/tensorflow.go index d1f48c52b..e831a8d64 100644 --- a/internal/classify/tensorflow.go +++ b/internal/classify/tensorflow.go @@ -14,7 +14,7 @@ import ( "strings" "github.com/disintegration/imaging" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" tf "github.com/tensorflow/tensorflow/tensorflow/go" ) @@ -148,7 +148,7 @@ func (t *TensorFlow) loadModel() error { modelPath := path.Join(t.modelsPath, t.modelName) - log.Infof("classify: loading %s", sanitize.Log(filepath.Base(modelPath))) + log.Infof("classify: loading %s", clean.Log(filepath.Base(modelPath))) // Load model model, err := tf.LoadSavedModel(modelPath, t.modelTags, nil) diff --git a/internal/commands/backup.go b/internal/commands/backup.go index dc76b2001..1a2443b17 100644 --- a/internal/commands/backup.go +++ b/internal/commands/backup.go @@ -16,8 +16,8 @@ import ( "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/service" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) const backupDescription = "A user-defined SQL dump FILENAME or - for stdout can be passed as the first argument. " + @@ -113,7 +113,7 @@ func backupAction(ctx *cli.Context) error { } } - log.Infof("writing SQL dump to %s", sanitize.Log(indexFileName)) + log.Infof("writing SQL dump to %s", clean.Log(indexFileName)) } var cmd *exec.Cmd @@ -178,7 +178,7 @@ func backupAction(ctx *cli.Context) error { albumsPath = conf.AlbumsPath() } - log.Infof("saving albums in %s", sanitize.Log(albumsPath)) + log.Infof("saving albums in %s", clean.Log(albumsPath)) if count, err := photoprism.BackupAlbums(albumsPath, true); err != nil { return err diff --git a/internal/commands/convert.go b/internal/commands/convert.go index 155bcdbda..3a4ed326c 100644 --- a/internal/commands/convert.go +++ b/internal/commands/convert.go @@ -10,7 +10,7 @@ import ( "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/service" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // ConvertCommand registers the convert cli command. @@ -54,7 +54,7 @@ func convertAction(ctx *cli.Context) error { convertPath = filepath.Join(convertPath, subPath) } - log.Infof("converting originals in %s", sanitize.Log(convertPath)) + log.Infof("converting originals in %s", clean.Log(convertPath)) w := service.Convert() diff --git a/internal/commands/faces.go b/internal/commands/faces.go index e134bef26..12155486c 100644 --- a/internal/commands/faces.go +++ b/internal/commands/faces.go @@ -14,8 +14,8 @@ import ( "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/service" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // FacesCommand registers the facial recognition subcommands. @@ -239,9 +239,9 @@ func facesIndexAction(ctx *cli.Context) error { subPath := strings.TrimSpace(ctx.Args().First()) if subPath == "" { - log.Infof("finding faces in %s", sanitize.Log(conf.OriginalsPath())) + log.Infof("finding faces in %s", clean.Log(conf.OriginalsPath())) } else { - log.Infof("finding faces in %s", sanitize.Log(filepath.Join(conf.OriginalsPath(), subPath))) + log.Infof("finding faces in %s", clean.Log(filepath.Join(conf.OriginalsPath(), subPath))) } if conf.ReadOnly() { diff --git a/internal/commands/index.go b/internal/commands/index.go index c3f0aac32..9aa314e1f 100644 --- a/internal/commands/index.go +++ b/internal/commands/index.go @@ -13,8 +13,8 @@ import ( "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/service" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // IndexCommand registers the index cli command. @@ -57,9 +57,9 @@ func indexAction(ctx *cli.Context) error { subPath := strings.TrimSpace(ctx.Args().First()) if subPath == "" { - log.Infof("indexing originals in %s", sanitize.Log(conf.OriginalsPath())) + log.Infof("indexing originals in %s", clean.Log(conf.OriginalsPath())) } else { - log.Infof("indexing originals in %s", sanitize.Log(filepath.Join(conf.OriginalsPath(), subPath))) + log.Infof("indexing originals in %s", clean.Log(filepath.Join(conf.OriginalsPath(), subPath))) } if conf.ReadOnly() { diff --git a/internal/commands/passwd.go b/internal/commands/passwd.go index e214e4709..ebda39800 100644 --- a/internal/commands/passwd.go +++ b/internal/commands/passwd.go @@ -14,7 +14,7 @@ import ( "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/entity" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // PasswdCommand updates a password. @@ -39,7 +39,7 @@ func passwdAction(ctx *cli.Context) error { user := entity.Admin - log.Infof("please enter a new password for %s (at least 6 characters)\n", sanitize.Log(user.Username())) + log.Infof("please enter a new password for %s (at least 6 characters)\n", clean.Log(user.Username())) newPassword := getPassword("New Password: ") @@ -57,7 +57,7 @@ func passwdAction(ctx *cli.Context) error { return err } - log.Infof("changed password for %s\n", sanitize.Log(user.Username())) + log.Infof("changed password for %s\n", clean.Log(user.Username())) conf.Shutdown() diff --git a/internal/commands/purge.go b/internal/commands/purge.go index d0786dbf7..a0020371d 100644 --- a/internal/commands/purge.go +++ b/internal/commands/purge.go @@ -13,8 +13,8 @@ import ( "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/service" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // PurgeCommand registers the index cli command. @@ -56,9 +56,9 @@ func purgeAction(ctx *cli.Context) error { subPath := strings.TrimSpace(ctx.Args().First()) if subPath == "" { - log.Infof("purge: removing missing files in %s", sanitize.Log(filepath.Base(conf.OriginalsPath()))) + log.Infof("purge: removing missing files in %s", clean.Log(filepath.Base(conf.OriginalsPath()))) } else { - log.Infof("purge: removing missing files in %s", sanitize.Log(fs.RelName(filepath.Join(conf.OriginalsPath(), subPath), filepath.Dir(conf.OriginalsPath())))) + log.Infof("purge: removing missing files in %s", clean.Log(fs.RelName(filepath.Join(conf.OriginalsPath(), subPath), filepath.Dir(conf.OriginalsPath())))) } if conf.ReadOnly() { diff --git a/internal/commands/restore.go b/internal/commands/restore.go index b95a34c82..073590688 100644 --- a/internal/commands/restore.go +++ b/internal/commands/restore.go @@ -18,8 +18,8 @@ import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/service" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) const restoreDescription = "A user-defined SQL dump FILENAME can be passed as the first argument. " + @@ -124,7 +124,7 @@ func restoreAction(ctx *cli.Context) error { log.Warnf("replacing existing index with %d photos", counts.Photos) } - log.Infof("restoring index from %s", sanitize.Log(indexFileName)) + log.Infof("restoring index from %s", clean.Log(indexFileName)) sqlBackup, err := os.ReadFile(indexFileName) @@ -203,9 +203,9 @@ func restoreAction(ctx *cli.Context) error { } if !fs.PathExists(albumsPath) { - log.Warnf("album files path %s not found", sanitize.Log(albumsPath)) + log.Warnf("album files path %s not found", clean.Log(albumsPath)) } else { - log.Infof("restoring albums from %s", sanitize.Log(albumsPath)) + log.Infof("restoring albums from %s", clean.Log(albumsPath)) if count, err := photoprism.RestoreAlbums(albumsPath, true); err != nil { return err diff --git a/internal/commands/show_formats.go b/internal/commands/show_formats.go index cee8a14a3..dac01964f 100644 --- a/internal/commands/show_formats.go +++ b/internal/commands/show_formats.go @@ -6,6 +6,7 @@ import ( "github.com/urfave/cli" "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/media" "github.com/photoprism/photoprism/pkg/report" ) @@ -15,8 +16,8 @@ var ShowFormatsCommand = cli.Command{ Usage: "Lists supported media and sidecar file formats", Flags: []cli.Flag{ cli.BoolFlag{ - Name: "compact, c", - Usage: "hide format descriptions to make the output more compact", + Name: "short, s", + Usage: "hides format descriptions", }, cli.BoolFlag{ Name: "md, m", @@ -28,8 +29,7 @@ var ShowFormatsCommand = cli.Command{ // showFormatsAction lists supported media and sidecar file formats. func showFormatsAction(ctx *cli.Context) error { - rows, cols := fs.Extensions.Formats(true).Report(!ctx.Bool("compact"), true, true) - + rows, cols := media.Report(fs.Extensions.Types(true), !ctx.Bool("short"), true, true) fmt.Println(report.Table(rows, cols, ctx.Bool("md"))) return nil diff --git a/internal/commands/start.go b/internal/commands/start.go index d3f125607..ea4717760 100644 --- a/internal/commands/start.go +++ b/internal/commands/start.go @@ -18,8 +18,8 @@ import ( "github.com/photoprism/photoprism/internal/server" "github.com/photoprism/photoprism/internal/service" "github.com/photoprism/photoprism/internal/workers" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // StartCommand registers the start cli command. @@ -95,7 +95,7 @@ func startAction(ctx *cli.Context) error { if child != nil { if !fs.Overwrite(conf.PIDFilename(), []byte(strconv.Itoa(child.Pid))) { - log.Fatalf("failed writing process id to %s", sanitize.Log(conf.PIDFilename())) + log.Fatalf("failed writing process id to %s", clean.Log(conf.PIDFilename())) } log.Infof("daemon started with process id %v\n", child.Pid) diff --git a/internal/commands/stop.go b/internal/commands/stop.go index 43bbfe10d..e63793a54 100644 --- a/internal/commands/stop.go +++ b/internal/commands/stop.go @@ -7,7 +7,7 @@ import ( "github.com/urfave/cli" "github.com/photoprism/photoprism/internal/config" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // StopCommand registers the stop cli command. @@ -22,7 +22,7 @@ var StopCommand = cli.Command{ func stopAction(ctx *cli.Context) error { conf := config.NewConfig(ctx) - log.Infof("looking for pid in %s", sanitize.Log(conf.PIDFilename())) + log.Infof("looking for pid in %s", clean.Log(conf.PIDFilename())) dcxt := new(daemon.Context) dcxt.PidFileName = conf.PIDFilename() diff --git a/internal/commands/thumbs.go b/internal/commands/thumbs.go index 25ce66790..500d4f731 100644 --- a/internal/commands/thumbs.go +++ b/internal/commands/thumbs.go @@ -7,7 +7,7 @@ import ( "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/service" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // ThumbsCommand registers the resample cli command. @@ -34,7 +34,7 @@ func thumbsAction(ctx *cli.Context) error { return err } - log.Infof("creating thumbnails in %s", sanitize.Log(conf.ThumbPath())) + log.Infof("creating thumbnails in %s", clean.Log(conf.ThumbPath())) rs := service.Resample() diff --git a/internal/commands/users.go b/internal/commands/users.go index fa0a1f10f..6a0714306 100644 --- a/internal/commands/users.go +++ b/internal/commands/users.go @@ -14,7 +14,7 @@ import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/query" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // UsersCommand registers user management subcommands. @@ -183,7 +183,7 @@ func usersDeleteAction(ctx *cli.Context) error { } actionPrompt := promptui.Prompt{ - Label: fmt.Sprintf("Delete %s?", sanitize.Log(userName)), + Label: fmt.Sprintf("Delete %s?", clean.Log(userName)), IsConfirm: true, } @@ -193,7 +193,7 @@ func usersDeleteAction(ctx *cli.Context) error { } else if err := m.Delete(); err != nil { return err } else { - log.Infof("%s deleted", sanitize.Log(userName)) + log.Infof("%s deleted", clean.Log(userName)) } } else { log.Infof("keeping user") @@ -242,7 +242,7 @@ func usersUpdateAction(ctx *cli.Context) error { if err != nil { return err } - fmt.Printf("password successfully changed: %s\n", sanitize.Log(u.Username())) + fmt.Printf("password successfully changed: %s\n", clean.Log(u.Username())) } if ctx.IsSet("fullname") { @@ -261,7 +261,7 @@ func usersUpdateAction(ctx *cli.Context) error { return err } - fmt.Printf("user successfully updated: %s\n", sanitize.Log(u.Username())) + fmt.Printf("user successfully updated: %s\n", clean.Log(u.Username())) return nil }) diff --git a/internal/config/doc.go b/internal/config/about.go similarity index 100% rename from internal/config/doc.go rename to internal/config/about.go diff --git a/internal/config/client.go b/internal/config/client_config.go similarity index 96% rename from internal/config/client.go rename to internal/config/client_config.go index 46b1f3dcd..dfd53db28 100644 --- a/internal/config/client.go +++ b/internal/config/client_config.go @@ -6,7 +6,9 @@ import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/query" + "github.com/photoprism/photoprism/pkg/colors" + "github.com/photoprism/photoprism/pkg/env" "github.com/photoprism/photoprism/pkg/txt" ) @@ -64,7 +66,7 @@ type ClientConfig struct { Colors []map[string]string `json:"colors"` Categories CategoryLabels `json:"categories"` Clip int `json:"clip"` - Server RuntimeInfo `json:"server"` + Server env.Resources `json:"server"` } // Years represents a list of years. @@ -384,7 +386,7 @@ func (c *Config) UserConfig() ClientConfig { PreviewToken: c.PreviewToken(), ManifestUri: c.ClientManifestUri(), Clip: txt.ClipDefault, - Server: NewRuntimeInfo(), + Server: env.Info(), } c.Db(). @@ -409,12 +411,12 @@ func (c *Config) UserConfig() ClientConfig { c.Db(). Table("photos"). - Select("SUM(photo_type = 'video' AND photo_quality >= 0 AND photo_private = 0) AS videos, " + - "SUM(photo_type = 'live' AND photo_quality >= 0 AND photo_private = 0) AS live, " + - "SUM(photo_quality = -1) AS hidden, SUM(photo_type IN ('image','raw','animated') AND photo_private = 0 AND photo_quality >= 0) AS photos, " + - "SUM(photo_type IN ('image','raw','live','animated') AND photo_quality < 3 AND photo_quality >= 0 AND photo_private = 0) AS review, " + - "SUM(photo_favorite = 1 AND photo_private = 0 AND photo_quality >= 0) AS favorites, " + - "SUM(photo_private = 1 AND photo_quality >= 0) AS private"). + Select("SUM(photo_type = 'video' AND photo_quality > -1 AND photo_private = 0) AS videos, " + + "SUM(photo_type = 'live' AND photo_quality > -1 AND photo_private = 0) AS live, " + + "SUM(photo_quality = -1) AS hidden, SUM(photo_type IN ('image','raw','animated') AND photo_private = 0 AND photo_quality > -1) AS photos, " + + "SUM(photo_type IN ('image','raw','live','animated') AND photo_quality < 3 AND photo_quality > -1 AND photo_private = 0) AS review, " + + "SUM(photo_favorite = 1 AND photo_private = 0 AND photo_quality > -1) AS favorites, " + + "SUM(photo_private = 1 AND photo_quality > -1) AS private"). Where("photos.id NOT IN (SELECT photo_id FROM files WHERE file_primary = 1 AND (file_missing = 1 OR file_error <> ''))"). Where("deleted_at IS NULL"). Take(&result.Count) @@ -460,7 +462,7 @@ func (c *Config) UserConfig() ClientConfig { result.People, _ = query.People() c.Db(). - Where("id IN (SELECT photos.camera_id FROM photos WHERE photos.photo_quality >= 0 OR photos.deleted_at IS NULL)"). + Where("id IN (SELECT photos.camera_id FROM photos WHERE photos.photo_quality > -1 OR photos.deleted_at IS NULL)"). Where("deleted_at IS NULL"). Limit(10000).Order("camera_slug"). Find(&result.Cameras) @@ -477,7 +479,7 @@ func (c *Config) UserConfig() ClientConfig { c.Db(). Table("photos"). - Where("photo_year > 0 AND (photos.photo_quality >= 0 OR photos.deleted_at IS NULL)"). + Where("photo_year > 0 AND (photos.photo_quality > -1 OR photos.deleted_at IS NULL)"). Order("photo_year DESC"). Pluck("DISTINCT photo_year", &result.Years) diff --git a/internal/config/client_test.go b/internal/config/client_config_test.go similarity index 100% rename from internal/config/client_test.go rename to internal/config/client_config_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 9ffc08ecd..4341a1660 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,9 +29,9 @@ import ( "github.com/photoprism/photoprism/internal/hub/places" "github.com/photoprism/photoprism/internal/mutex" "github.com/photoprism/photoprism/internal/thumb" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/rnd" - "github.com/photoprism/photoprism/pkg/sanitize" ) var log = event.Log @@ -39,37 +39,6 @@ var once sync.Once var LowMem = false var TotalMem uint64 -const MsgSponsor = "PhotoPrism® needs your support!" -const SignUpURL = "https://docs.photoprism.app/funding/" -const MsgSignUp = "Visit " + SignUpURL + " to learn more." -const MsgSponsorCommand = "Since running this command puts additional load on our infrastructure," + - " we unfortunately can only offer it to sponsors." - -const ApiUri = "/api/v1" // REST API -const StaticUri = "/static" // Static Content - -const DefaultAutoIndexDelay = int(5 * 60) // 5 Minutes -const DefaultAutoImportDelay = int(3 * 60) // 3 Minutes - -const DefaultWakeupIntervalSeconds = int(15 * 60) // 15 Minutes -const DefaultWakeupInterval = time.Second * time.Duration(DefaultWakeupIntervalSeconds) -const MaxWakeupInterval = time.Hour * 24 // 1 Day - -// Megabyte in bytes. -const Megabyte = 1000 * 1000 - -// Gigabyte in bytes. -const Gigabyte = Megabyte * 1000 - -// MinMem is the minimum amount of system memory required. -const MinMem = Gigabyte - -// RecommendedMem is the recommended amount of system memory. -const RecommendedMem = 3 * Gigabyte - -// serialName is the name of the unique storage serial. -const serialName = "serial" - // Config holds database, cache and all parameters of photoprism type Config struct { once sync.Once @@ -125,16 +94,16 @@ func NewConfig(ctx *cli.Context) *Config { // Initialize options from config file and CLI context. c := &Config{ options: NewOptions(ctx), - token: rnd.Token(8), + token: rnd.GenerateToken(8), env: os.Getenv("DOCKER_ENV"), } // Overwrite values with options.yml from config path. if optionsYaml := c.OptionsYaml(); fs.FileExists(optionsYaml) { if err := c.options.Load(optionsYaml); err != nil { - log.Warnf("config: failed loading values from %s (%s)", sanitize.Log(optionsYaml), err) + log.Warnf("config: failed loading values from %s (%s)", clean.Log(optionsYaml), err) } else { - log.Debugf("config: overriding config with values from %s", sanitize.Log(optionsYaml)) + log.Debugf("config: overriding config with values from %s", clean.Log(optionsYaml)) } } @@ -210,7 +179,7 @@ func (c *Config) Init() error { } if cpuName := cpuid.CPU.BrandName; cpuName != "" { - log.Debugf("config: running on %s, %s memory detected", sanitize.Log(cpuid.CPU.BrandName), humanize.Bytes(TotalMem)) + log.Debugf("config: running on %s, %s memory detected", clean.Log(cpuid.CPU.BrandName), humanize.Bytes(TotalMem)) } // Exit if less than 128 MB RAM was detected. @@ -261,7 +230,7 @@ func (c *Config) readSerial() string { if data, err := os.ReadFile(storageName); err == nil && len(data) == 16 { return string(data) } else { - log.Tracef("config: could not read %s (%s)", sanitize.Log(storageName), err) + log.Tracef("config: could not read %s (%s)", clean.Log(storageName), err) } } @@ -269,7 +238,7 @@ func (c *Config) readSerial() string { if data, err := os.ReadFile(backupName); err == nil && len(data) == 16 { return string(data) } else { - log.Tracef("config: could not read %s (%s)", sanitize.Log(backupName), err) + log.Tracef("config: could not read %s (%s)", clean.Log(backupName), err) } } @@ -282,7 +251,7 @@ func (c *Config) initSerial() (err error) { return nil } - c.serial = rnd.PPID('z') + c.serial = rnd.GenerateUID('z') storageName := filepath.Join(c.StoragePath(), serialName) backupName := filepath.Join(c.BackupPath(), serialName) diff --git a/internal/config/auth.go b/internal/config/config_auth.go similarity index 96% rename from internal/config/auth.go rename to internal/config/config_auth.go index 4a08bd66f..70ef55166 100644 --- a/internal/config/auth.go +++ b/internal/config/config_auth.go @@ -35,7 +35,7 @@ func (c *Config) InvalidDownloadToken(t string) bool { // DownloadToken returns the DOWNLOAD api token (you can optionally use a static value for permanent caching). func (c *Config) DownloadToken() string { if c.options.DownloadToken == "" { - c.options.DownloadToken = rnd.Token(8) + c.options.DownloadToken = rnd.GenerateToken(8) } return c.options.DownloadToken diff --git a/internal/config/auth_test.go b/internal/config/config_auth_test.go similarity index 100% rename from internal/config/auth_test.go rename to internal/config/config_auth_test.go diff --git a/internal/config/config_const.go b/internal/config/config_const.go new file mode 100644 index 000000000..98fe8ca93 --- /dev/null +++ b/internal/config/config_const.go @@ -0,0 +1,34 @@ +package config + +import "time" + +const MsgSponsor = "PhotoPrism® needs your support!" +const SignUpURL = "https://docs.photoprism.app/funding/" +const MsgSignUp = "Visit " + SignUpURL + " to learn more." +const MsgSponsorCommand = "Since running this command puts additional load on our infrastructure," + + " we unfortunately can only offer it to sponsors." + +const ApiUri = "/api/v1" // REST API +const StaticUri = "/static" // Static Content + +const DefaultAutoIndexDelay = int(5 * 60) // 5 Minutes +const DefaultAutoImportDelay = int(3 * 60) // 3 Minutes + +const DefaultWakeupIntervalSeconds = int(15 * 60) // 15 Minutes +const DefaultWakeupInterval = time.Second * time.Duration(DefaultWakeupIntervalSeconds) +const MaxWakeupInterval = time.Hour * 24 // 1 Day + +// Megabyte in bytes. +const Megabyte = 1000 * 1000 + +// Gigabyte in bytes. +const Gigabyte = Megabyte * 1000 + +// MinMem is the minimum amount of system memory required. +const MinMem = Gigabyte + +// RecommendedMem is the recommended amount of system memory. +const RecommendedMem = 3 * Gigabyte + +// serialName is the name of the unique storage serial. +const serialName = "serial" diff --git a/internal/config/database.go b/internal/config/config_db.go similarity index 100% rename from internal/config/database.go rename to internal/config/config_db.go diff --git a/internal/config/database_test.go b/internal/config/config_db_test.go similarity index 100% rename from internal/config/database_test.go rename to internal/config/config_db_test.go diff --git a/internal/config/face.go b/internal/config/config_faces.go similarity index 100% rename from internal/config/face.go rename to internal/config/config_faces.go diff --git a/internal/config/face_test.go b/internal/config/config_faces_test.go similarity index 100% rename from internal/config/face_test.go rename to internal/config/config_faces_test.go diff --git a/internal/config/disable.go b/internal/config/config_features.go similarity index 100% rename from internal/config/disable.go rename to internal/config/config_features.go diff --git a/internal/config/disable_test.go b/internal/config/config_features_test.go similarity index 100% rename from internal/config/disable_test.go rename to internal/config/config_features_test.go diff --git a/internal/config/ffmpeg.go b/internal/config/config_ffmpeg.go similarity index 100% rename from internal/config/ffmpeg.go rename to internal/config/config_ffmpeg.go diff --git a/internal/config/ffmpeg_test.go b/internal/config/config_ffmpeg_test.go similarity index 100% rename from internal/config/ffmpeg_test.go rename to internal/config/config_ffmpeg_test.go diff --git a/internal/config/fs.go b/internal/config/config_filepaths.go similarity index 97% rename from internal/config/fs.go rename to internal/config/config_filepaths.go index 362b805b0..afd358cf6 100644 --- a/internal/config/fs.go +++ b/internal/config/config_filepaths.go @@ -7,8 +7,8 @@ import ( "os/user" "path/filepath" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // binPaths stores known executable paths. @@ -48,9 +48,9 @@ func findExecutable(configBin, defaultBin string) (binPath string) { func (c *Config) CreateDirectories() error { createError := func(path string, err error) (result error) { if fs.FileExists(path) { - result = fmt.Errorf("directory path %s is a file, please check your configuration", sanitize.Log(path)) + result = fmt.Errorf("directory path %s is a file, please check your configuration", clean.Log(path)) } else { - result = fmt.Errorf("failed to create the directory %s, check configuration and permissions", sanitize.Log(path)) + result = fmt.Errorf("failed to create the directory %s, check configuration and permissions", clean.Log(path)) } log.Debug(err) @@ -59,7 +59,7 @@ func (c *Config) CreateDirectories() error { } notFoundError := func(name string) error { - return fmt.Errorf("invalid %s path, check configuration and permissions", sanitize.Log(name)) + return fmt.Errorf("invalid %s path, check configuration and permissions", clean.Log(name)) } if c.AssetsPath() == "" { @@ -154,11 +154,11 @@ func (c *Config) CreateDirectories() error { if c.DarktableEnabled() { if cachePath, err := c.CreateDarktableCachePath(); err != nil { - return fmt.Errorf("could not create darktable cache path %s", sanitize.Log(cachePath)) + return fmt.Errorf("could not create darktable cache path %s", clean.Log(cachePath)) } if configPath, err := c.CreateDarktableConfigPath(); err != nil { - return fmt.Errorf("could not create darktable cache path %s", sanitize.Log(configPath)) + return fmt.Errorf("could not create darktable cache path %s", clean.Log(configPath)) } } diff --git a/internal/config/fs_test.go b/internal/config/config_filepaths_test.go similarity index 96% rename from internal/config/fs_test.go rename to internal/config/config_filepaths_test.go index 89efce0b0..0d79e265a 100644 --- a/internal/config/fs_test.go +++ b/internal/config/config_filepaths_test.go @@ -86,7 +86,7 @@ func TestConfig_CreateDirectories(t *testing.T) { c := &Config{ options: NewTestOptions("config"), - token: rnd.Token(8), + token: rnd.GenerateToken(8), } if err := c.CreateDirectories(); err != nil { @@ -106,7 +106,7 @@ func TestConfig_CreateDirectories2(t *testing.T) { defer testConfigMutex.Unlock() c := &Config{ options: NewTestOptions(), - token: rnd.Token(8), + token: rnd.GenerateToken(8), } c.options.AssetsPath = "" @@ -130,7 +130,7 @@ func TestConfig_CreateDirectories2(t *testing.T) { defer testConfigMutex.Unlock() c := &Config{ options: NewTestOptions(), - token: rnd.Token(8), + token: rnd.GenerateToken(8), } c.options.StoragePath = "/-*&^%$#@!`~" @@ -147,7 +147,7 @@ func TestConfig_CreateDirectories2(t *testing.T) { defer testConfigMutex.Unlock() c := &Config{ options: NewTestOptions(), - token: rnd.Token(8), + token: rnd.GenerateToken(8), } c.options.OriginalsPath = "" @@ -172,7 +172,7 @@ func TestConfig_CreateDirectories2(t *testing.T) { defer testConfigMutex.Unlock() c := &Config{ options: NewTestOptions(), - token: rnd.Token(8), + token: rnd.GenerateToken(8), } c.options.ImportPath = "" @@ -197,7 +197,7 @@ func TestConfig_CreateDirectories2(t *testing.T) { defer testConfigMutex.Unlock() c := &Config{ options: NewTestOptions(), - token: rnd.Token(8), + token: rnd.GenerateToken(8), } c.options.SidecarPath = "/-*&^%$#@!`~" @@ -214,7 +214,7 @@ func TestConfig_CreateDirectories2(t *testing.T) { defer testConfigMutex.Unlock() c := &Config{ options: NewTestOptions(), - token: rnd.Token(8), + token: rnd.GenerateToken(8), } c.options.CachePath = "/-*&^%$#@!`~" @@ -231,7 +231,7 @@ func TestConfig_CreateDirectories2(t *testing.T) { defer testConfigMutex.Unlock() c := &Config{ options: NewTestOptions(), - token: rnd.Token(8), + token: rnd.GenerateToken(8), } c.options.ConfigPath = "/-*&^%$#@!`~" @@ -248,7 +248,7 @@ func TestConfig_CreateDirectories2(t *testing.T) { defer testConfigMutex.Unlock() c := &Config{ options: NewTestOptions(), - token: rnd.Token(8), + token: rnd.GenerateToken(8), } c.options.TempPath = "/-*&^%$#@!`~" diff --git a/internal/config/metadata.go b/internal/config/config_metadata.go similarity index 100% rename from internal/config/metadata.go rename to internal/config/config_metadata.go diff --git a/internal/config/metadata_test.go b/internal/config/config_metadata_test.go similarity index 100% rename from internal/config/metadata_test.go rename to internal/config/config_metadata_test.go diff --git a/internal/config/raw.go b/internal/config/config_raw.go similarity index 100% rename from internal/config/raw.go rename to internal/config/config_raw.go diff --git a/internal/config/raw_test.go b/internal/config/config_raw_test.go similarity index 100% rename from internal/config/raw_test.go rename to internal/config/config_raw_test.go diff --git a/internal/config/report.go b/internal/config/config_report.go similarity index 100% rename from internal/config/report.go rename to internal/config/config_report.go diff --git a/internal/config/resample.go b/internal/config/config_resample.go similarity index 100% rename from internal/config/resample.go rename to internal/config/config_resample.go diff --git a/internal/config/resample_test.go b/internal/config/config_resample_test.go similarity index 100% rename from internal/config/resample_test.go rename to internal/config/config_resample_test.go diff --git a/internal/config/server.go b/internal/config/config_server.go similarity index 100% rename from internal/config/server.go rename to internal/config/config_server.go diff --git a/internal/config/server_test.go b/internal/config/config_server_test.go similarity index 100% rename from internal/config/server_test.go rename to internal/config/config_server_test.go diff --git a/internal/config/tensorflow.go b/internal/config/config_tensorflow.go similarity index 100% rename from internal/config/tensorflow.go rename to internal/config/config_tensorflow.go diff --git a/internal/config/app.go b/internal/config/config_ui.go similarity index 73% rename from internal/config/app.go rename to internal/config/config_ui.go index 21a934842..9608ff04d 100644 --- a/internal/config/app.go +++ b/internal/config/config_ui.go @@ -4,10 +4,30 @@ import ( "path/filepath" "strings" + "github.com/photoprism/photoprism/internal/i18n" + "github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/txt" ) +// DefaultTheme returns the default user interface theme name. +func (c *Config) DefaultTheme() string { + if c.options.DefaultTheme == "" || !c.Sponsor() { + return "default" + } + + return c.options.DefaultTheme +} + +// DefaultLocale returns the default user interface language locale name. +func (c *Config) DefaultLocale() string { + if c.options.DefaultLocale == "" { + return i18n.Default.Locale() + } + + return c.options.DefaultLocale +} + // AppIcon returns the app icon when installed on a device. func (c *Config) AppIcon() string { defaultIcon := "logo" diff --git a/internal/config/app_test.go b/internal/config/config_ui_test.go similarity index 66% rename from internal/config/app_test.go rename to internal/config/config_ui_test.go index d549c6f54..0bc9ceacb 100644 --- a/internal/config/app_test.go +++ b/internal/config/config_ui_test.go @@ -7,6 +7,31 @@ import ( "github.com/stretchr/testify/assert" ) +func TestConfig_DefaultTheme(t *testing.T) { + c := NewConfig(CliTestContext()) + + assert.Equal(t, "default", c.DefaultTheme()) + c.options.Sponsor = false + c.options.DefaultTheme = "grayscale" + assert.Equal(t, "default", c.DefaultTheme()) + c.options.Sponsor = true + assert.Equal(t, "grayscale", c.DefaultTheme()) + c.options.DefaultTheme = "" + assert.Equal(t, "default", c.DefaultTheme()) + c.options.Sponsor = false + assert.Equal(t, "default", c.DefaultTheme()) +} + +func TestConfig_DefaultLocale(t *testing.T) { + c := NewConfig(CliTestContext()) + + assert.Equal(t, "en", c.DefaultLocale()) + c.options.DefaultLocale = "de" + assert.Equal(t, "de", c.DefaultLocale()) + c.options.DefaultLocale = "" + assert.Equal(t, "en", c.DefaultLocale()) +} + func TestConfig_AppIcon(t *testing.T) { c := NewConfig(CliTestContext()) diff --git a/internal/config/default.go b/internal/config/default.go deleted file mode 100644 index ef325d311..000000000 --- a/internal/config/default.go +++ /dev/null @@ -1,23 +0,0 @@ -package config - -import ( - "github.com/photoprism/photoprism/internal/i18n" -) - -// DefaultTheme returns the default user interface theme name. -func (c *Config) DefaultTheme() string { - if c.options.DefaultTheme == "" || !c.Sponsor() { - return "default" - } - - return c.options.DefaultTheme -} - -// DefaultLocale returns the default user interface language locale name. -func (c *Config) DefaultLocale() string { - if c.options.DefaultLocale == "" { - return i18n.Default.Locale() - } - - return c.options.DefaultLocale -} diff --git a/internal/config/default_test.go b/internal/config/default_test.go deleted file mode 100644 index 30f040e54..000000000 --- a/internal/config/default_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package config - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestConfig_DefaultTheme(t *testing.T) { - c := NewConfig(CliTestContext()) - - assert.Equal(t, "default", c.DefaultTheme()) - c.options.Sponsor = false - c.options.DefaultTheme = "grayscale" - assert.Equal(t, "default", c.DefaultTheme()) - c.options.Sponsor = true - assert.Equal(t, "grayscale", c.DefaultTheme()) - c.options.DefaultTheme = "" - assert.Equal(t, "default", c.DefaultTheme()) - c.options.Sponsor = false - assert.Equal(t, "default", c.DefaultTheme()) -} - -func TestConfig_DefaultLocale(t *testing.T) { - c := NewConfig(CliTestContext()) - - assert.Equal(t, "en", c.DefaultLocale()) - c.options.DefaultLocale = "de" - assert.Equal(t, "de", c.DefaultLocale()) - c.options.DefaultLocale = "" - assert.Equal(t, "en", c.DefaultLocale()) -} diff --git a/internal/config/errors.go b/internal/config/errors.go deleted file mode 100644 index b61ffeb66..000000000 --- a/internal/config/errors.go +++ /dev/null @@ -1,18 +0,0 @@ -package config - -import ( - "errors" -) - -// Define photoprism specific errors -var ( - ErrReadOnly = errors.New("not available in read-only mode") - ErrUnauthorized = errors.New("please log in and try again") - ErrUploadNSFW = errors.New("upload might be offensive") -) - -func LogError(err error) { - if err != nil { - log.Errorf("config: %s", err.Error()) - } -} diff --git a/internal/config/config_flags.go b/internal/config/global_flags.go similarity index 100% rename from internal/config/config_flags.go rename to internal/config/global_flags.go diff --git a/internal/config/config_options.go b/internal/config/global_options.go similarity index 98% rename from internal/config/config_options.go rename to internal/config/global_options.go index 43c08a694..e4a07f42b 100644 --- a/internal/config/config_options.go +++ b/internal/config/global_options.go @@ -10,8 +10,8 @@ import ( "github.com/urfave/cli" "gopkg.in/yaml.v2" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" "github.com/photoprism/photoprism/pkg/txt" ) @@ -150,9 +150,9 @@ func NewOptions(ctx *cli.Context) *Options { if defaultsYaml := ctx.GlobalString("defaults-yaml"); defaultsYaml == "" { log.Tracef("config: defaults yaml file not specified") } else if c.DefaultsYaml = fs.Abs(defaultsYaml); !fs.FileExists(c.DefaultsYaml) { - log.Tracef("config: defaults file %s does not exist", sanitize.Log(c.DefaultsYaml)) + log.Tracef("config: defaults file %s does not exist", clean.Log(c.DefaultsYaml)) } else if err := c.Load(c.DefaultsYaml); err != nil { - log.Warnf("config: failed loading defaults from %s (%s)", sanitize.Log(c.DefaultsYaml), err) + log.Warnf("config: failed loading defaults from %s (%s)", clean.Log(c.DefaultsYaml), err) } if err := c.SetContext(ctx); err != nil { diff --git a/internal/config/config_options_test.go b/internal/config/global_options_test.go similarity index 100% rename from internal/config/config_options_test.go rename to internal/config/global_options_test.go diff --git a/internal/config/log_error.go b/internal/config/log_error.go new file mode 100644 index 000000000..85a1e252b --- /dev/null +++ b/internal/config/log_error.go @@ -0,0 +1,15 @@ +package config + +import ( + "errors" +) + +var ( + ErrReadOnly = errors.New("not available in read-only mode") +) + +func LogError(err error) { + if err != nil { + log.Errorf("config: %s", err.Error()) + } +} diff --git a/internal/config/settings.go b/internal/config/settings.go index 86addb8e2..cab1a9e51 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -8,89 +8,10 @@ import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/i18n" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) -// UISettings represents user interface settings. -type UISettings struct { - Scrollbar bool `json:"scrollbar" yaml:"Scrollbar"` - Zoom bool `json:"zoom" yaml:"Zoom"` - Theme string `json:"theme" yaml:"Theme"` - Language string `json:"language" yaml:"Language"` -} - -// SearchSettings represents search UI preferences. -type SearchSettings struct { - BatchSize int `json:"batchSize" yaml:"BatchSize"` -} - -// TemplateSettings represents template settings for the UI and messaging. -type TemplateSettings struct { - Default string `json:"default" yaml:"Default"` -} - -// MapsSettings represents maps settings (for places). -type MapsSettings struct { - Animate int `json:"animate" yaml:"Animate"` - Style string `json:"style" yaml:"Style"` -} - -// FeatureSettings represents feature flags, mainly for the Web UI. -type FeatureSettings struct { - Upload bool `json:"upload" yaml:"Upload"` - Download bool `json:"download" yaml:"Download"` - Private bool `json:"private" yaml:"Private"` - Review bool `json:"review" yaml:"Review"` - Files bool `json:"files" yaml:"Files"` - Videos bool `json:"videos" yaml:"Videos"` - Folders bool `json:"folders" yaml:"Folders"` - Albums bool `json:"albums" yaml:"Albums"` - Moments bool `json:"moments" yaml:"Moments"` - Estimates bool `json:"estimates" yaml:"Estimates"` - People bool `json:"people" yaml:"People"` - Labels bool `json:"labels" yaml:"Labels"` - Places bool `json:"places" yaml:"Places"` - Edit bool `json:"edit" yaml:"Edit"` - Archive bool `json:"archive" yaml:"Archive"` - Delete bool `json:"delete" yaml:"Delete"` - Share bool `json:"share" yaml:"Share"` - Library bool `json:"library" yaml:"Library"` - Import bool `json:"import" yaml:"Import"` - Logs bool `json:"logs" yaml:"Logs"` -} - -// ImportSettings represents import settings. -type ImportSettings struct { - Path string `json:"path" yaml:"Path"` - Move bool `json:"move" yaml:"Move"` -} - -// IndexSettings represents indexing settings. -type IndexSettings struct { - Path string `json:"path" yaml:"Path"` - Convert bool `json:"convert" yaml:"Convert"` - Rescan bool `json:"rescan" yaml:"Rescan"` -} - -// StackSettings represents settings for files that belong to the same photo. -type StackSettings struct { - UUID bool `json:"uuid" yaml:"UUID"` - Meta bool `json:"meta" yaml:"Meta"` - Name bool `json:"name" yaml:"Name"` -} - -// ShareSettings represents content sharing settings. -type ShareSettings struct { - Title string `json:"title" yaml:"Title"` -} - -// DownloadSettings represents content download settings. -type DownloadSettings struct { - Name entity.DownloadName `json:"name" yaml:"Name"` - Raw bool `json:"raw" yaml:"Raw"` -} - // Settings represents user settings for Web UI, indexing, and import. type Settings struct { UI UISettings `json:"ui" yaml:"UI"` @@ -159,9 +80,7 @@ func NewSettings(c *Config) *Settings { Share: ShareSettings{ Title: "", }, - Download: DownloadSettings{ - Name: entity.DownloadNameDefault, - }, + Download: NewDownloadSettings(), Templates: TemplateSettings{ Default: "index.tmpl", }, @@ -191,7 +110,7 @@ func (s Settings) StackMeta() bool { // Load user settings from file. func (s *Settings) Load(fileName string) error { if !fs.FileExists(fileName) { - return fmt.Errorf("settings file not found: %s", sanitize.Log(fileName)) + return fmt.Errorf("settings file not found: %s", clean.Log(fileName)) } yamlConfig, err := os.ReadFile(fileName) diff --git a/internal/config/settings_download.go b/internal/config/settings_download.go new file mode 100644 index 000000000..1f629940c --- /dev/null +++ b/internal/config/settings_download.go @@ -0,0 +1,23 @@ +package config + +import "github.com/photoprism/photoprism/internal/entity" + +// DownloadSettings represents content download settings. +type DownloadSettings struct { + Name entity.DownloadName `json:"name" yaml:"Name"` + Disabled bool `json:"disabled" yaml:"Disabled"` + Originals bool `json:"originals" yaml:"Originals"` + MediaRaw bool `json:"mediaRaw" yaml:"MediaRaw"` + MediaSidecar bool `json:"mediaSidecar" yaml:"MediaSidecar"` +} + +// NewDownloadSettings creates download settings with defaults. +func NewDownloadSettings() DownloadSettings { + return DownloadSettings{ + Name: entity.DownloadNameDefault, + Disabled: false, + Originals: true, + MediaRaw: false, + MediaSidecar: false, + } +} diff --git a/internal/config/settings_feature.go b/internal/config/settings_feature.go new file mode 100644 index 000000000..acf318326 --- /dev/null +++ b/internal/config/settings_feature.go @@ -0,0 +1,25 @@ +package config + +// FeatureSettings represents feature flags, mainly for the Web UI. +type FeatureSettings struct { + Upload bool `json:"upload" yaml:"Upload"` + Download bool `json:"download" yaml:"Download"` + Private bool `json:"private" yaml:"Private"` + Review bool `json:"review" yaml:"Review"` + Files bool `json:"files" yaml:"Files"` + Videos bool `json:"videos" yaml:"Videos"` + Folders bool `json:"folders" yaml:"Folders"` + Albums bool `json:"albums" yaml:"Albums"` + Moments bool `json:"moments" yaml:"Moments"` + Estimates bool `json:"estimates" yaml:"Estimates"` + People bool `json:"people" yaml:"People"` + Labels bool `json:"labels" yaml:"Labels"` + Places bool `json:"places" yaml:"Places"` + Edit bool `json:"edit" yaml:"Edit"` + Archive bool `json:"archive" yaml:"Archive"` + Delete bool `json:"delete" yaml:"Delete"` + Share bool `json:"share" yaml:"Share"` + Library bool `json:"library" yaml:"Library"` + Import bool `json:"import" yaml:"Import"` + Logs bool `json:"logs" yaml:"Logs"` +} diff --git a/internal/config/settings_import.go b/internal/config/settings_import.go new file mode 100644 index 000000000..61d1be0c2 --- /dev/null +++ b/internal/config/settings_import.go @@ -0,0 +1,7 @@ +package config + +// ImportSettings represents import settings. +type ImportSettings struct { + Path string `json:"path" yaml:"Path"` + Move bool `json:"move" yaml:"Move"` +} diff --git a/internal/config/settings_index.go b/internal/config/settings_index.go new file mode 100644 index 000000000..dde24de0b --- /dev/null +++ b/internal/config/settings_index.go @@ -0,0 +1,8 @@ +package config + +// IndexSettings represents indexing settings. +type IndexSettings struct { + Path string `json:"path" yaml:"Path"` + Convert bool `json:"convert" yaml:"Convert"` + Rescan bool `json:"rescan" yaml:"Rescan"` +} diff --git a/internal/config/settings_maps.go b/internal/config/settings_maps.go new file mode 100644 index 000000000..3fc1e7822 --- /dev/null +++ b/internal/config/settings_maps.go @@ -0,0 +1,7 @@ +package config + +// MapsSettings represents maps settings (for places). +type MapsSettings struct { + Animate int `json:"animate" yaml:"Animate"` + Style string `json:"style" yaml:"Style"` +} diff --git a/internal/config/settings_search.go b/internal/config/settings_search.go new file mode 100644 index 000000000..6c4f5c19e --- /dev/null +++ b/internal/config/settings_search.go @@ -0,0 +1,6 @@ +package config + +// SearchSettings represents search UI preferences. +type SearchSettings struct { + BatchSize int `json:"batchSize" yaml:"BatchSize"` +} diff --git a/internal/config/settings_share.go b/internal/config/settings_share.go new file mode 100644 index 000000000..3510f7a5d --- /dev/null +++ b/internal/config/settings_share.go @@ -0,0 +1,6 @@ +package config + +// ShareSettings represents content sharing settings. +type ShareSettings struct { + Title string `json:"title" yaml:"Title"` +} diff --git a/internal/config/settings_stack.go b/internal/config/settings_stack.go new file mode 100644 index 000000000..c652d3b91 --- /dev/null +++ b/internal/config/settings_stack.go @@ -0,0 +1,8 @@ +package config + +// StackSettings represents settings for files that belong to the same photo. +type StackSettings struct { + UUID bool `json:"uuid" yaml:"UUID"` + Meta bool `json:"meta" yaml:"Meta"` + Name bool `json:"name" yaml:"Name"` +} diff --git a/internal/config/settings_template.go b/internal/config/settings_template.go new file mode 100644 index 000000000..1247caffa --- /dev/null +++ b/internal/config/settings_template.go @@ -0,0 +1,6 @@ +package config + +// TemplateSettings represents template settings for the UI and messaging. +type TemplateSettings struct { + Default string `json:"default" yaml:"Default"` +} diff --git a/internal/config/settings_ui.go b/internal/config/settings_ui.go new file mode 100644 index 000000000..446f0a71d --- /dev/null +++ b/internal/config/settings_ui.go @@ -0,0 +1,9 @@ +package config + +// UISettings represents user interface settings. +type UISettings struct { + Scrollbar bool `json:"scrollbar" yaml:"Scrollbar"` + Zoom bool `json:"zoom" yaml:"Zoom"` + Theme string `json:"theme" yaml:"Theme"` + Language string `json:"language" yaml:"Language"` +} diff --git a/internal/config/test.go b/internal/config/test.go index 2d48e7e80..2719488d9 100644 --- a/internal/config/test.go +++ b/internal/config/test.go @@ -18,9 +18,9 @@ import ( "github.com/photoprism/photoprism/internal/thumb" "github.com/photoprism/photoprism/pkg/capture" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/rnd" - "github.com/photoprism/photoprism/pkg/sanitize" ) // Download URL and ZIP hash for test files. @@ -69,7 +69,7 @@ func NewTestOptions(pkg string) *Options { } else if dsn != SQLiteTestDB { // Continue. } else if err := os.Remove(dsn); err == nil { - log.Debugf("sqlite: test file %s removed", sanitize.Log(dsn)) + log.Debugf("sqlite: test file %s removed", clean.Log(dsn)) } } @@ -148,7 +148,7 @@ func NewTestConfig(pkg string) *Config { c := &Config{ options: NewTestOptions(pkg), - token: rnd.Token(8), + token: rnd.GenerateToken(8), } s := NewSettings(c) diff --git a/internal/config/testdata/settings.yml b/internal/config/testdata/settings.yml index bc349db69..7af7b36ad 100755 --- a/internal/config/testdata/settings.yml +++ b/internal/config/testdata/settings.yml @@ -44,6 +44,9 @@ Share: Title: "" Download: Name: file - Raw: false + Disabled: false + Originals: true + MediaRaw: false + MediaSidecar: false Templates: Default: index.tmpl diff --git a/internal/config/thumbs.go b/internal/config/thumbnails.go similarity index 100% rename from internal/config/thumbs.go rename to internal/config/thumbnails.go diff --git a/internal/crop/cache.go b/internal/crop/cache.go index cfbec213b..0bfe22a7d 100644 --- a/internal/crop/cache.go +++ b/internal/crop/cache.go @@ -6,8 +6,8 @@ import ( "path" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // FromCache returns the crop file name if cached. @@ -28,7 +28,7 @@ func FromCache(hash, area string, size Size, thumbPath string) (fileName string, // FileName returns the crop file name based on cache path, size, and area. func FileName(hash, area string, width, height int, thumbPath string) (fileName string, err error) { if len(hash) < 4 { - return "", fmt.Errorf("crop: invalid file hash %s", sanitize.Log(hash)) + return "", fmt.Errorf("crop: invalid file hash %s", clean.Log(hash)) } if len(thumbPath) < 1 { @@ -39,7 +39,7 @@ func FileName(hash, area string, width, height int, thumbPath string) (fileName return "", fmt.Errorf("crop: invalid size %dx%d", width, height) } - fileName = path.Join(thumbPath, hash[0:1], hash[1:2], hash[2:3], fmt.Sprintf("%s_%dx%d_crop_%s%s", hash, width, height, area, fs.JpegExt)) + fileName = path.Join(thumbPath, hash[0:1], hash[1:2], hash[2:3], fmt.Sprintf("%s_%dx%d_crop_%s%s", hash, width, height, area, fs.ExtJPEG)) return fileName, nil } diff --git a/internal/crop/image.go b/internal/crop/image.go index ec1de15b6..e53ce3dbc 100644 --- a/internal/crop/image.go +++ b/internal/crop/image.go @@ -11,8 +11,8 @@ import ( "github.com/disintegration/imaging" "github.com/photoprism/photoprism/internal/thumb" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // Filenames of usable thumb sizes. @@ -44,7 +44,7 @@ func ImageFromThumb(thumbName string, area Area, size Size, cache bool) (img ima hash := thumbHash(thumbName) // Compose cached crop image file name. - cropBase := fmt.Sprintf("%s_%dx%d_crop_%s%s", hash, size.Width, size.Height, area.String(), fs.JpegExt) + cropBase := fmt.Sprintf("%s_%dx%d_crop_%s%s", hash, size.Width, size.Height, area.String(), fs.ExtJPEG) cropName := filepath.Join(filePath, cropBase) // Cached? @@ -91,7 +91,7 @@ func ImageFromThumb(thumbName string, area Area, size Size, cache bool) (img ima // ThumbFileName returns the ideal thumb file name. func ThumbFileName(hash string, area Area, size Size, thumbPath string) (string, error) { if len(hash) < 4 { - return "", fmt.Errorf("invalid file hash %s", sanitize.Log(hash)) + return "", fmt.Errorf("invalid file hash %s", clean.Log(hash)) } if len(thumbPath) < 1 { diff --git a/internal/crop/names.go b/internal/crop/names.go index f084d60db..23a1cc884 100644 --- a/internal/crop/names.go +++ b/internal/crop/names.go @@ -7,7 +7,7 @@ type Name string // Jpeg returns the crop name with a jpeg file extension suffix as string. func (n Name) Jpeg() string { - return string(n) + fs.JpegExt + return string(n) + fs.ExtJPEG } // Names of standard crop sizes. diff --git a/internal/crop/request.go b/internal/crop/request.go index 7ff740a53..c00e35393 100644 --- a/internal/crop/request.go +++ b/internal/crop/request.go @@ -27,7 +27,7 @@ func FromRequest(hash, area string, size Size, thumbPath string) (fileName strin } // Compose cached crop image file name. - cropBase := fmt.Sprintf("%s_%dx%d_crop_%s%s", hash, size.Width, size.Height, area, fs.JpegExt) + cropBase := fmt.Sprintf("%s_%dx%d_crop_%s%s", hash, size.Width, size.Height, area, fs.ExtJPEG) cropName := filepath.Join(filepath.Dir(thumbName), cropBase) imageBuffer, err := os.ReadFile(thumbName) diff --git a/internal/entity/address.go b/internal/entity/address.go index 593bca9c4..d23d8468d 100644 --- a/internal/entity/address.go +++ b/internal/entity/address.go @@ -4,7 +4,7 @@ import ( "fmt" "time" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) type Addresses []Address @@ -64,7 +64,7 @@ func FirstOrCreateAddress(m *Address) *Address { // String returns an identifier that can be used in logs. func (m *Address) String() string { - return sanitize.Log(fmt.Sprintf("%s, %s %s, %s", m.AddressLine1, m.AddressZip, m.AddressCity, m.AddressCountry)) + return clean.Log(fmt.Sprintf("%s, %s %s, %s", m.AddressLine1, m.AddressZip, m.AddressCity, m.AddressCountry)) } // Unknown returns true if the address is unknown. diff --git a/internal/entity/album.go b/internal/entity/album.go index c7ac0c6ce..2e328a490 100644 --- a/internal/entity/album.go +++ b/internal/entity/album.go @@ -13,8 +13,8 @@ import ( "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/maps" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/rnd" - "github.com/photoprism/photoprism/pkg/sanitize" "github.com/photoprism/photoprism/pkg/txt" ) @@ -72,7 +72,7 @@ func AddPhotoToAlbums(photo string, albums []string) (err error) { return nil } - if !rnd.IsPPID(photo, 'p') { + if !rnd.EntityUID(photo, 'p') { return fmt.Errorf("album: invalid photo uid %s", photo) } @@ -84,7 +84,7 @@ func AddPhotoToAlbums(photo string, albums []string) (err error) { continue } - if rnd.IsPPID(album, 'a') { + if rnd.EntityUID(album, 'a') { aUID = album } else { a := NewAlbum(album, AlbumDefault) @@ -297,7 +297,7 @@ func FindFolderAlbum(albumPath string) *Album { // Find returns an entity from the database. func (m *Album) Find() (err error) { - if rnd.IsPPID(m.AlbumUID, 'a') { + if rnd.EntityUID(m.AlbumUID, 'a') { if err := UnscopedDb().First(m, "album_uid = ?", m.AlbumUID).Error; err != nil { return err } @@ -326,25 +326,25 @@ func (m *Album) Find() (err error) { // BeforeCreate creates a random UID if needed before inserting a new row to the database. func (m *Album) BeforeCreate(scope *gorm.Scope) error { - if rnd.IsUID(m.AlbumUID, 'a') { + if rnd.ValidID(m.AlbumUID, 'a') { return nil } - return scope.SetColumn("AlbumUID", rnd.PPID('a')) + return scope.SetColumn("AlbumUID", rnd.GenerateUID('a')) } // String returns the id or name as string. func (m *Album) String() string { if m.AlbumSlug != "" { - return sanitize.Log(m.AlbumSlug) + return clean.Log(m.AlbumSlug) } if m.AlbumTitle != "" { - return sanitize.Log(m.AlbumTitle) + return clean.Log(m.AlbumTitle) } if m.AlbumUID != "" { - return sanitize.Log(m.AlbumUID) + return clean.Log(m.AlbumUID) } return "[unknown album]" diff --git a/internal/entity/album_yaml.go b/internal/entity/album_yaml.go index c4ae56eb7..63b47a455 100644 --- a/internal/entity/album_yaml.go +++ b/internal/entity/album_yaml.go @@ -62,5 +62,5 @@ func (m *Album) LoadFromYaml(fileName string) error { // YamlFileName returns the YAML file name. func (m *Album) YamlFileName(albumsPath string) string { - return filepath.Join(albumsPath, m.AlbumType, m.AlbumUID+fs.YamlExt) + return filepath.Join(albumsPath, m.AlbumType, m.AlbumUID+fs.ExtYAML) } diff --git a/internal/entity/camera.go b/internal/entity/camera.go index 83cc35e1a..9d336aede 100644 --- a/internal/entity/camera.go +++ b/internal/entity/camera.go @@ -6,7 +6,7 @@ import ( "time" "github.com/photoprism/photoprism/internal/event" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/txt" ) @@ -129,7 +129,7 @@ func FirstOrCreateCamera(m *Camera) *Camera { cameraCache.SetDefault(m.CameraSlug, &result) return &result } else { - log.Errorf("camera: %s (create %s)", err.Error(), sanitize.Log(m.String())) + log.Errorf("camera: %s (create %s)", err.Error(), clean.Log(m.String())) } return &UnknownCamera @@ -137,7 +137,7 @@ func FirstOrCreateCamera(m *Camera) *Camera { // String returns an identifier that can be used in logs. func (m *Camera) String() string { - return sanitize.Log(m.CameraName) + return clean.Log(m.CameraName) } // Unknown returns true if the camera is not a known make or model. diff --git a/internal/entity/duplicate.go b/internal/entity/duplicate.go index 427a8e197..844063ea7 100644 --- a/internal/entity/duplicate.go +++ b/internal/entity/duplicate.go @@ -3,7 +3,7 @@ package entity import ( "fmt" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) type Duplicates []Duplicate @@ -56,7 +56,7 @@ func PurgeDuplicate(fileName, fileRoot string) error { } if err := UnscopedDb().Delete(Duplicate{}, "file_name = ? AND file_root = ?", fileName, fileRoot).Error; err != nil { - log.Errorf("duplicate: %s in %s (purge)", err, sanitize.Log(fileName)) + log.Errorf("duplicate: %s in %s (purge)", err, clean.Log(fileName)) return err } @@ -101,7 +101,7 @@ func (m *Duplicate) Save() error { } if err := UnscopedDb().Save(m).Error; err != nil { - log.Errorf("duplicate: %s in %s (save)", err, sanitize.Log(m.FileName)) + log.Errorf("duplicate: %s in %s (save)", err, clean.Log(m.FileName)) return err } diff --git a/internal/entity/const.go b/internal/entity/entity_const.go similarity index 56% rename from internal/entity/const.go rename to internal/entity/entity_const.go index 4b1aaac3b..79f717fe3 100644 --- a/internal/entity/const.go +++ b/internal/entity/entity_const.go @@ -1,32 +1,33 @@ package entity -import "github.com/photoprism/photoprism/pkg/fs" - -// Panorama Projection Types -// TODO: Move to separate package. - -const ( - ProjDefault = "" - ProjEquirectangular = "equirectangular" - ProjCubestrip = "cubestrip" - ProjCylindrical = "cylindrical" - ProjTransverseCylindrical = "transverse-cylindrical" - ProjPseudocylindricalCompromise = "pseudocylindrical-compromise" +import ( + "github.com/photoprism/photoprism/pkg/media" ) -// Media Types. +// Default values. +const ( + Unknown = "" + UnknownYear = -1 + UnknownMonth = -1 + UnknownDay = -1 + UnknownName = "Unknown" + UnknownTitle = UnknownName + UnknownID = "zz" +) + +// Media content types. const ( MediaUnknown = "" - MediaImage = string(fs.MediaImage) - MediaVector = string(fs.MediaVector) - MediaAnimated = "animated" - MediaLive = "live" - MediaVideo = string(fs.MediaVideo) - MediaRaw = string(fs.MediaRaw) - TypeMeta = "meta" + MediaImage = string(media.Image) + MediaVector = string(media.Vector) + MediaAnimated = string(media.Animated) + MediaLive = string(media.Live) + MediaVideo = string(media.Video) + MediaRaw = string(media.Raw) + MediaText = string(media.Text) ) -// Root Dirs. +// Storage root folders. const ( RootUnknown = "" RootOriginals = "/" @@ -36,34 +37,21 @@ const ( RootPath = "/" ) -// Defaults. +// Event type. const ( - UnknownYear = -1 - UnknownMonth = -1 - UnknownDay = -1 - UnknownName = "Unknown" - UnknownTitle = UnknownName - UnknownID = "zz" -) - -// Event Types - -const ( - Updated = "updated" Created = "created" + Updated = "updated" Deleted = "deleted" ) -// Photo Stacks - +// Photo stack states. const ( IsStacked int8 = 1 IsStackable int8 = 0 IsUnstacked int8 = -1 ) -// Sort Orders - +// Sort options. const ( SortOrderDefault = "" SortOrderRelevance = "relevance" diff --git a/internal/entity/entity_count.go b/internal/entity/entity_count.go index 73dbd325a..87e4e8d06 100644 --- a/internal/entity/entity_count.go +++ b/internal/entity/entity_count.go @@ -1,14 +1,7 @@ package entity import ( - "fmt" - "strings" - "time" - - "github.com/dustin/go-humanize/english" "github.com/jinzhu/gorm" - - "github.com/photoprism/photoprism/internal/mutex" ) // Count returns the number of records for a given a model and key values. @@ -33,227 +26,3 @@ func Count(m interface{}, keys []string, values []interface{}) int { return count } - -type LabelPhotoCount struct { - LabelID int - PhotoCount int -} - -type LabelPhotoCounts []LabelPhotoCount - -// LabelCounts returns the number of photos for each label ID. -func LabelCounts() LabelPhotoCounts { - result := LabelPhotoCounts{} - - if err := UnscopedDb().Raw(` - SELECT label_id, SUM(photo_count) AS photo_count FROM ( - SELECT l.id AS label_id, COUNT(*) AS photo_count FROM labels l - JOIN photos_labels pl ON pl.label_id = l.id - JOIN photos ph ON pl.photo_id = ph.id - WHERE pl.uncertainty < 100 - AND ph.photo_quality >= 0 - AND ph.photo_private = 0 - AND ph.deleted_at IS NULL GROUP BY l.id - UNION ALL - SELECT l.id AS label_id, COUNT(*) AS photo_count FROM labels l - JOIN categories c ON c.category_id = l.id - JOIN photos_labels pl ON pl.label_id = c.label_id - JOIN photos ph ON pl.photo_id = ph.id - WHERE pl.uncertainty < 100 - AND ph.photo_quality >= 0 - AND ph.photo_private = 0 - AND ph.deleted_at IS NULL GROUP BY l.id) counts GROUP BY label_id - `).Scan(&result).Error; err != nil { - log.Errorf("label-count: %s", err.Error()) - } - - return result -} - -// UpdatePlacesCounts updates the places photo counts. -func UpdatePlacesCounts() (err error) { - mutex.Index.Lock() - defer mutex.Index.Unlock() - - start := time.Now() - - // Update places. - res := Db().Table("places"). - UpdateColumn("photo_count", gorm.Expr("(SELECT COUNT(*) FROM photos p "+ - "WHERE places.id = p.place_id "+ - "AND p.photo_quality >= 0 "+ - "AND p.photo_private = 0 "+ - "AND p.deleted_at IS NULL)")) - - if res.Error != nil { - return res.Error - } - - log.Debugf("counts: updated %s [%s]", english.Plural(int(res.RowsAffected), "place", "places"), time.Since(start)) - - return nil -} - -// UpdateSubjectCounts updates the subject file counts. -func UpdateSubjectCounts() (err error) { - mutex.Index.Lock() - defer mutex.Index.Unlock() - - start := time.Now() - - var res *gorm.DB - - subjTable := Subject{}.TableName() - filesTable := File{}.TableName() - markerTable := Marker{}.TableName() - - condition := gorm.Expr("subj_type = ?", SubjPerson) - - switch DbDialect() { - case MySQL: - res = Db().Exec(`UPDATE ? LEFT JOIN ( - SELECT m.subj_uid, COUNT(DISTINCT f.id) AS subj_files, COUNT(DISTINCT f.photo_id) AS subj_photos FROM ? f - JOIN ? m ON f.file_uid = m.file_uid AND m.subj_uid IS NOT NULL AND m.subj_uid <> '' AND m.subj_uid IS NOT NULL - WHERE m.marker_invalid = 0 AND f.deleted_at IS NULL GROUP BY m.subj_uid - ) b ON b.subj_uid = subjects.subj_uid - SET subjects.file_count = CASE WHEN b.subj_files IS NULL THEN 0 ELSE b.subj_files END, - subjects.photo_count = CASE WHEN b.subj_photos IS NULL THEN 0 ELSE b.subj_photos END - WHERE ?`, gorm.Expr(subjTable), gorm.Expr(filesTable), gorm.Expr(markerTable), condition) - case SQLite3: - // Update files count. - res = Db().Table(subjTable). - UpdateColumn("file_count", gorm.Expr("(SELECT COUNT(DISTINCT f.id) FROM files f "+ - fmt.Sprintf("JOIN %s m ON f.file_uid = m.file_uid AND m.subj_uid = %s.subj_uid ", - markerTable, subjTable)+" WHERE m.marker_invalid = 0 AND f.deleted_at IS NULL) WHERE ?", condition)) - - // Update photo count. - if res.Error != nil { - return res.Error - } else { - photosRes := Db().Table(subjTable). - UpdateColumn("photo_count", gorm.Expr("(SELECT COUNT(DISTINCT f.photo_id) FROM files f "+ - fmt.Sprintf("JOIN %s m ON f.file_uid = m.file_uid AND m.subj_uid = %s.subj_uid ", - markerTable, subjTable)+" WHERE m.marker_invalid = 0 AND f.deleted_at IS NULL) WHERE ?", condition)) - res.RowsAffected += photosRes.RowsAffected - } - default: - return fmt.Errorf("sql: unsupported dialect %s", DbDialect()) - } - - if res.Error != nil { - return res.Error - } - - log.Debugf("counts: updated %s [%s]", english.Plural(int(res.RowsAffected), "subject", "subjects"), time.Since(start)) - - return nil -} - -// UpdateLabelCounts updates the label photo counts. -func UpdateLabelCounts() (err error) { - mutex.Index.Lock() - defer mutex.Index.Unlock() - - start := time.Now() - var res *gorm.DB - if IsDialect(MySQL) { - res = Db().Exec(`UPDATE labels LEFT JOIN ( - SELECT p2.label_id, COUNT(DISTINCT photo_id) AS label_photos FROM ( - SELECT pl.label_id as label_id, p.id AS photo_id FROM photos p - JOIN photos_labels pl ON pl.photo_id = p.id AND pl.uncertainty < 100 - WHERE p.photo_quality > -1 AND p.photo_private = 0 AND p.deleted_at IS NULL - UNION - SELECT c.category_id as label_id, p.id AS photo_id FROM photos p - JOIN photos_labels pl ON pl.photo_id = p.id AND pl.uncertainty < 100 - JOIN categories c ON c.label_id = pl.label_id - WHERE p.photo_quality > -1 AND p.photo_private = 0 AND p.deleted_at IS NULL - ) p2 GROUP BY p2.label_id - ) b ON b.label_id = labels.id - SET photo_count = CASE WHEN b.label_photos IS NULL THEN 0 ELSE b.label_photos END`) - } else if IsDialect(SQLite3) { - res = Db(). - Table("labels"). - UpdateColumn("photo_count", - gorm.Expr(`(SELECT photo_count FROM (SELECT label_id, SUM(photo_count) AS photo_count FROM ( - SELECT l.id AS label_id, COUNT(*) AS photo_count FROM labels l - JOIN photos_labels pl ON pl.label_id = l.id - JOIN photos ph ON pl.photo_id = ph.id - WHERE pl.uncertainty < 100 - AND ph.photo_quality >= 0 - AND ph.photo_private = 0 - AND ph.deleted_at IS NULL GROUP BY l.id - UNION ALL - SELECT l.id AS label_id, COUNT(*) AS photo_count FROM labels l - JOIN categories c ON c.category_id = l.id - JOIN photos_labels pl ON pl.label_id = c.label_id - JOIN photos ph ON pl.photo_id = ph.id - WHERE pl.uncertainty < 100 - AND ph.photo_quality >= 0 - AND ph.photo_private = 0 - AND ph.deleted_at IS NULL GROUP BY l.id) counts GROUP BY label_id) label_counts WHERE label_id = labels.id)`)) - } else { - return fmt.Errorf("sql: unsupported dialect %s", DbDialect()) - } - - if res.Error != nil { - return res.Error - } - - log.Debugf("counts: updated %s [%s]", english.Plural(int(res.RowsAffected), "label", "labels"), time.Since(start)) - - return nil -} - -// UpdateCounts updates precalculated photo and file counts. -func UpdateCounts() (err error) { - log.Debug("index: updating counts") - - if err = UpdatePlacesCounts(); err != nil { - if strings.Contains(err.Error(), "Error 1054") { - log.Errorf("counts: failed updating places, potentially incompatible database version") - log.Errorf("%s see https://jira.mariadb.org/browse/MDEV-25362", err) - return nil - } - - return err - } - - if err = UpdateSubjectCounts(); err != nil { - if strings.Contains(err.Error(), "Error 1054") { - log.Errorf("counts: failed updating subjects, potentially incompatible database version") - log.Errorf("%s see https://jira.mariadb.org/browse/MDEV-25362", err) - return nil - } - - return err - } - - if err = UpdateLabelCounts(); err != nil { - return err - } - - /* TODO: Slow with many photos due to missing index. - start = time.Now() - - // Update calendar album visibility. - switch DbDialect() { - default: - if err = UnscopedDb().Exec(`UPDATE albums SET deleted_at = ? WHERE album_type=? AND id NOT IN ( - SELECT a.id FROM albums a JOIN photos p ON a.album_month = MONTH(p.taken_at) AND a.album_year = YEAR(p.taken_at) - AND p.deleted_at IS NULL AND p.photo_quality > -1 AND p.photo_private = 0 WHERE album_type=? GROUP BY a.id)`, - TimeStamp(), AlbumMonth, AlbumMonth).Error; err != nil { - return err - } - if err = UnscopedDb().Exec(`UPDATE albums SET deleted_at = NULL WHERE album_type=? AND id IN ( - SELECT a.id FROM albums a JOIN photos p ON a.album_month = MONTH(p.taken_at) AND a.album_year = YEAR(p.taken_at) - AND p.deleted_at IS NULL AND p.photo_quality > -1 AND p.photo_private = 0 WHERE album_type=? GROUP BY a.id)`, - AlbumMonth, AlbumMonth).Error; err != nil { - return err - } - } - - log.Debugf("calendar: updating visibility completed [%s]", time.Since(start)) - */ - - return nil -} diff --git a/internal/entity/entity_count_test.go b/internal/entity/entity_count_test.go index 7260e157e..c7cb86299 100644 --- a/internal/entity/entity_count_test.go +++ b/internal/entity/entity_count_test.go @@ -18,23 +18,3 @@ func TestCount(t *testing.T) { assert.Equal(t, 1, result) } - -func TestLabelCounts(t *testing.T) { - results := LabelCounts() - - if len(results) == 0 { - t.Fatal("at least one result expected") - } - - for _, result := range results { - t.Logf("LABEL COUNT: %+v", result) - } -} - -func TestUpdatePhotoCounts(t *testing.T) { - err := UpdateCounts() - - if err != nil { - t.Fatal(err) - } -} diff --git a/internal/entity/entity_counts.go b/internal/entity/entity_counts.go new file mode 100644 index 000000000..69411d246 --- /dev/null +++ b/internal/entity/entity_counts.go @@ -0,0 +1,236 @@ +package entity + +import ( + "fmt" + "strings" + "time" + + "github.com/dustin/go-humanize/english" + "github.com/jinzhu/gorm" + + "github.com/photoprism/photoprism/internal/mutex" +) + +type LabelPhotoCount struct { + LabelID int + PhotoCount int +} + +type LabelPhotoCounts []LabelPhotoCount + +// LabelCounts returns the number of photos for each label ID. +func LabelCounts() LabelPhotoCounts { + result := LabelPhotoCounts{} + + if err := UnscopedDb().Raw(` + SELECT label_id, SUM(photo_count) AS photo_count FROM ( + SELECT l.id AS label_id, COUNT(*) AS photo_count FROM labels l + JOIN photos_labels pl ON pl.label_id = l.id + JOIN photos ph ON pl.photo_id = ph.id + WHERE pl.uncertainty < 100 + AND ph.photo_quality > -1 + AND ph.photo_private = 0 + AND ph.deleted_at IS NULL GROUP BY l.id + UNION ALL + SELECT l.id AS label_id, COUNT(*) AS photo_count FROM labels l + JOIN categories c ON c.category_id = l.id + JOIN photos_labels pl ON pl.label_id = c.label_id + JOIN photos ph ON pl.photo_id = ph.id + WHERE pl.uncertainty < 100 + AND ph.photo_quality > -1 + AND ph.photo_private = 0 + AND ph.deleted_at IS NULL GROUP BY l.id) counts GROUP BY label_id + `).Scan(&result).Error; err != nil { + log.Errorf("label-count: %s", err.Error()) + } + + return result +} + +// UpdatePlacesCounts updates the places photo counts. +func UpdatePlacesCounts() (err error) { + mutex.Index.Lock() + defer mutex.Index.Unlock() + + start := time.Now() + + // Update places. + res := Db().Table("places"). + UpdateColumn("photo_count", gorm.Expr("(SELECT COUNT(*) FROM photos p "+ + "WHERE places.id = p.place_id "+ + "AND p.photo_quality > -1 "+ + "AND p.photo_private = 0 "+ + "AND p.deleted_at IS NULL)")) + + if res.Error != nil { + return res.Error + } + + log.Debugf("counts: updated %s [%s]", english.Plural(int(res.RowsAffected), "place", "places"), time.Since(start)) + + return nil +} + +// UpdateSubjectCounts updates the subject file counts. +func UpdateSubjectCounts() (err error) { + mutex.Index.Lock() + defer mutex.Index.Unlock() + + start := time.Now() + + var res *gorm.DB + + subjTable := Subject{}.TableName() + filesTable := File{}.TableName() + markerTable := Marker{}.TableName() + + condition := gorm.Expr("subj_type = ?", SubjPerson) + + switch DbDialect() { + case MySQL: + res = Db().Exec(`UPDATE ? LEFT JOIN ( + SELECT m.subj_uid, COUNT(DISTINCT f.id) AS subj_files, COUNT(DISTINCT f.photo_id) AS subj_photos FROM ? f + JOIN ? m ON f.file_uid = m.file_uid AND m.subj_uid IS NOT NULL AND m.subj_uid <> '' AND m.subj_uid IS NOT NULL + WHERE m.marker_invalid = 0 AND f.deleted_at IS NULL GROUP BY m.subj_uid + ) b ON b.subj_uid = subjects.subj_uid + SET subjects.file_count = CASE WHEN b.subj_files IS NULL THEN 0 ELSE b.subj_files END, + subjects.photo_count = CASE WHEN b.subj_photos IS NULL THEN 0 ELSE b.subj_photos END + WHERE ?`, gorm.Expr(subjTable), gorm.Expr(filesTable), gorm.Expr(markerTable), condition) + case SQLite3: + // Update files count. + res = Db().Table(subjTable). + UpdateColumn("file_count", gorm.Expr("(SELECT COUNT(DISTINCT f.id) FROM files f "+ + fmt.Sprintf("JOIN %s m ON f.file_uid = m.file_uid AND m.subj_uid = %s.subj_uid ", + markerTable, subjTable)+" WHERE m.marker_invalid = 0 AND f.deleted_at IS NULL) WHERE ?", condition)) + + // Update photo count. + if res.Error != nil { + return res.Error + } else { + photosRes := Db().Table(subjTable). + UpdateColumn("photo_count", gorm.Expr("(SELECT COUNT(DISTINCT f.photo_id) FROM files f "+ + fmt.Sprintf("JOIN %s m ON f.file_uid = m.file_uid AND m.subj_uid = %s.subj_uid ", + markerTable, subjTable)+" WHERE m.marker_invalid = 0 AND f.deleted_at IS NULL) WHERE ?", condition)) + res.RowsAffected += photosRes.RowsAffected + } + default: + return fmt.Errorf("sql: unsupported dialect %s", DbDialect()) + } + + if res.Error != nil { + return res.Error + } + + log.Debugf("counts: updated %s [%s]", english.Plural(int(res.RowsAffected), "subject", "subjects"), time.Since(start)) + + return nil +} + +// UpdateLabelCounts updates the label photo counts. +func UpdateLabelCounts() (err error) { + mutex.Index.Lock() + defer mutex.Index.Unlock() + + start := time.Now() + var res *gorm.DB + if IsDialect(MySQL) { + res = Db().Exec(`UPDATE labels LEFT JOIN ( + SELECT p2.label_id, COUNT(DISTINCT photo_id) AS label_photos FROM ( + SELECT pl.label_id as label_id, p.id AS photo_id FROM photos p + JOIN photos_labels pl ON pl.photo_id = p.id AND pl.uncertainty < 100 + WHERE p.photo_quality > -1 AND p.photo_private = 0 AND p.deleted_at IS NULL + UNION + SELECT c.category_id as label_id, p.id AS photo_id FROM photos p + JOIN photos_labels pl ON pl.photo_id = p.id AND pl.uncertainty < 100 + JOIN categories c ON c.label_id = pl.label_id + WHERE p.photo_quality > -1 AND p.photo_private = 0 AND p.deleted_at IS NULL + ) p2 GROUP BY p2.label_id + ) b ON b.label_id = labels.id + SET photo_count = CASE WHEN b.label_photos IS NULL THEN 0 ELSE b.label_photos END`) + } else if IsDialect(SQLite3) { + res = Db(). + Table("labels"). + UpdateColumn("photo_count", + gorm.Expr(`(SELECT photo_count FROM (SELECT label_id, SUM(photo_count) AS photo_count FROM ( + SELECT l.id AS label_id, COUNT(*) AS photo_count FROM labels l + JOIN photos_labels pl ON pl.label_id = l.id + JOIN photos ph ON pl.photo_id = ph.id + WHERE pl.uncertainty < 100 + AND ph.photo_quality > -1 + AND ph.photo_private = 0 + AND ph.deleted_at IS NULL GROUP BY l.id + UNION ALL + SELECT l.id AS label_id, COUNT(*) AS photo_count FROM labels l + JOIN categories c ON c.category_id = l.id + JOIN photos_labels pl ON pl.label_id = c.label_id + JOIN photos ph ON pl.photo_id = ph.id + WHERE pl.uncertainty < 100 + AND ph.photo_quality > -1 + AND ph.photo_private = 0 + AND ph.deleted_at IS NULL GROUP BY l.id) counts GROUP BY label_id) label_counts WHERE label_id = labels.id)`)) + } else { + return fmt.Errorf("sql: unsupported dialect %s", DbDialect()) + } + + if res.Error != nil { + return res.Error + } + + log.Debugf("counts: updated %s [%s]", english.Plural(int(res.RowsAffected), "label", "labels"), time.Since(start)) + + return nil +} + +// UpdateCounts updates precalculated photo and file counts. +func UpdateCounts() (err error) { + log.Debug("index: updating counts") + + if err = UpdatePlacesCounts(); err != nil { + if strings.Contains(err.Error(), "Error 1054") { + log.Errorf("counts: failed updating places, potentially incompatible database version") + log.Errorf("%s see https://jira.mariadb.org/browse/MDEV-25362", err) + return nil + } + + return err + } + + if err = UpdateSubjectCounts(); err != nil { + if strings.Contains(err.Error(), "Error 1054") { + log.Errorf("counts: failed updating subjects, potentially incompatible database version") + log.Errorf("%s see https://jira.mariadb.org/browse/MDEV-25362", err) + return nil + } + + return err + } + + if err = UpdateLabelCounts(); err != nil { + return err + } + + /* TODO: Slow with many photos due to missing index. + start = time.Now() + + // Update calendar album visibility. + switch DbDialect() { + default: + if err = UnscopedDb().Exec(`UPDATE albums SET deleted_at = ? WHERE album_type=? AND id NOT IN ( + SELECT a.id FROM albums a JOIN photos p ON a.album_month = MONTH(p.taken_at) AND a.album_year = YEAR(p.taken_at) + AND p.deleted_at IS NULL AND p.photo_quality > -1 AND p.photo_private = 0 WHERE album_type=? GROUP BY a.id)`, + TimeStamp(), AlbumMonth, AlbumMonth).Error; err != nil { + return err + } + if err = UnscopedDb().Exec(`UPDATE albums SET deleted_at = NULL WHERE album_type=? AND id IN ( + SELECT a.id FROM albums a JOIN photos p ON a.album_month = MONTH(p.taken_at) AND a.album_year = YEAR(p.taken_at) + AND p.deleted_at IS NULL AND p.photo_quality > -1 AND p.photo_private = 0 WHERE album_type=? GROUP BY a.id)`, + AlbumMonth, AlbumMonth).Error; err != nil { + return err + } + } + + log.Debugf("calendar: updating visibility completed [%s]", time.Since(start)) + */ + + return nil +} diff --git a/internal/entity/entity_counts_test.go b/internal/entity/entity_counts_test.go new file mode 100644 index 000000000..4b9fa4819 --- /dev/null +++ b/internal/entity/entity_counts_test.go @@ -0,0 +1,25 @@ +package entity + +import ( + "testing" +) + +func TestLabelCounts(t *testing.T) { + results := LabelCounts() + + if len(results) == 0 { + t.Fatal("at least one result expected") + } + + for _, result := range results { + t.Logf("LABEL COUNT: %+v", result) + } +} + +func TestUpdatePhotoCounts(t *testing.T) { + err := UpdateCounts() + + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/entity/entity_init.go b/internal/entity/entity_init.go index 4894ad565..e19ab36b8 100644 --- a/internal/entity/entity_init.go +++ b/internal/entity/entity_init.go @@ -4,7 +4,7 @@ import ( "os" "time" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // onReady contains init functions to be called when the @@ -62,7 +62,7 @@ func InitTestDb(driver, dsn string) *Gorm { } else if dsn != SQLiteTestDB { // Continue. } else if err := os.Remove(dsn); err == nil { - log.Debugf("sqlite: test file %s removed", sanitize.Log(dsn)) + log.Debugf("sqlite: test file %s removed", clean.Log(dsn)) } } diff --git a/internal/entity/entity_save_test.go b/internal/entity/entity_save_test.go index 730ee39d6..692ca4be9 100644 --- a/internal/entity/entity_save_test.go +++ b/internal/entity/entity_save_test.go @@ -13,7 +13,7 @@ func TestSave(t *testing.T) { t.Run("HasCreatedUpdatedAt", func(t *testing.T) { id := 99999 + r.Intn(10000) - m := Photo{ID: uint(id), PhotoUID: rnd.PPID('p'), UpdatedAt: TimeStamp(), CreatedAt: TimeStamp()} + m := Photo{ID: uint(id), PhotoUID: rnd.GenerateUID('p'), UpdatedAt: TimeStamp(), CreatedAt: TimeStamp()} if err := m.Save(); err != nil { t.Fatal(err) @@ -27,7 +27,7 @@ func TestSave(t *testing.T) { }) t.Run("HasCreatedAt", func(t *testing.T) { id := 99999 + r.Intn(10000) - m := Photo{ID: uint(id), PhotoUID: rnd.PPID('p'), CreatedAt: TimeStamp()} + m := Photo{ID: uint(id), PhotoUID: rnd.GenerateUID('p'), CreatedAt: TimeStamp()} if err := m.Save(); err != nil { t.Fatal(err) @@ -41,7 +41,7 @@ func TestSave(t *testing.T) { }) t.Run("NoCreatedAt", func(t *testing.T) { id := 99999 + r.Intn(10000) - m := Photo{ID: uint(id), PhotoUID: rnd.PPID('p'), CreatedAt: TimeStamp()} + m := Photo{ID: uint(id), PhotoUID: rnd.GenerateUID('p'), CreatedAt: TimeStamp()} if err := m.Save(); err != nil { t.Fatal(err) diff --git a/internal/entity/entity_tables.go b/internal/entity/entity_tables.go index 318b29528..834533e09 100644 --- a/internal/entity/entity_tables.go +++ b/internal/entity/entity_tables.go @@ -7,7 +7,7 @@ import ( "github.com/jinzhu/gorm" "github.com/photoprism/photoprism/internal/migrate" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) type Tables map[string]interface{} @@ -56,10 +56,10 @@ func (list Tables) WaitForMigration(db *gorm.DB) { for i := 0; i <= attempts; i++ { count := RowCount{} if err := db.Raw(fmt.Sprintf("SELECT COUNT(*) AS count FROM %s", name)).Scan(&count).Error; err == nil { - log.Tracef("migrate: %s migrated", sanitize.Log(name)) + log.Tracef("migrate: %s migrated", clean.Log(name)) break } else { - log.Debugf("migrate: waiting for %s migration (%s)", sanitize.Log(name), err.Error()) + log.Debugf("migrate: waiting for %s migration (%s)", clean.Log(name), err.Error()) } if i == attempts { @@ -78,7 +78,7 @@ func (list Tables) Truncate(db *gorm.DB) { // log.Debugf("entity: removed all data from %s", name) break } else if err.Error() != "record not found" { - log.Debugf("migrate: %s in %s", err, sanitize.Log(name)) + log.Debugf("migrate: %s in %s", err, clean.Log(name)) } } } @@ -93,7 +93,7 @@ func (list Tables) Migrate(db *gorm.DB, runFailed bool, ids []string) { time.Sleep(time.Second) if err := db.AutoMigrate(entity).Error; err != nil { - log.Errorf("migrate: failed migrating %s", sanitize.Log(name)) + log.Errorf("migrate: failed migrating %s", clean.Log(name)) panic(err) } } diff --git a/internal/entity/entity_update_test.go b/internal/entity/entity_update_test.go index 5175dfef7..3b4bc780b 100644 --- a/internal/entity/entity_update_test.go +++ b/internal/entity/entity_update_test.go @@ -13,7 +13,7 @@ import ( func TestUpdate(t *testing.T) { var r = rand.New(rand.NewSource(time.Now().UnixNano())) t.Run("IDMissing", func(t *testing.T) { - uid := rnd.PPID('p') + uid := rnd.GenerateUID('p') m := &Photo{ID: 0, PhotoUID: uid, UpdatedAt: TimeStamp(), CreatedAt: TimeStamp(), PhotoTitle: "Foo"} updatedAt := m.UpdatedAt @@ -42,7 +42,7 @@ func TestUpdate(t *testing.T) { }) t.Run("NotUpdated", func(t *testing.T) { id := 99999 + r.Intn(10000) - uid := rnd.PPID('p') + uid := rnd.GenerateUID('p') m := &Photo{ID: uint(id), PhotoUID: uid, UpdatedAt: time.Now(), CreatedAt: TimeStamp(), PhotoTitle: "Foo"} updatedAt := m.UpdatedAt @@ -85,7 +85,7 @@ func TestUpdate(t *testing.T) { t.Run("NonExistentKeys", func(t *testing.T) { m := PhotoFixtures.Pointer("Photo01") m.ID = uint(99999 + r.Intn(10000)) - m.PhotoUID = rnd.PPID('p') + m.PhotoUID = rnd.GenerateUID('p') updatedAt := m.UpdatedAt if err := Update(m, "ID", "PhotoUID"); err == nil { t.Fatal("error expected") diff --git a/internal/entity/face.go b/internal/entity/face.go index 6e5c83174..2653e2a3d 100644 --- a/internal/entity/face.go +++ b/internal/entity/face.go @@ -411,7 +411,7 @@ func FindFace(id string) *Face { // ValidFaceCount counts the number of valid face markers for a file uid. func ValidFaceCount(fileUID string) (c int) { - if !rnd.IsPPID(fileUID, 'f') { + if !rnd.EntityUID(fileUID, 'f') { return } diff --git a/internal/entity/file.go b/internal/entity/file.go index ef6e47c60..68ca9cb6b 100644 --- a/internal/entity/file.go +++ b/internal/entity/file.go @@ -5,7 +5,6 @@ import ( "math" "path/filepath" "sort" - "strings" "sync" "time" @@ -15,10 +14,14 @@ import ( "github.com/ulule/deepcopier" "github.com/photoprism/photoprism/internal/face" + + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/colors" "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/media" + "github.com/photoprism/photoprism/pkg/projection" "github.com/photoprism/photoprism/pkg/rnd" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/txt" ) type DownloadName string @@ -44,9 +47,9 @@ type File struct { PhotoID uint `gorm:"index:idx_files_photo_id;" json:"-" yaml:"-"` PhotoUID string `gorm:"type:VARBINARY(42);index;" json:"PhotoUID" yaml:"PhotoUID"` PhotoTakenAt time.Time `gorm:"type:DATETIME;index;" json:"TakenAt" yaml:"TakenAt"` - MetaUTC int64 `gorm:"column:meta_utc;index;" json:"MetaUTC" yaml:"MetaUTC,omitempty"` TimeIndex *string `gorm:"type:VARBINARY(48);" json:"TimeIndex" yaml:"TimeIndex"` MediaID *string `gorm:"type:VARBINARY(32);" json:"MediaID" yaml:"MediaID"` + MediaUTC int64 `gorm:"column:media_utc;index;" json:"MediaUTC" yaml:"MediaUTC,omitempty"` InstanceID string `gorm:"type:VARBINARY(42);index;" json:"InstanceID,omitempty" yaml:"InstanceID,omitempty"` FileUID string `gorm:"type:VARBINARY(42);unique_index;" json:"UID" yaml:"UID"` FileName string `gorm:"type:VARBINARY(755);unique_index:idx_files_name_root;" json:"Name" yaml:"Name"` @@ -187,11 +190,22 @@ func PrimaryFile(photoUID string) (*File, error) { // BeforeCreate creates a random UID if needed before inserting a new row to the database. func (m *File) BeforeCreate(scope *gorm.Scope) error { - if rnd.IsUID(m.FileUID, 'f') { + // Set MediaType based on FileName if empty. + if m.MediaType == "" && m.FileName != "" { + m.MediaType = media.FromName(m.FileName).String() + } + + // Set MediaUTC based on PhotoTakenAt if empty. + if m.MediaUTC == 0 && !m.PhotoTakenAt.IsZero() { + m.MediaUTC = m.PhotoTakenAt.UnixMilli() + } + + // Return if uid exists. + if rnd.ValidID(m.FileUID, 'f') { return nil } - return scope.SetColumn("FileUID", rnd.PPID('f')) + return scope.SetColumn("FileUID", rnd.GenerateUID('f')) } // DownloadName returns the download file name. @@ -248,7 +262,7 @@ func (m *File) ShareBase(seq int) string { return fmt.Sprintf("%s.%s", m.FileHash, m.FileType) } - name := strings.Title(slug.MakeLang(photo.PhotoTitle, "en")) + name := txt.Title(slug.MakeLang(photo.PhotoTitle, "en")) taken := photo.TakenAtLocal.Format("20060102-150405") if seq > 0 { @@ -281,23 +295,23 @@ func (m File) Missing() bool { // DeletePermanently permanently removes a file from the index. func (m *File) DeletePermanently() error { if m.ID < 1 || m.FileUID == "" { - return fmt.Errorf("invalid file id %d / uid %s", m.ID, sanitize.Log(m.FileUID)) + return fmt.Errorf("invalid file id %d / uid %s", m.ID, clean.Log(m.FileUID)) } if err := UnscopedDb().Delete(Marker{}, "file_uid = ?", m.FileUID).Error; err != nil { - log.Errorf("file %s: %s while removing markers", sanitize.Log(m.FileUID), err) + log.Errorf("file %s: %s while removing markers", clean.Log(m.FileUID), err) } if err := UnscopedDb().Delete(FileShare{}, "file_id = ?", m.ID).Error; err != nil { - log.Errorf("file %s: %s while removing share info", sanitize.Log(m.FileUID), err) + log.Errorf("file %s: %s while removing share info", clean.Log(m.FileUID), err) } if err := UnscopedDb().Delete(FileSync{}, "file_id = ?", m.ID).Error; err != nil { - log.Errorf("file %s: %s while removing remote sync info", sanitize.Log(m.FileUID), err) + log.Errorf("file %s: %s while removing remote sync info", clean.Log(m.FileUID), err) } if err := m.ReplaceHash(""); err != nil { - log.Errorf("file %s: %s while removing covers", sanitize.Log(m.FileUID), err) + log.Errorf("file %s: %s while removing covers", clean.Log(m.FileUID), err) } return UnscopedDb().Delete(m).Error @@ -312,9 +326,9 @@ func (m *File) ReplaceHash(newHash string) error { // Log values. if m.FileHash != "" && newHash == "" { - log.Tracef("file %s: removing hash %s", sanitize.Log(m.FileUID), sanitize.Log(m.FileHash)) + log.Tracef("file %s: removing hash %s", clean.Log(m.FileUID), clean.Log(m.FileHash)) } else if m.FileHash != "" && newHash != "" { - log.Tracef("file %s: hash %s changed to %s", sanitize.Log(m.FileUID), sanitize.Log(m.FileHash), sanitize.Log(newHash)) + log.Tracef("file %s: hash %s changed to %s", clean.Log(m.FileUID), clean.Log(m.FileHash), clean.Log(newHash)) // Reset error when hash changes. m.FileError = "" } @@ -350,7 +364,7 @@ func (m *File) ReplaceHash(newHash string) error { // Delete deletes the entity from the database. func (m *File) Delete(permanently bool) error { if m.ID < 1 || m.FileUID == "" { - return fmt.Errorf("invalid file id %d / uid %s", m.ID, sanitize.Log(m.FileUID)) + return fmt.Errorf("invalid file id %d / uid %s", m.ID, clean.Log(m.FileUID)) } if permanently { @@ -401,7 +415,7 @@ func (m *File) Create() error { } if _, err := m.SaveMarkers(); err != nil { - log.Errorf("file %s: %s while saving markers", sanitize.Log(m.FileUID), err) + log.Errorf("file %s: %s while saving markers", clean.Log(m.FileUID), err) return err } @@ -434,12 +448,12 @@ func (m *File) Save() error { } if err := UnscopedDb().Save(m).Error; err != nil { - log.Errorf("file %s: %s while saving", sanitize.Log(m.FileUID), err) + log.Errorf("file %s: %s while saving", clean.Log(m.FileUID), err) return err } if _, err := m.SaveMarkers(); err != nil { - log.Errorf("file %s: %s while saving markers", sanitize.Log(m.FileUID), err) + log.Errorf("file %s: %s while saving markers", clean.Log(m.FileUID), err) return err } @@ -469,7 +483,7 @@ func (m *File) Updates(values interface{}) error { // Rename updates the name and path of this file. func (m *File) Rename(fileName, rootName, filePath, fileBase string) error { - log.Debugf("file %s: renaming %s to %s", sanitize.Log(m.FileUID), sanitize.Log(m.FileName), sanitize.Log(fileName)) + log.Debugf("file %s: renaming %s to %s", clean.Log(m.FileUID), clean.Log(m.FileName), clean.Log(fileName)) // Update database row. if err := m.Updates(map[string]interface{}{ @@ -513,7 +527,7 @@ func (m *File) Undelete() error { return err } - log.Debugf("file %s: removed missing flag from %s", sanitize.Log(m.FileUID), sanitize.Log(m.FileName)) + log.Debugf("file %s: removed missing flag from %s", clean.Log(m.FileUID), clean.Log(m.FileName)) m.FileMissing = false m.DeletedAt = nil @@ -536,7 +550,7 @@ func (m *File) RelatedPhoto() *Photo { // NoJPEG returns true if the file is not a JPEG image file. func (m *File) NoJPEG() bool { - return m.FileType != string(fs.FormatJpeg) + return fs.ImageJPEG.NotEqual(m.FileType) } // Links returns all share links for this entity. @@ -549,7 +563,7 @@ func (m *File) Panorama() bool { if m.FileSidecar || m.FileWidth <= 1000 || m.FileHeight <= 500 { // Too small. return false - } else if m.Projection() != ProjDefault { + } else if m.Projection() != projection.Unknown { // Panoramic projection. return true } @@ -559,13 +573,17 @@ func (m *File) Panorama() bool { } // Projection returns the panorama projection name if any. -func (m *File) Projection() string { - return SanitizeStringTypeLower(m.FileProjection) +func (m *File) Projection() projection.Type { + return projection.New(m.FileProjection) } // SetProjection sets the panorama projection name. -func (m *File) SetProjection(name string) { - m.FileProjection = SanitizeStringTypeLower(name) +func (m *File) SetProjection(s string) { + if s == "" { + return + } else if t := projection.New(s); !t.Unknown() { + m.FileProjection = t.String() + } } // IsHDR returns true if it is a high dynamic range file. @@ -592,7 +610,7 @@ func (m *File) HasWatermark() bool { // IsAnimated returns true if the file has animated image frames. func (m *File) IsAnimated() bool { - return m.FileFrames > 1 && m.MediaType == MediaImage + return m.FileFrames > 1 && media.Image.Equal(m.MediaType) } // ColorProfile returns the ICC color profile name if any. @@ -633,12 +651,12 @@ func (m *File) SetDuration(d time.Duration) { m.FileDuration = d.Round(time.Second) // Update number of frames. - if m.FileFrames <= 0 && m.FileFPS > 0 { + if m.FileFrames == 0 && m.FileFPS > 1 { m.FileFrames = int(math.Round(m.FileFPS * m.FileDuration.Seconds())) } // Update number of frames per second. - if m.FileFPS <= 0 && m.FileFrames > 0 { + if m.FileFPS == 0 && m.FileFrames > 1 { m.FileFPS = float64(m.FileFrames) / m.FileDuration.Seconds() } } @@ -652,7 +670,7 @@ func (m *File) SetFPS(frameRate float64) { m.FileFPS = frameRate // Update number of frames. - if m.FileFrames <= 0 && m.FileDuration > 0 { + if m.FileFrames == 0 && m.FileDuration > time.Second { m.FileFrames = int(math.Round(m.FileFPS * m.FileDuration.Seconds())) } } @@ -671,13 +689,13 @@ func (m *File) SetFrames(n int) { } } -// SetMetaUTC sets the creation date found in the metadata as a unix ms timestamp. -func (m *File) SetMetaUTC(taken time.Time) { +// SetMediaUTC sets the media creation date from metadata as unix time in ms. +func (m *File) SetMediaUTC(taken time.Time) { if taken.IsZero() { return } - m.MetaUTC = taken.UTC().UnixMilli() + m.MediaUTC = taken.UTC().UnixMilli() } // AddFaces adds face markers to the file. @@ -749,7 +767,7 @@ func (m *File) Markers() *Markers { } else if m.FileUID == "" { m.markers = &Markers{} } else if res, err := FindMarkers(m.FileUID); err != nil { - log.Warnf("file %s: %s while loading markers", sanitize.Log(m.FileUID), err) + log.Warnf("file %s: %s while loading markers", clean.Log(m.FileUID), err) m.markers = &Markers{} } else { m.markers = &res diff --git a/internal/entity/file_fixtures.go b/internal/entity/file_fixtures.go index 32cb938f1..2bdb0f3f8 100644 --- a/internal/entity/file_fixtures.go +++ b/internal/entity/file_fixtures.go @@ -2,6 +2,9 @@ package entity import ( "time" + + "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/media" ) type FileMap map[string]File @@ -81,6 +84,7 @@ var FileFixtures = FileMap{ FileSize: 661858, FileCodec: "jpeg", FileType: "raw", + MediaType: string(media.Raw), FileMime: "image/DNG", FilePrimary: false, FileSidecar: false, @@ -122,6 +126,7 @@ var FileFixtures = FileMap{ FileSize: 858, FileCodec: "", FileType: "xmp", + MediaType: string(media.Sidecar), FileMime: "", FilePrimary: false, FileSidecar: true, @@ -163,6 +168,7 @@ var FileFixtures = FileMap{ FileSize: 961858, FileCodec: "jpeg", FileType: "jpg", + MediaType: string(media.Image), FileMime: "image/jpg", FilePrimary: true, FileSidecar: false, @@ -204,6 +210,7 @@ var FileFixtures = FileMap{ FileSize: 81858, FileCodec: "jpeg", FileType: "jpg", + MediaType: string(media.Image), FileMime: "image/jpg", FilePrimary: true, FileSidecar: false, @@ -245,6 +252,7 @@ var FileFixtures = FileMap{ FileSize: 500, FileCodec: "jpeg", FileType: "jpg", + MediaType: string(media.Image), FileMime: "image/jpg", FilePrimary: true, FileSidecar: false, @@ -286,6 +294,7 @@ var FileFixtures = FileMap{ FileSize: 500, FileCodec: "jpeg", FileType: "jpg", + MediaType: string(media.Image), FileMime: "image/jpg", FilePrimary: true, FileSidecar: false, @@ -327,6 +336,7 @@ var FileFixtures = FileMap{ FileSize: 500, FileCodec: "jpeg", FileType: "jpg", + MediaType: string(media.Image), FileMime: "image/jpg", FilePrimary: true, FileSidecar: false, @@ -368,6 +378,7 @@ var FileFixtures = FileMap{ FileSize: 7799202, FileCodec: "avc1", FileType: "mp4", + MediaType: string(media.Video), FileMime: "video/mp4", FilePrimary: false, FileSidecar: false, @@ -409,6 +420,7 @@ var FileFixtures = FileMap{ FileSize: 7799202, FileCodec: "jpeg", FileType: "jpg", + MediaType: string(media.Image), FileMime: "image/jpg", FilePrimary: true, FileSidecar: false, @@ -450,6 +462,7 @@ var FileFixtures = FileMap{ FileSize: 500, FileCodec: "avc1", FileType: "mp4", + MediaType: string(media.Video), FileMime: "video/mp4", FilePrimary: false, FileSidecar: false, @@ -491,6 +504,7 @@ var FileFixtures = FileMap{ FileSize: 961851, FileCodec: "jpeg", FileType: "jpg", + MediaType: string(media.Image), FileMime: "image/jpg", FilePrimary: true, FileSidecar: false, @@ -532,6 +546,7 @@ var FileFixtures = FileMap{ FileSize: 921858, FileCodec: "jpeg", FileType: "jpg", + MediaType: string(media.Image), FileMime: "image/jpg", FilePrimary: true, FileSidecar: false, @@ -573,6 +588,7 @@ var FileFixtures = FileMap{ FileSize: 921851, FileCodec: "jpeg", FileType: "jpg", + MediaType: string(media.Image), FileMime: "image/jpg", FilePrimary: false, FileSidecar: false, @@ -614,6 +630,7 @@ var FileFixtures = FileMap{ FileSize: 921851, FileCodec: "avc1", FileType: "mp4", + MediaType: string(media.Video), FileMime: "image/mp4", FilePrimary: false, FileSidecar: false, @@ -655,6 +672,7 @@ var FileFixtures = FileMap{ FileSize: 921831, FileCodec: "jpeg", FileType: "jpg", + MediaType: string(media.Image), FileMime: "image/jpg", FilePrimary: true, FileSidecar: false, @@ -696,6 +714,7 @@ var FileFixtures = FileMap{ FileSize: 921831, FileCodec: "jpeg", FileType: "jpg", + MediaType: string(media.Image), FileMime: "image/jpg", FilePrimary: true, FileSidecar: false, @@ -737,6 +756,7 @@ var FileFixtures = FileMap{ FileSize: 900, FileCodec: "jpeg", FileType: "jpg", + MediaType: string(media.Image), FileMime: "image/jpg", FilePrimary: true, FileSidecar: false, @@ -778,6 +798,7 @@ var FileFixtures = FileMap{ FileSize: 900, FileCodec: "", FileType: "jpg", + MediaType: string(media.Image), FileMime: "image/jpg", FilePrimary: true, FileSidecar: false, @@ -819,6 +840,7 @@ var FileFixtures = FileMap{ FileSize: 900, FileCodec: "avc1", FileType: "mp4", + MediaType: string(media.Video), FileMime: "video/mp4", FilePrimary: false, FileSidecar: false, @@ -860,6 +882,7 @@ var FileFixtures = FileMap{ FileSize: 900, FileCodec: "jpeg", FileType: "jpg", + MediaType: string(media.Image), FileMime: "image/jpg", FilePrimary: true, FileSidecar: false, @@ -901,6 +924,7 @@ var FileFixtures = FileMap{ FileSize: 900, FileCodec: "jpeg", FileType: "raw", + MediaType: string(media.Raw), FileMime: "image/x-canon-cr2", FilePrimary: false, FileSidecar: false, @@ -942,6 +966,7 @@ var FileFixtures = FileMap{ FileSize: 900, FileCodec: "jpeg", FileType: "jpg", + MediaType: string(media.Image), FileMime: "image/jpg", FilePrimary: true, FileSidecar: false, @@ -2538,7 +2563,8 @@ var FileFixtures = FileMap{ FileHash: "pcad9168fa6acc5c5c2965adf6ec465ca42fd3451", FileSize: 921858, FileCodec: "jpeg", - FileType: "jpg", + FileType: string(fs.ImageJPEG), + MediaType: string(media.Image), FileMime: "image/jpg", FilePrimary: true, FileSidecar: false, @@ -2580,6 +2606,7 @@ var FileFixtures = FileMap{ FileSize: 921858, FileCodec: "jpeg", FileType: "jpg", + MediaType: string(media.Image), FileMime: "image/jpg", FilePrimary: true, FileSidecar: false, @@ -2621,6 +2648,7 @@ var FileFixtures = FileMap{ FileSize: 921858, FileCodec: "jpeg", FileType: "jpg", + MediaType: string(media.Image), FileMime: "image/jpg", FilePrimary: true, FileSidecar: false, diff --git a/internal/entity/file_json.go b/internal/entity/file_json.go index a76fab9e7..c11e6cbd5 100644 --- a/internal/entity/file_json.go +++ b/internal/entity/file_json.go @@ -15,14 +15,14 @@ func (m *File) MarshalJSON() ([]byte, error) { Hash string Size int64 Primary bool - MetaUTC int64 `json:",omitempty"` TimeIndex *string `json:",omitempty"` MediaID *string `json:",omitempty"` + MediaUTC int64 `json:",omitempty"` InstanceID string `json:",omitempty"` OriginalName string `json:",omitempty"` Codec string `json:",omitempty"` - FileType string `json:"FileType"` - MediaType string `json:"MediaType"` + FileType string `json:",omitempty"` + MediaType string `json:",omitempty"` Mime string `json:",omitempty"` Sidecar bool `json:",omitempty"` Missing bool `json:",omitempty"` @@ -61,7 +61,7 @@ func (m *File) MarshalJSON() ([]byte, error) { Hash: m.FileHash, Size: m.FileSize, Primary: m.FilePrimary, - MetaUTC: m.MetaUTC, + MediaUTC: m.MediaUTC, TimeIndex: m.TimeIndex, MediaID: m.MediaID, InstanceID: m.InstanceID, diff --git a/internal/entity/file_test.go b/internal/entity/file_test.go index 67790c278..ca4f00609 100644 --- a/internal/entity/file_test.go +++ b/internal/entity/file_test.go @@ -7,8 +7,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/photoprism/photoprism/internal/face" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/colors" "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/projection" ) func TestFirstFileByHash(t *testing.T) { @@ -35,7 +37,7 @@ func TestFile_ShareFileName(t *testing.T) { filename := file.ShareBase(0) assert.Contains(t, filename, "20190115-000000-Berlin-Morning-Mood") - assert.Contains(t, filename, fs.JpegExt) + assert.Contains(t, filename, fs.ExtJPEG) }) t.Run("photo without title", func(t *testing.T) { photo := &Photo{TakenAtLocal: time.Date(2019, 01, 15, 0, 0, 0, 0, time.UTC), PhotoTitle: ""} @@ -290,11 +292,11 @@ func TestFile_Panorama(t *testing.T) { assert.False(t, file.Panorama()) }) t.Run("equirectangular", func(t *testing.T) { - file := &File{Photo: nil, FileType: "jpg", FileSidecar: false, FileWidth: 1500, FileHeight: 1000, FileProjection: ProjEquirectangular} + file := &File{Photo: nil, FileType: "jpg", FileSidecar: false, FileWidth: 1500, FileHeight: 1000, FileProjection: projection.Equirectangular.String()} assert.True(t, file.Panorama()) }) t.Run("transverse-cylindrical", func(t *testing.T) { - file := &File{Photo: nil, FileType: "jpg", FileSidecar: false, FileWidth: 1500, FileHeight: 1000, FileProjection: ProjTransverseCylindrical} + file := &File{Photo: nil, FileType: "jpg", FileSidecar: false, FileWidth: 1500, FileHeight: 1000, FileProjection: projection.TransverseCylindrical.String()} assert.True(t, file.Panorama()) }) t.Run("sidecar", func(t *testing.T) { @@ -304,36 +306,42 @@ func TestFile_Panorama(t *testing.T) { } func TestFile_SetProjection(t *testing.T) { - t.Run(ProjDefault, func(t *testing.T) { + t.Run("Unknown", func(t *testing.T) { m := &File{} - m.SetProjection(ProjDefault) - assert.Equal(t, ProjDefault, m.FileProjection) + m.SetProjection(Unknown) + assert.True(t, projection.Unknown.Equal(m.FileProjection)) + assert.Equal(t, Unknown, m.FileProjection) + assert.Equal(t, projection.Unknown.String(), m.FileProjection) }) - t.Run(ProjCubestrip, func(t *testing.T) { + t.Run(projection.Cubestrip.String(), func(t *testing.T) { m := &File{} - m.SetProjection(ProjCubestrip) - assert.Equal(t, ProjCubestrip, m.FileProjection) + m.SetProjection(projection.Cubestrip.String()) + assert.True(t, projection.Cubestrip.Equal(m.FileProjection)) + assert.Equal(t, projection.Cubestrip.String(), m.FileProjection) }) - t.Run(ProjCylindrical, func(t *testing.T) { + t.Run(projection.Cylindrical.String(), func(t *testing.T) { m := &File{} - m.SetProjection(ProjCylindrical) - assert.Equal(t, ProjCylindrical, m.FileProjection) + m.SetProjection(projection.Cylindrical.String()) + assert.True(t, projection.Cylindrical.Equal(m.FileProjection)) + assert.Equal(t, projection.Cylindrical.String(), m.FileProjection) }) - t.Run(ProjTransverseCylindrical, func(t *testing.T) { + t.Run(projection.TransverseCylindrical.String(), func(t *testing.T) { m := &File{} - m.SetProjection(ProjTransverseCylindrical) - assert.Equal(t, ProjTransverseCylindrical, m.FileProjection) + m.SetProjection(projection.TransverseCylindrical.String()) + assert.Equal(t, projection.TransverseCylindrical.String(), m.FileProjection) }) - t.Run(ProjPseudocylindricalCompromise, func(t *testing.T) { + t.Run(projection.PseudocylindricalCompromise.String(), func(t *testing.T) { m := &File{} - m.SetProjection(ProjPseudocylindricalCompromise) - assert.Equal(t, ProjPseudocylindricalCompromise, m.FileProjection) + m.SetProjection(projection.PseudocylindricalCompromise.String()) + assert.Equal(t, projection.PseudocylindricalCompromise.String(), m.FileProjection) + assert.Equal(t, projection.PseudocylindricalCompromise, projection.Find(m.FileProjection)) }) - t.Run("Sanitize", func(t *testing.T) { + t.Run("New", func(t *testing.T) { m := &File{} - m.SetProjection(" 幸福 Hanzi are logograms developed for the writing of Chinese! Expressions in an index may not ...!") - assert.Equal(t, "hanzi are logograms developed for the writing of chinese! expres", m.FileProjection) - assert.Equal(t, ClipStringType, len(m.FileProjection)) + p := projection.New(" 幸福 Hanzi are logograms developed for the writing of Chinese! Expressions in an index may not ...!") + m.SetProjection(p.String()) + assert.Equal(t, p.String(), m.FileProjection) + assert.GreaterOrEqual(t, clean.ClipType, len(m.FileProjection)) }) } @@ -384,12 +392,12 @@ func TestFile_OriginalBase(t *testing.T) { filename := file.OriginalBase(0) assert.Contains(t, filename, "20190115-000000-Berlin-Morning-Mood") - assert.Contains(t, filename, fs.JpegExt) + assert.Contains(t, filename, fs.ExtJPEG) filename2 := file.OriginalBase(1) assert.Contains(t, filename2, "20190115-000000-Berlin-Morning-Mood") assert.Contains(t, filename2, "(1)") - assert.Contains(t, filename2, fs.JpegExt) + assert.Contains(t, filename2, fs.ExtJPEG) }) t.Run("original name empty", func(t *testing.T) { photo := &Photo{TakenAtLocal: time.Date(2019, 01, 15, 0, 0, 0, 0, time.UTC), PhotoTitle: "Berlin / Morning Mood"} @@ -398,12 +406,12 @@ func TestFile_OriginalBase(t *testing.T) { filename := file.OriginalBase(0) assert.Contains(t, filename, "sonnenaufgang") - assert.Contains(t, filename, fs.JpegExt) + assert.Contains(t, filename, fs.ExtJPEG) filename2 := file.OriginalBase(1) assert.Contains(t, filename2, "sonnenaufgang") assert.Contains(t, filename2, "(1)") - assert.Contains(t, filename2, fs.JpegExt) + assert.Contains(t, filename2, fs.ExtJPEG) }) t.Run("original name not empty", func(t *testing.T) { photo := &Photo{TakenAtLocal: time.Date(2019, 01, 15, 0, 0, 0, 0, time.UTC), PhotoTitle: "Berlin / Morning Mood"} @@ -412,12 +420,12 @@ func TestFile_OriginalBase(t *testing.T) { filename := file.OriginalBase(0) assert.Contains(t, filename, "Sonnenaufgang") - assert.Contains(t, filename, fs.JpegExt) + assert.Contains(t, filename, fs.ExtJPEG) filename2 := file.OriginalBase(1) assert.Contains(t, filename2, "Sonnenaufgang") assert.Contains(t, filename2, "(1)") - assert.Contains(t, filename2, fs.JpegExt) + assert.Contains(t, filename2, fs.ExtJPEG) }) } @@ -428,12 +436,12 @@ func TestFile_DownloadName(t *testing.T) { filename := file.DownloadName(DownloadNameFile, 0) assert.Contains(t, filename, "filename") - assert.Contains(t, filename, fs.JpegExt) + assert.Contains(t, filename, fs.ExtJPEG) filename2 := file.DownloadName(DownloadNameOriginal, 1) assert.Contains(t, filename2, "originalName") assert.Contains(t, filename2, "(1)") - assert.Contains(t, filename2, fs.JpegExt) + assert.Contains(t, filename2, fs.ExtJPEG) filename3 := file.DownloadName("xxx", 0) assert.Contains(t, filename3, "20190115-000000-Berlin-Morning-Mood") diff --git a/internal/entity/folder.go b/internal/entity/folder.go index e989101b6..e844dcf10 100644 --- a/internal/entity/folder.go +++ b/internal/entity/folder.go @@ -8,11 +8,12 @@ import ( "time" "github.com/jinzhu/gorm" - "github.com/photoprism/photoprism/internal/form" - "github.com/photoprism/photoprism/pkg/rnd" - "github.com/photoprism/photoprism/pkg/sanitize" - "github.com/photoprism/photoprism/pkg/txt" "github.com/ulule/deepcopier" + + "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{} @@ -51,11 +52,11 @@ func (Folder) TableName() string { // 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.IsUID(m.FolderUID, 'd') { + if rnd.ValidID(m.FolderUID, 'd') { return nil } - return scope.SetColumn("FolderUID", rnd.PPID('d')) + return scope.SetColumn("FolderUID", rnd.GenerateUID('d')) } // NewFolder creates a new file system directory entity. @@ -77,7 +78,7 @@ func NewFolder(root, pathName string, modTime time.Time) Folder { } result := Folder{ - FolderUID: rnd.PPID('d'), + FolderUID: rnd.GenerateUID('d'), Root: root, Path: pathName, FolderType: MediaUnknown, @@ -185,7 +186,7 @@ func (m *Folder) Create() error { if err := a.Create(); err != nil { log.Errorf("folder: %s (add album)", err) } else { - log.Infof("folder: added album %s (%s)", sanitize.Log(a.AlbumTitle), a.AlbumFilter) + log.Infof("folder: added album %s (%s)", clean.Log(a.AlbumTitle), a.AlbumFilter) } } diff --git a/internal/entity/label.go b/internal/entity/label.go index 66a9246c0..13e292f7a 100644 --- a/internal/entity/label.go +++ b/internal/entity/label.go @@ -9,8 +9,8 @@ import ( "github.com/photoprism/photoprism/internal/classify" "github.com/photoprism/photoprism/internal/event" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/rnd" - "github.com/photoprism/photoprism/pkg/sanitize" "github.com/photoprism/photoprism/pkg/txt" ) @@ -47,11 +47,11 @@ func (Label) TableName() string { // BeforeCreate creates a random UID if needed before inserting a new row to the database. func (m *Label) BeforeCreate(scope *gorm.Scope) error { - if rnd.IsUID(m.LabelUID, 'l') { + if rnd.ValidID(m.LabelUID, 'l') { return nil } - return scope.SetColumn("LabelUID", rnd.PPID('l')) + return scope.SetColumn("LabelUID", rnd.GenerateUID('l')) } // NewLabel returns a new label. @@ -164,7 +164,7 @@ func (m *Label) AfterCreate(scope *gorm.Scope) error { // SetName changes the label name. func (m *Label) SetName(name string) { - name = sanitize.Name(name) + name = clean.Name(name) if name == "" { return @@ -221,7 +221,7 @@ func (m *Label) UpdateClassify(label classify.Label) error { } if err := db.Model(m).Association("LabelCategories").Append(sn).Error; err != nil { - log.Debugf("index: failed saving label category %s (%s)", sanitize.Log(category), err) + log.Debugf("index: failed saving label category %s (%s)", clean.Log(category), err) } } } diff --git a/internal/entity/lens.go b/internal/entity/lens.go index 072c61884..bdf2599f6 100644 --- a/internal/entity/lens.go +++ b/internal/entity/lens.go @@ -6,7 +6,7 @@ import ( "time" "github.com/photoprism/photoprism/internal/event" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/txt" ) @@ -125,7 +125,7 @@ func FirstOrCreateLens(m *Lens) *Lens { lensCache.SetDefault(m.LensSlug, &result) return &result } else { - log.Errorf("lens: %s (create %s)", err.Error(), sanitize.Log(m.String())) + log.Errorf("lens: %s (create %s)", err.Error(), clean.Log(m.String())) } return &UnknownLens @@ -133,7 +133,7 @@ func FirstOrCreateLens(m *Lens) *Lens { // String returns an identifier that can be used in logs. func (m *Lens) String() string { - return sanitize.Log(m.LensName) + return clean.Log(m.LensName) } // Unknown returns true if the lens is not a known make or model. diff --git a/internal/entity/link.go b/internal/entity/link.go index 2a5460e19..9ac12d00d 100644 --- a/internal/entity/link.go +++ b/internal/entity/link.go @@ -6,8 +6,8 @@ import ( "github.com/jinzhu/gorm" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/rnd" - "github.com/photoprism/photoprism/pkg/sanitize" "github.com/photoprism/photoprism/pkg/txt" ) @@ -31,11 +31,11 @@ type Link struct { // BeforeCreate creates a random UID if needed before inserting a new row to the database. func (m *Link) BeforeCreate(scope *gorm.Scope) error { - if rnd.IsUID(m.LinkUID, 's') { + if rnd.ValidID(m.LinkUID, 's') { return nil } - return scope.SetColumn("LinkUID", rnd.PPID('s')) + return scope.SetColumn("LinkUID", rnd.GenerateUID('s')) } // NewLink creates a sharing link. @@ -43,9 +43,9 @@ func NewLink(shareUID string, canComment, canEdit bool) Link { now := TimeStamp() result := Link{ - LinkUID: rnd.PPID('s'), + LinkUID: rnd.GenerateUID('s'), ShareUID: shareUID, - LinkToken: rnd.Token(10), + LinkToken: rnd.GenerateToken(10), CanComment: canComment, CanEdit: canEdit, CreatedAt: now, @@ -112,7 +112,7 @@ func (m *Link) InvalidPassword(password string) bool { // Save inserts a new row to the database or updates a row if the primary key already exists. func (m *Link) Save() error { - if !rnd.IsPPID(m.ShareUID, 0) { + if !rnd.EntityUID(m.ShareUID, 0) { return fmt.Errorf("link: invalid share uid (%s)", m.ShareUID) } @@ -166,7 +166,7 @@ func FindLinks(token, share string) (result Links) { } if share != "" { - if rnd.IsPPID(share, 'a') { + if rnd.EntityUID(share, 'a') { q = q.Where("share_uid = ?", share) } else { q = q.Where("share_slug = ?", share) @@ -193,5 +193,5 @@ func FindValidLinks(token, share string) (result Links) { // String returns an human readable identifier for logging. func (m *Link) String() string { - return sanitize.Log(m.LinkUID) + return clean.Log(m.LinkUID) } diff --git a/internal/entity/link_test.go b/internal/entity/link_test.go index 7c37267eb..88d95bc3f 100644 --- a/internal/entity/link_test.go +++ b/internal/entity/link_test.go @@ -46,7 +46,7 @@ func TestLink_Expired(t *testing.T) { } func TestLink_Redeem(t *testing.T) { - link := NewLink(rnd.PPID('a'), false, false) + link := NewLink(rnd.GenerateUID('a'), false, false) assert.Equal(t, uint(0), link.LinkViews) diff --git a/internal/entity/marker.go b/internal/entity/marker.go index 57a82a1a0..c19adba95 100644 --- a/internal/entity/marker.go +++ b/internal/entity/marker.go @@ -14,8 +14,8 @@ import ( "github.com/photoprism/photoprism/internal/face" "github.com/photoprism/photoprism/internal/form" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/rnd" - "github.com/photoprism/photoprism/pkg/sanitize" ) const ( @@ -62,11 +62,11 @@ func (Marker) TableName() string { // BeforeCreate creates a random UID if needed before inserting a new row to the database. func (m *Marker) BeforeCreate(scope *gorm.Scope) error { - if rnd.IsUID(m.MarkerUID, 'm') { + if rnd.ValidID(m.MarkerUID, 'm') { return nil } - return scope.SetColumn("MarkerUID", rnd.PPID('m')) + return scope.SetColumn("MarkerUID", rnd.GenerateUID('m')) } // NewMarker creates a new entity. @@ -157,7 +157,7 @@ func (m *Marker) SetName(name, src string) (changed bool, err error) { return false, nil } - name = sanitize.Name(name) + name = clean.Name(name) if name == "" { return false, nil @@ -232,7 +232,7 @@ func (m *Marker) SetFace(f *Face, dist float64) (updated bool, err error) { } else if reported, err := f.ResolveCollision(m.Embeddings()); err != nil { return false, err } else if reported { - log.Warnf("faces: marker %s face %s has ambiguous subjects %s <> %s, subject source %s", sanitize.Log(m.MarkerUID), sanitize.Log(f.ID), sanitize.Log(m.SubjUID), sanitize.Log(f.SubjUID), SrcString(m.SubjSrc)) + log.Warnf("faces: marker %s face %s has ambiguous subjects %s <> %s, subject source %s", clean.Log(m.MarkerUID), clean.Log(f.ID), clean.Log(m.SubjUID), clean.Log(f.SubjUID), SrcString(m.SubjSrc)) return false, nil } else { return false, nil @@ -428,10 +428,10 @@ func (m *Marker) Subject() (subj *Subject) { // Create subject? if m.SubjSrc != SrcAuto && m.MarkerName != "" && m.SubjUID == "" { if subj = NewSubject(m.MarkerName, SubjPerson, m.SubjSrc); subj == nil { - log.Errorf("faces: marker %s has invalid subject %s", sanitize.Log(m.MarkerUID), sanitize.Log(m.MarkerName)) + log.Errorf("faces: marker %s has invalid subject %s", clean.Log(m.MarkerUID), clean.Log(m.MarkerName)) return nil } else if subj = FirstOrCreateSubject(subj); subj == nil { - log.Debugf("faces: marker %s has invalid subject %s", sanitize.Log(m.MarkerUID), sanitize.Log(m.MarkerName)) + log.Debugf("faces: marker %s has invalid subject %s", clean.Log(m.MarkerUID), clean.Log(m.MarkerName)) return nil } else { m.subject = subj @@ -457,9 +457,9 @@ func (m *Marker) ClearSubject(src string) error { // Find and (soft) delete unused subjects. start := time.Now() if count, err := DeleteOrphanPeople(); err != nil { - log.Errorf("faces: %s while clearing subject of marker %s [%s]", err, sanitize.Log(m.MarkerUID), time.Since(start)) + log.Errorf("faces: %s while clearing subject of marker %s [%s]", err, clean.Log(m.MarkerUID), time.Since(start)) } else if count > 0 { - log.Debugf("faces: %s marked as missing while clearing subject of marker %s [%s]", english.Plural(count, "person", "people"), sanitize.Log(m.MarkerUID), time.Since(start)) + log.Debugf("faces: %s marked as missing while clearing subject of marker %s [%s]", english.Plural(count, "person", "people"), clean.Log(m.MarkerUID), time.Since(start)) } }() @@ -472,7 +472,7 @@ func (m *Marker) ClearSubject(src string) error { } else if resolved, err := m.face.ResolveCollision(m.Embeddings()); err != nil { return err } else if resolved { - log.Debugf("faces: marker %s resolved ambiguous subjects for face %s", sanitize.Log(m.MarkerUID), sanitize.Log(m.face.ID)) + log.Debugf("faces: marker %s resolved ambiguous subjects for face %s", clean.Log(m.MarkerUID), clean.Log(m.face.ID)) } // Clear references. @@ -498,23 +498,23 @@ func (m *Marker) Face() (f *Face) { // Add face if size if m.SubjSrc != SrcAuto && m.FaceID == "" { if m.Size < face.ClusterSizeThreshold || m.Score < face.ClusterScoreThreshold { - log.Debugf("faces: marker %s skipped adding face due to low-quality (size %d, score %d)", sanitize.Log(m.MarkerUID), m.Size, m.Score) + log.Debugf("faces: marker %s skipped adding face due to low-quality (size %d, score %d)", clean.Log(m.MarkerUID), m.Size, m.Score) return nil } if emb := m.Embeddings(); emb.Empty() { - log.Warnf("faces: marker %s has no face embeddings", sanitize.Log(m.MarkerUID)) + log.Warnf("faces: marker %s has no face embeddings", clean.Log(m.MarkerUID)) return nil } else if f = NewFace(m.SubjUID, m.SubjSrc, emb); f == nil { - log.Warnf("faces: failed assigning face to marker %s", sanitize.Log(m.MarkerUID)) + log.Warnf("faces: failed assigning face to marker %s", clean.Log(m.MarkerUID)) return nil } else if f.SkipMatching() { - log.Infof("faces: skipped matching marker %s, embedding %s not distinct enough", sanitize.Log(m.MarkerUID), f.ID) + log.Infof("faces: skipped matching marker %s, embedding %s not distinct enough", clean.Log(m.MarkerUID), f.ID) } else if f = FirstOrCreateFace(f); f == nil { - log.Warnf("faces: failed matching marker %s with subject %s", sanitize.Log(m.MarkerUID), SubjNames.Log(m.SubjUID)) + log.Warnf("faces: failed matching marker %s with subject %s", clean.Log(m.MarkerUID), SubjNames.Log(m.SubjUID)) return nil } else if err := f.MatchMarkers(Faceless); err != nil { - log.Errorf("faces: failed matching marker %s with subject %s (%s)", sanitize.Log(m.MarkerUID), SubjNames.Log(m.SubjUID), err) + log.Errorf("faces: failed matching marker %s with subject %s (%s)", clean.Log(m.MarkerUID), SubjNames.Log(m.SubjUID), err) } m.face = f @@ -709,7 +709,7 @@ func CreateMarkerIfNotExists(m *Marker) (*Marker, error) { } else if err := m.Create(); err != nil { return m, err } else { - log.Debugf("markers: added %s %s for file %s", TypeString(m.MarkerType), sanitize.Log(m.MarkerUID), sanitize.Log(m.FileUID)) + log.Debugf("markers: added %s %s for file %s", TypeString(m.MarkerType), clean.Log(m.MarkerUID), clean.Log(m.FileUID)) } return m, nil diff --git a/internal/entity/photo.go b/internal/entity/photo.go index af72a5ab8..414fb560c 100644 --- a/internal/entity/photo.go +++ b/internal/entity/photo.go @@ -16,8 +16,8 @@ import ( "github.com/photoprism/photoprism/internal/classify" "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/form" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/rnd" - "github.com/photoprism/photoprism/pkg/sanitize" "github.com/photoprism/photoprism/pkg/txt" ) @@ -211,15 +211,15 @@ func SavePhotoForm(model Photo, form form.Photo) error { func (m *Photo) String() string { if m.PhotoUID == "" { if m.PhotoName != "" { - return sanitize.Log(m.PhotoName) + return clean.Log(m.PhotoName) } else if m.OriginalName != "" { - return sanitize.Log(m.OriginalName) + return clean.Log(m.OriginalName) } return "(unknown)" } - return "uid " + sanitize.Log(m.PhotoUID) + return "uid " + clean.Log(m.PhotoUID) } // FirstOrCreate fetches an existing row from the database or inserts a new one. @@ -286,7 +286,7 @@ func (m *Photo) Find() error { Preload("Cell"). Preload("Cell.Place") - if rnd.IsPPID(m.PhotoUID, 'p') { + if rnd.EntityUID(m.PhotoUID, 'p') { if err := q.First(m, "photo_uid = ?", m.PhotoUID).Error; err != nil { return err } @@ -365,11 +365,11 @@ func (m *Photo) BeforeCreate(scope *gorm.Scope) error { } } - if rnd.IsUID(m.PhotoUID, 'p') { + if rnd.ValidID(m.PhotoUID, 'p') { return nil } - return scope.SetColumn("PhotoUID", rnd.PPID('p')) + return scope.SetColumn("PhotoUID", rnd.GenerateUID('p')) } // BeforeSave ensures the existence of TakenAt properties before indexing or updating a photo @@ -562,17 +562,17 @@ func (m *Photo) AddLabels(labels classify.Labels) { labelEntity := FirstOrCreateLabel(NewLabel(classifyLabel.Title(), classifyLabel.Priority)) if labelEntity == nil { - log.Errorf("index: label %s should not be nil - possible bug (%s)", sanitize.Log(classifyLabel.Title()), m) + log.Errorf("index: label %s should not be nil - possible bug (%s)", clean.Log(classifyLabel.Title()), m) continue } if labelEntity.Deleted() { - log.Debugf("index: skipping deleted label %s (%s)", sanitize.Log(classifyLabel.Title()), m) + log.Debugf("index: skipping deleted label %s (%s)", clean.Log(classifyLabel.Title()), m) continue } if err := labelEntity.UpdateClassify(classifyLabel); err != nil { - log.Errorf("index: failed updating label %s (%s)", sanitize.Log(classifyLabel.Title()), err) + log.Errorf("index: failed updating label %s (%s)", clean.Log(classifyLabel.Title()), err) } photoLabel := FirstOrCreatePhotoLabel(NewPhotoLabel(m.ID, labelEntity.ID, classifyLabel.Uncertainty, classifyLabel.Source)) @@ -722,7 +722,7 @@ func (m *Photo) Restore() error { // Delete deletes the photo from the index. func (m *Photo) Delete(permanently bool) (files Files, err error) { if m.ID < 1 || m.PhotoUID == "" { - return files, fmt.Errorf("invalid photo id %d / uid %s", m.ID, sanitize.Log(m.PhotoUID)) + return files, fmt.Errorf("invalid photo id %d / uid %s", m.ID, clean.Log(m.PhotoUID)) } if permanently { @@ -743,7 +743,7 @@ func (m *Photo) Delete(permanently bool) (files Files, err error) { // DeletePermanently permanently removes a photo from the index. func (m *Photo) DeletePermanently() (files Files, err error) { if m.ID < 1 || m.PhotoUID == "" { - return files, fmt.Errorf("invalid photo id %d / uid %s", m.ID, sanitize.Log(m.PhotoUID)) + return files, fmt.Errorf("invalid photo id %d / uid %s", m.ID, clean.Log(m.PhotoUID)) } files = m.AllFiles() diff --git a/internal/entity/photo_estimate.go b/internal/entity/photo_estimate.go index 8e1c7bbd7..5b522e6e9 100644 --- a/internal/entity/photo_estimate.go +++ b/internal/entity/photo_estimate.go @@ -5,9 +5,9 @@ import ( "github.com/jinzhu/gorm" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/geo" - "github.com/photoprism/photoprism/pkg/sanitize" "github.com/photoprism/photoprism/pkg/txt" ) @@ -53,7 +53,7 @@ func (m *Photo) EstimateCountry() { m.PhotoCountry = countryCode m.PlaceSrc = SrcEstimate m.EstimatedAt = TimePointer() - log.Debugf("photo: estimated country for %s is %s", m, sanitize.Log(m.CountryName())) + log.Debugf("photo: estimated country for %s is %s", m, clean.Log(m.CountryName())) } } @@ -110,7 +110,7 @@ func (m *Photo) EstimateLocation(force bool) { Order(gorm.Expr("ABS(JulianDay(taken_at) - JulianDay(?))", m.TakenAt)).Limit(2). Preload("Place").Find(&mostRecent).Error default: - log.Warnf("photo: unsupported sql dialect %s", sanitize.Log(DbDialect())) + log.Warnf("photo: unsupported sql dialect %s", clean.Log(DbDialect())) return } @@ -147,7 +147,7 @@ func (m *Photo) EstimateLocation(force bool) { } } } else if recentPhoto.HasCountry() { - log.Debugf("photo: estimated country for %s is %s", m, sanitize.Log(m.CountryName())) + log.Debugf("photo: estimated country for %s is %s", m, clean.Log(m.CountryName())) m.RemoveLocation(SrcEstimate, false) m.RemoveLocationLabels() m.PhotoCountry = recentPhoto.PhotoCountry diff --git a/internal/entity/photo_location.go b/internal/entity/photo_location.go index 1b787c058..7cd304de4 100644 --- a/internal/entity/photo_location.go +++ b/internal/entity/photo_location.go @@ -9,8 +9,8 @@ import ( "github.com/photoprism/photoprism/internal/classify" "github.com/photoprism/photoprism/internal/maps" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/geo" - "github.com/photoprism/photoprism/pkg/sanitize" "github.com/photoprism/photoprism/pkg/txt" "gopkg.in/photoprism/go-tz.v2/tz" ) @@ -78,7 +78,7 @@ func (m *Photo) SetPosition(pos geo.Position, source string, force bool) { if m.Place == nil { log.Warnf("photo: failed updating position of %s", m) } else { - log.Debugf("photo: approximate place of %s is %s (id %s)", m, sanitize.Log(m.Place.Label()), m.PlaceID) + log.Debugf("photo: approximate place of %s is %s (id %s)", m, clean.Log(m.Place.Label()), m.PlaceID) } } } @@ -107,7 +107,7 @@ func (m *Photo) AdoptPlace(other Photo, source string, force bool) { m.UpdateTimeZone(other.TimeZone) - log.Debugf("photo: %s now located at %s (id %s)", m.String(), sanitize.Log(m.Place.Label()), m.PlaceID) + log.Debugf("photo: %s now located at %s (id %s)", m.String(), clean.Log(m.Place.Label()), m.PlaceID) } // RemoveLocation removes the current location. diff --git a/internal/entity/photo_merge.go b/internal/entity/photo_merge.go index 6028667b5..2a0533e77 100644 --- a/internal/entity/photo_merge.go +++ b/internal/entity/photo_merge.go @@ -38,7 +38,7 @@ func (m *Photo) Identical(includeMeta, includeUuid bool) (identical Photos, err } includeMeta = includeMeta && m.TrustedLocation() && m.TrustedTime() - includeUuid = includeUuid && rnd.IsUUID(m.UUID) + includeUuid = includeUuid && rnd.ValidUUID(m.UUID) switch { case includeMeta && includeUuid: diff --git a/internal/entity/photo_title.go b/internal/entity/photo_title.go index f433f5be6..fcd9d241e 100644 --- a/internal/entity/photo_title.go +++ b/internal/entity/photo_title.go @@ -9,8 +9,8 @@ import ( "github.com/dustin/go-humanize/english" "github.com/photoprism/photoprism/internal/classify" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" "github.com/photoprism/photoprism/pkg/txt" ) @@ -69,7 +69,7 @@ func (m *Photo) UpdateTitle(labels classify.Labels) error { // TODO: User defined title format if names != "" { - log.Debugf("photo: %s title based on %s (%s)", m.String(), english.Plural(len(people), "person", "people"), sanitize.Log(names)) + log.Debugf("photo: %s title based on %s (%s)", m.String(), english.Plural(len(people), "person", "people"), clean.Log(names)) if l := len([]rune(names)); l > 35 { m.SetTitle(names, SrcAuto) @@ -83,7 +83,7 @@ func (m *Photo) UpdateTitle(labels classify.Labels) error { m.SetTitle(fmt.Sprintf("%s / %s / %s", names, loc.City(), m.TakenAt.Format("2006")), SrcAuto) } } else if title := labels.Title(loc.Name()); title != "" { - log.Debugf("photo: %s title based on label %s", m.String(), sanitize.Log(title)) + log.Debugf("photo: %s title based on label %s", m.String(), clean.Log(title)) if loc.NoCity() || loc.LongCity() || loc.CityContains(title) { m.SetTitle(fmt.Sprintf("%s / %s / %s", txt.Title(title), loc.CountryName(), m.TakenAt.Format("2006")), SrcAuto) } else { @@ -108,7 +108,7 @@ func (m *Photo) UpdateTitle(labels classify.Labels) error { knownLocation = true if names != "" { - log.Debugf("photo: %s title based on %s (%s)", m.String(), english.Plural(len(people), "person", "people"), sanitize.Log(names)) + log.Debugf("photo: %s title based on %s (%s)", m.String(), english.Plural(len(people), "person", "people"), clean.Log(names)) if l := len([]rune(names)); l > 35 { m.SetTitle(names, SrcAuto) @@ -122,7 +122,7 @@ func (m *Photo) UpdateTitle(labels classify.Labels) error { m.SetTitle(fmt.Sprintf("%s / %s / %s", names, m.Place.City(), m.TakenAt.Format("2006")), SrcAuto) } } else if title := labels.Title(fileTitle); title != "" { - log.Debugf("photo: %s title based on label %s", m.String(), sanitize.Log(title)) + log.Debugf("photo: %s title based on label %s", m.String(), clean.Log(title)) if m.Place.NoCity() || m.Place.LongCity() || m.Place.CityContains(title) { m.SetTitle(fmt.Sprintf("%s / %s / %s", txt.Title(title), m.Place.CountryName(), m.TakenAt.Format("2006")), SrcAuto) } else { @@ -164,7 +164,7 @@ func (m *Photo) UpdateTitle(labels classify.Labels) error { } if m.PhotoTitle != oldTitle { - log.Debugf("photo: %s has new title %s [%s]", m.String(), sanitize.Log(m.PhotoTitle), time.Since(start)) + log.Debugf("photo: %s has new title %s [%s]", m.String(), clean.Log(m.PhotoTitle), time.Since(start)) } return nil diff --git a/internal/entity/photo_yaml.go b/internal/entity/photo_yaml.go index 7f2d2b61c..aed175183 100644 --- a/internal/entity/photo_yaml.go +++ b/internal/entity/photo_yaml.go @@ -66,5 +66,5 @@ func (m *Photo) LoadFromYaml(fileName string) error { // YamlFileName returns the YAML file name. func (m *Photo) YamlFileName(originalsPath, sidecarPath string) string { - return fs.FileName(filepath.Join(originalsPath, m.PhotoPath, m.PhotoName), sidecarPath, originalsPath, fs.YamlExt) + return fs.FileName(filepath.Join(originalsPath, m.PhotoPath, m.PhotoName), sidecarPath, originalsPath, fs.ExtYAML) } diff --git a/internal/entity/place.go b/internal/entity/place.go index 8f916105d..b0e38457f 100644 --- a/internal/entity/place.go +++ b/internal/entity/place.go @@ -6,7 +6,7 @@ import ( "time" "github.com/photoprism/photoprism/internal/maps" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) var placeMutex = sync.Mutex{} @@ -54,7 +54,7 @@ func FindPlace(id string) *Place { place := Place{} if err := Db().Where("id = ?", id).First(&place).Error; err != nil { - log.Debugf("place: %s not found", sanitize.Log(id)) + log.Debugf("place: %s not found", clean.Log(id)) return nil } else { return &place diff --git a/internal/entity/string_map.go b/internal/entity/string_map.go index 7b3078572..4990c5fca 100644 --- a/internal/entity/string_map.go +++ b/internal/entity/string_map.go @@ -4,7 +4,7 @@ import ( "strings" "sync" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // Strings is a simple string map that should not be accessed by multiple goroutines. @@ -63,9 +63,9 @@ func (s *StringMap) Log(key string) (val string) { } if val = s.Get(key); val != "" { - return sanitize.Log(val) + return clean.Log(val) } else { - return sanitize.Log(key) + return clean.Log(key) } } diff --git a/internal/entity/subject.go b/internal/entity/subject.go index 5cc07dd7c..37213f3fa 100644 --- a/internal/entity/subject.go +++ b/internal/entity/subject.go @@ -12,8 +12,8 @@ import ( "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/form" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/rnd" - "github.com/photoprism/photoprism/pkg/sanitize" "github.com/photoprism/photoprism/pkg/txt" ) @@ -50,11 +50,11 @@ func (Subject) TableName() string { // BeforeCreate creates a random UID if needed before inserting a new row to the database. func (m *Subject) BeforeCreate(scope *gorm.Scope) error { - if rnd.IsUID(m.SubjUID, 'j') { + if rnd.ValidID(m.SubjUID, 'j') { return nil } - return scope.SetColumn("SubjUID", rnd.PPID('j')) + return scope.SetColumn("SubjUID", rnd.GenerateUID('j')) } // AfterSave is a hook that updates the name cache after saving. @@ -131,7 +131,7 @@ func (m *Subject) Delete() error { return err } - log.Infof("subject: marked %s %s as missing", TypeString(m.SubjType), sanitize.Log(m.SubjName)) + log.Infof("subject: marked %s %s as missing", TypeString(m.SubjType), clean.Log(m.SubjName)) return Db().Delete(m).Error } @@ -155,7 +155,7 @@ func (m *Subject) Restore() error { if m.Deleted() { m.DeletedAt = nil - log.Infof("subject: restoring %s %s", TypeString(m.SubjType), sanitize.Log(m.SubjName)) + log.Infof("subject: restoring %s %s", TypeString(m.SubjType), clean.Log(m.SubjName)) event.EntitiesCreated("subjects", []*Subject{m}) @@ -193,7 +193,7 @@ func FirstOrCreateSubject(m *Subject) *Subject { if found := FindSubjectByName(m.SubjName); found != nil { return found } else if err := m.Create(); err == nil { - log.Infof("subject: added %s %s", TypeString(m.SubjType), sanitize.Log(m.SubjName)) + log.Infof("subject: added %s %s", TypeString(m.SubjType), clean.Log(m.SubjName)) event.EntitiesCreated("subjects", []*Subject{m}) @@ -208,7 +208,7 @@ func FirstOrCreateSubject(m *Subject) *Subject { } else if found = FindSubjectByName(m.SubjName); found != nil { return found } else { - log.Errorf("subject: failed adding %s (%s)", sanitize.Log(m.SubjName), err) + log.Errorf("subject: failed adding %s (%s)", clean.Log(m.SubjName), err) } return nil @@ -231,7 +231,7 @@ func FindSubject(uid string) *Subject { // FindSubjectByName find an existing subject by name. func FindSubjectByName(name string) *Subject { - name = sanitize.Name(name) + name = clean.Name(name) if name == "" { return nil @@ -243,12 +243,12 @@ func FindSubjectByName(name string) *Subject { switch uid { case "": if err := UnscopedDb().Where("subj_name LIKE ?", name).First(&result).Error; err != nil { - log.Debugf("subject: %s not found by name", sanitize.Log(name)) + log.Debugf("subject: %s not found by name", clean.Log(name)) return nil } default: if found := FindSubject(uid); found == nil { - log.Debugf("subject: %s not found by uid", sanitize.Log(name)) + log.Debugf("subject: %s not found by uid", clean.Log(name)) return nil } else { result = *found @@ -259,10 +259,10 @@ func FindSubjectByName(name string) *Subject { if !result.Deleted() { return &result } else if err := result.Restore(); err == nil { - log.Debugf("subject: restored %s", sanitize.Log(result.SubjName)) + log.Debugf("subject: restored %s", clean.Log(result.SubjName)) return &result } else { - log.Errorf("subject: failed restoring %s (%s)", sanitize.Log(result.SubjName), err) + log.Errorf("subject: failed restoring %s (%s)", clean.Log(result.SubjName), err) } return nil @@ -280,7 +280,7 @@ func (m *Subject) Person() *Person { // SetName changes the subject's name. func (m *Subject) SetName(name string) error { - name = sanitize.Name(name) + name = clean.Name(name) if name == m.SubjName { // Nothing to do. @@ -307,7 +307,7 @@ func (m *Subject) SaveForm(f form.Subject) (changed bool, err error) { } // Change name? - if name := sanitize.Name(f.SubjName); name != "" && name != m.SubjName { + if name := clean.Name(f.SubjName); name != "" && name != m.SubjName { existing, err := m.UpdateName(name) if existing.SubjUID != m.SubjUID || err != nil { @@ -375,7 +375,7 @@ func (m *Subject) UpdateName(name string) (*Subject, error) { if err := m.SetName(name); err != nil { return m, err } else if err := m.Updates(Values{"SubjName": m.SubjName, "SubjSlug": m.SubjSlug}); err == nil { - log.Infof("subject: renamed %s %s", TypeString(m.SubjType), sanitize.Log(m.SubjName)) + log.Infof("subject: renamed %s %s", TypeString(m.SubjType), clean.Log(m.SubjName)) event.EntitiesUpdated("subjects", []*Subject{m}) diff --git a/internal/entity/user.go b/internal/entity/user.go index 4be76732e..333766346 100644 --- a/internal/entity/user.go +++ b/internal/entity/user.go @@ -11,8 +11,8 @@ import ( "github.com/photoprism/photoprism/internal/acl" "github.com/photoprism/photoprism/internal/form" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/rnd" - "github.com/photoprism/photoprism/pkg/sanitize" ) const UsernameLen = 3 @@ -146,10 +146,10 @@ func (m *User) Save() error { // BeforeCreate creates a random UID if needed before inserting a new row to the database. func (m *User) BeforeCreate(tx *gorm.DB) error { - if rnd.IsUID(m.UserUID, 'u') { + if rnd.ValidID(m.UserUID, 'u') { return nil } - m.UserUID = rnd.PPID('u') + m.UserUID = rnd.GenerateUID('u') return nil } @@ -169,7 +169,7 @@ func FirstOrCreateUser(m *User) *User { // FindUserByName returns an existing user or nil if not found. func FindUserByName(userName string) *User { - userName = sanitize.Username(userName) + userName = clean.Username(userName) if userName == "" { return nil @@ -180,7 +180,7 @@ func FindUserByName(userName string) *User { if err := Db().Preload("Address").Where("user_name = ?", userName).First(&result).Error; err == nil { return &result } else { - log.Debugf("user %s not found", sanitize.Log(userName)) + log.Debugf("user %s not found", clean.Log(userName)) return nil } } @@ -196,7 +196,7 @@ func FindUserByUID(uid string) *User { if err := Db().Preload("Address").Where("user_uid = ?", uid).First(&result).Error; err == nil { return &result } else { - log.Debugf("user %s not found", sanitize.Log(uid)) + log.Debugf("user %s not found", clean.Log(uid)) return nil } } @@ -218,24 +218,24 @@ func (m *User) Deleted() bool { // String returns an identifier that can be used in logs. func (m *User) String() string { if n := m.Username(); n != "" { - return sanitize.Log(n) + return clean.Log(n) } if m.FullName != "" { - return sanitize.Log(m.FullName) + return clean.Log(m.FullName) } - return sanitize.Log(m.UserUID) + return clean.Log(m.UserUID) } // Username returns the normalized username. func (m *User) Username() string { - return sanitize.Username(m.UserName) + return clean.Username(m.UserName) } // Registered tests if the user is registered e.g. has a username. func (m *User) Registered() bool { - return m.Username() != "" && rnd.IsPPID(m.UserUID, 'u') + return m.Username() != "" && rnd.EntityUID(m.UserUID, 'u') } // Admin returns true if the user is an admin with user name. @@ -245,7 +245,7 @@ func (m *User) Admin() bool { // Anonymous returns true if the user is unknown. func (m *User) Anonymous() bool { - return !rnd.IsPPID(m.UserUID, 'u') || m.ID == UnknownUser.ID || m.UserUID == UnknownUser.UserUID + return !rnd.EntityUID(m.UserUID, 'u') || m.ID == UnknownUser.ID || m.UserUID == UnknownUser.UserUID } // Guest returns true if the user is a guest. @@ -418,7 +418,7 @@ func CreateWithPassword(uc form.UserCreate) error { if err := tx.Create(&pw).Error; err != nil { return err } - log.Infof("created user %s with uid %s", sanitize.Log(u.Username()), sanitize.Log(u.UserUID)) + log.Infof("created user %s with uid %s", clean.Log(u.Username()), clean.Log(u.UserUID)) return nil }) } diff --git a/internal/face/detector.go b/internal/face/detector.go index 247801b8c..7139af2c2 100644 --- a/internal/face/detector.go +++ b/internal/face/detector.go @@ -11,8 +11,8 @@ import ( "sort" pigo "github.com/esimov/pigo/core" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) //go:embed cascade/facefinder @@ -90,7 +90,7 @@ func Detect(fileName string, findLandmarks bool, minSize int) (faces Faces, err } if !fs.FileExists(fileName) { - return faces, fmt.Errorf("faces: file '%s' not found", sanitize.Log(filepath.Base(fileName))) + return faces, fmt.Errorf("faces: file '%s' not found", clean.Log(filepath.Base(fileName))) } det, params, err := d.Detect(fileName) diff --git a/internal/face/net.go b/internal/face/net.go index 68e2a3cd9..ddfba154c 100644 --- a/internal/face/net.go +++ b/internal/face/net.go @@ -11,7 +11,7 @@ import ( tf "github.com/tensorflow/tensorflow/tensorflow/go" "github.com/photoprism/photoprism/internal/crop" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // Net is a wrapper for the TensorFlow Facenet model. @@ -82,7 +82,7 @@ func (t *Net) loadModel() error { modelPath := path.Join(t.modelPath) - log.Infof("faces: loading %s", sanitize.Log(filepath.Base(modelPath))) + log.Infof("faces: loading %s", clean.Log(filepath.Base(modelPath))) // Load model model, err := tf.LoadSavedModel(modelPath, t.modelTags, nil) diff --git a/internal/ffmpeg/convert.go b/internal/ffmpeg/convert.go index 99a568749..35355ff16 100644 --- a/internal/ffmpeg/convert.go +++ b/internal/ffmpeg/convert.go @@ -19,7 +19,7 @@ func AvcConvertCommand(fileName, avcName, ffmpegBin, bitrate string, encoder Avc useMutex = true // Animated GIF? - if fs.FileFormat(fileName) == fs.FormatGif { + if fs.FileType(fileName) == fs.ImageGIF { result = exec.Command( ffmpegBin, "-i", fileName, diff --git a/internal/ffmpeg/encoders.go b/internal/ffmpeg/encoders.go index 9b8e7eef0..d41e69dc3 100644 --- a/internal/ffmpeg/encoders.go +++ b/internal/ffmpeg/encoders.go @@ -1,6 +1,6 @@ package ffmpeg -import "github.com/photoprism/photoprism/pkg/sanitize" +import "github.com/photoprism/photoprism/pkg/clean" // AvcEncoder represents a supported FFmpeg AVC encoder name. type AvcEncoder string @@ -56,7 +56,7 @@ func FindEncoder(s string) AvcEncoder { if encoder, ok := AvcEncoders[s]; ok { return encoder } else { - log.Warnf("ffmpeg: unsupported encoder %s", sanitize.Log(s)) + log.Warnf("ffmpeg: unsupported encoder %s", clean.Log(s)) } return SoftwareEncoder diff --git a/internal/form/report.go b/internal/form/form_report.go similarity index 88% rename from internal/form/report.go rename to internal/form/form_report.go index fb8439fa5..f9640f0e3 100644 --- a/internal/form/report.go +++ b/internal/form/form_report.go @@ -5,7 +5,7 @@ import ( "reflect" "time" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // Report returns form fields as table rows for reports. @@ -26,9 +26,12 @@ func Report(f interface{}) (rows [][]string, cols []string) { // Iterate through all form fields. for i := 0; i < v.NumField(); i++ { + // Skip unexported fields. if !v.Type().Field(i).IsExported() { continue } + + // Get info from struct field tags. fieldValue := v.Field(i) fieldName := v.Type().Field(i).Tag.Get("form") fieldInfo := v.Type().Field(i).Tag.Get("serialize") @@ -68,10 +71,10 @@ func Report(f interface{}) (rows [][]string, cols []string) { case bool: typeName = "switch" if example == "" { - example = fmt.Sprintf("%s:yes %s:no", fieldName, fieldName) + example = fmt.Sprintf("%s:yes", fieldName) } default: - log.Warnf("failed reporting on %T %s", t, sanitize.Token(fieldName)) + log.Warnf("failed reporting on %T %s", t, clean.Token(fieldName)) continue } diff --git a/internal/form/serialize.go b/internal/form/serialize.go index 886fe7c92..7ea107c86 100644 --- a/internal/form/serialize.go +++ b/internal/form/serialize.go @@ -8,7 +8,7 @@ import ( "time" "unicode" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/araddon/dateparse" "github.com/photoprism/photoprism/pkg/txt" @@ -70,7 +70,7 @@ func Serialize(f interface{}, all bool) string { q = append(q, fmt.Sprintf("%s:%t", fieldName, fieldValue.Bool())) } default: - log.Warnf("form: failed serializing %T %s", t, sanitize.Token(fieldName)) + log.Warnf("form: failed serializing %T %s", t, clean.Token(fieldName)) } } } @@ -93,7 +93,7 @@ func Unserialize(f SearchForm, q string) (result error) { for _, char := range q { if unicode.IsSpace(char) && !escaped { if isKeyValue { - fieldName := strings.Title(string(key)) + fieldName := txt.UpperFirst(string(key)) field := formValues.FieldByNameFunc(func(name string) bool { return strings.EqualFold(name, fieldName) }) @@ -126,7 +126,7 @@ func Unserialize(f SearchForm, q string) (result error) { field.SetUint(uint64(intValue)) } case string: - field.SetString(sanitize.SearchString(stringValue)) + field.SetString(clean.SearchString(stringValue)) case bool: field.SetBool(txt.Bool(stringValue)) default: @@ -155,7 +155,7 @@ func Unserialize(f SearchForm, q string) (result error) { } if len(queryStrings) > 0 { - f.SetQuery(sanitize.SearchQuery(strings.Join(queryStrings, " "))) + f.SetQuery(clean.SearchQuery(strings.Join(queryStrings, " "))) } if result != nil { diff --git a/internal/form/user.go b/internal/form/user.go index 91e9610cb..b7a69fe33 100644 --- a/internal/form/user.go +++ b/internal/form/user.go @@ -1,6 +1,6 @@ package form -import "github.com/photoprism/photoprism/pkg/sanitize" +import "github.com/photoprism/photoprism/pkg/clean" // UserCreate represents a User with a new password. type UserCreate struct { @@ -12,5 +12,5 @@ type UserCreate struct { // Username returns the normalized username in lowercase and without whitespace padding. func (f UserCreate) Username() string { - return sanitize.Username(f.UserName) + return clean.Username(f.UserName) } diff --git a/internal/hub/config.go b/internal/hub/config.go index 5e6e097f9..60d015831 100644 --- a/internal/hub/config.go +++ b/internal/hub/config.go @@ -19,8 +19,8 @@ import ( "gopkg.in/yaml.v2" "github.com/photoprism/photoprism/internal/hub/places" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // Config represents backend api credentials for maps & geodata. @@ -209,7 +209,7 @@ func (c *Config) Refresh() (err error) { // Load backend api credentials from a YAML file. func (c *Config) Load() error { if !fs.FileExists(c.FileName) { - return fmt.Errorf("settings file not found: %s", sanitize.Log(c.FileName)) + return fmt.Errorf("settings file not found: %s", clean.Log(c.FileName)) } mutex.Lock() diff --git a/internal/hub/places/location.go b/internal/hub/places/location.go index 52664b49e..59858cbf5 100644 --- a/internal/hub/places/location.go +++ b/internal/hub/places/location.go @@ -8,8 +8,8 @@ import ( "strings" "time" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/s2" - "github.com/photoprism/photoprism/pkg/sanitize" "github.com/photoprism/photoprism/pkg/txt" ) @@ -47,7 +47,7 @@ func FindLocation(id string) (result Location, err error) { if len(id) == 0 { return result, fmt.Errorf("empty cell id") } else if n := len(id); n < 4 || n > 16 { - return result, fmt.Errorf("invalid cell id %s", sanitize.Log(id)) + return result, fmt.Errorf("invalid cell id %s", clean.Log(id)) } // Remember start time. @@ -145,7 +145,7 @@ func FindLocation(id string) (result Location, err error) { } cache.SetDefault(id, result) - log.Tracef("places: cached cell %s [%s]", sanitize.Log(id), time.Since(start)) + log.Tracef("places: cached cell %s [%s]", clean.Log(id), time.Since(start)) result.Cached = false @@ -204,7 +204,7 @@ func (l Location) CountryCode() (result string) { // State returns the location address state name. func (l Location) State() (result string) { - return sanitize.State(l.Place.LocState, l.CountryCode()) + return clean.State(l.Place.LocState, l.CountryCode()) } // Latitude returns the location position latitude. diff --git a/internal/maps/location.go b/internal/maps/location.go index 87bac3912..320ee2e61 100644 --- a/internal/maps/location.go +++ b/internal/maps/location.go @@ -5,8 +5,8 @@ import ( "strings" "github.com/photoprism/photoprism/internal/hub/places" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/s2" - "github.com/photoprism/photoprism/pkg/sanitize" "github.com/photoprism/photoprism/pkg/txt" ) @@ -123,7 +123,7 @@ func (l Location) CountryCode() string { } func (l Location) State() string { - return txt.Clip(sanitize.State(l.LocState, l.CountryCode()), 100) + return txt.Clip(clean.State(l.LocState, l.CountryCode()), 100) } func (l Location) CountryName() string { diff --git a/internal/meta/codec.go b/internal/meta/codec.go index f77eb94e3..bc0b39ab8 100644 --- a/internal/meta/codec.go +++ b/internal/meta/codec.go @@ -1,6 +1,8 @@ package meta -import "github.com/photoprism/photoprism/internal/video" +import ( + "github.com/photoprism/photoprism/pkg/video" +) const CodecUnknown = "" const CodecAvc1 = string(video.CodecAVC) diff --git a/internal/meta/data.go b/internal/meta/data.go index da4a8c0db..38c189ce7 100644 --- a/internal/meta/data.go +++ b/internal/meta/data.go @@ -100,12 +100,12 @@ func (data Data) Megapixels() int { // HasDocumentID returns true if a DocumentID exists. func (data Data) HasDocumentID() bool { - return rnd.IsUUID(data.DocumentID) + return rnd.ValidUUID(data.DocumentID) } // HasInstanceID returns true if an InstanceID exists. func (data Data) HasInstanceID() bool { - return rnd.IsUUID(data.InstanceID) + return rnd.ValidUUID(data.InstanceID) } // HasTimeAndPlace if data contains a time and GPS position. diff --git a/internal/meta/report.go b/internal/meta/data_report.go similarity index 84% rename from internal/meta/report.go rename to internal/meta/data_report.go index 8c99b50e0..0b2011ddf 100644 --- a/internal/meta/report.go +++ b/internal/meta/data_report.go @@ -5,7 +5,11 @@ import ( "strings" "time" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/projection" + + "github.com/photoprism/photoprism/pkg/media" + + "github.com/photoprism/photoprism/pkg/clean" ) // Report returns form fields as table rows for reports. @@ -43,7 +47,9 @@ func Report(f interface{}) (rows [][]string, cols []string) { switch t := fieldValue.Interface().(type) { case Keywords: - typeName = "keywords" + typeName = "list" + case projection.Type, media.Type: + typeName = "type" case time.Duration: typeName = "duration" case time.Time: @@ -59,7 +65,7 @@ func Report(f interface{}) (rows [][]string, cols []string) { case bool: typeName = "flag" default: - log.Warnf("failed reporting on %T %s", t, sanitize.Token(fieldName)) + log.Warnf("failed reporting on %T %s", t, clean.Token(fieldName)) continue } diff --git a/internal/meta/exif.go b/internal/meta/exif.go index 84091ab0b..de0456a17 100644 --- a/internal/meta/exif.go +++ b/internal/meta/exif.go @@ -15,9 +15,10 @@ import ( exifcommon "github.com/dsoprea/go-exif/v3/common" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/projection" "github.com/photoprism/photoprism/pkg/rnd" - "github.com/photoprism/photoprism/pkg/sanitize" "github.com/photoprism/photoprism/pkg/txt" ) @@ -36,20 +37,20 @@ func init() { } // Exif parses an image file for Exif metadata and returns as Data struct. -func Exif(fileName string, fileType fs.Format, bruteForce bool) (data Data, err error) { +func Exif(fileName string, fileType fs.Type, bruteForce bool) (data Data, err error) { err = data.Exif(fileName, fileType, bruteForce) return data, err } // Exif parses an image file for Exif metadata and returns as Data struct. -func (data *Data) Exif(fileName string, fileFormat fs.Format, bruteForce bool) (err error) { +func (data *Data) Exif(fileName string, fileFormat fs.Type, bruteForce bool) (err error) { exifMutex.Lock() defer exifMutex.Unlock() defer func() { if e := recover(); e != nil { - err = fmt.Errorf("metadata: %s in %s (exif panic)\nstack: %s", e, sanitize.Log(filepath.Base(fileName)), debug.Stack()) + err = fmt.Errorf("metadata: %s in %s (exif panic)\nstack: %s", e, clean.Log(filepath.Base(fileName)), debug.Stack()) } }() @@ -60,7 +61,7 @@ func (data *Data) Exif(fileName string, fileFormat fs.Format, bruteForce bool) ( return err } - logName := sanitize.Log(filepath.Base(fileName)) + logName := clean.Log(filepath.Base(fileName)) // Enumerate data.exif in Exif block. opt := exif.ScanOptions{} @@ -102,7 +103,7 @@ func (data *Data) Exif(fileName string, fileFormat fs.Format, bruteForce bool) ( data.Lat = float32(gi.Latitude.Decimal()) data.Lng = float32(gi.Longitude.Decimal()) } else if gi.Altitude != 0 || !gi.Timestamp.IsZero() { - log.Warnf("metadata: invalid exif gps coordinates in %s (%s)", logName, sanitize.Log(gi.String())) + log.Warnf("metadata: invalid exif gps coordinates in %s (%s)", logName, clean.Log(gi.String())) } if gi.Altitude != 0 { @@ -281,7 +282,7 @@ func (data *Data) Exif(fileName string, fileFormat fs.Format, bruteForce bool) ( } } - // Valid time found in Exif metadata? + // UniqueID time found in Exif metadata? if !takenAt.IsZero() { if takenAtLocal, err := time.ParseInLocation("2006-01-02T15:04:05", takenAt.Format("2006-01-02T15:04:05"), time.UTC); err == nil { data.TakenAtLocal = takenAtLocal @@ -314,7 +315,7 @@ func (data *Data) Exif(fileName string, fileFormat fs.Format, bruteForce bool) ( if value, ok := data.exif["ProjectionType"]; ok { data.AddKeywords(KeywordPanorama) - data.Projection = SanitizeString(value) + data.Projection = projection.New(SanitizeString(value)).String() } data.Subject = SanitizeMeta(data.Subject) diff --git a/internal/meta/exif_parser.go b/internal/meta/exif_parser.go index a31f5dbd5..72223f680 100644 --- a/internal/meta/exif_parser.go +++ b/internal/meta/exif_parser.go @@ -12,14 +12,14 @@ import ( pngstructure "github.com/dsoprea/go-png-image-structure/v2" tiffstructure "github.com/dsoprea/go-tiff-image-structure/v2" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) -func RawExif(fileName string, fileFormat fs.Format, bruteForce bool) (rawExif []byte, err error) { +func RawExif(fileName string, fileFormat fs.Type, bruteForce bool) (rawExif []byte, err error) { defer func() { if e := recover(); e != nil { - err = fmt.Errorf("%s in %s (raw exif panic)\nstack: %s", e, sanitize.Log(filepath.Base(fileName)), debug.Stack()) + err = fmt.Errorf("%s in %s (raw exif panic)\nstack: %s", e, clean.Log(filepath.Base(fileName)), debug.Stack()) } }() @@ -27,11 +27,11 @@ func RawExif(fileName string, fileFormat fs.Format, bruteForce bool) (rawExif [] var parsed bool // Sanitized and shortened file name for logs. - logName := sanitize.Log(filepath.Base(fileName)) + logName := clean.Log(filepath.Base(fileName)) // Try Exif parser for specific media file format first. switch fileFormat { - case fs.FormatJpeg: + case fs.ImageJPEG: jpegMp := jpegstructure.NewJpegMediaParser() sl, err := jpegMp.ParseFile(fileName) @@ -53,7 +53,7 @@ func RawExif(fileName string, fileFormat fs.Format, bruteForce bool) (rawExif [] parsed = true } } - case fs.FormatPng: + case fs.ImagePNG: pngMp := pngstructure.NewPngMediaParser() cs, err := pngMp.ParseFile(fileName) @@ -73,7 +73,7 @@ func RawExif(fileName string, fileFormat fs.Format, bruteForce bool) (rawExif [] parsed = true } } - case fs.FormatHEIF: + case fs.ImageHEIF: heicMp := heicexif.NewHeicExifMediaParser() cs, err := heicMp.ParseFile(fileName) @@ -93,7 +93,7 @@ func RawExif(fileName string, fileFormat fs.Format, bruteForce bool) (rawExif [] parsed = true } } - case fs.FormatTiff: + case fs.ImageTIFF: tiffMp := tiffstructure.NewTiffMediaParser() cs, err := tiffMp.ParseFile(fileName) diff --git a/internal/meta/exif_test.go b/internal/meta/exif_test.go index 848c8128e..75766a7b1 100644 --- a/internal/meta/exif_test.go +++ b/internal/meta/exif_test.go @@ -3,13 +3,14 @@ package meta import ( "testing" - "github.com/photoprism/photoprism/pkg/fs" "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/pkg/fs" ) func TestExif(t *testing.T) { t.Run("iptc-2014.jpg", func(t *testing.T) { - data, err := Exif("testdata/iptc-2014.jpg", fs.FormatJpeg, true) + data, err := Exif("testdata/iptc-2014.jpg", fs.ImageJPEG, true) if err != nil { t.Fatal(err) @@ -37,7 +38,7 @@ func TestExif(t *testing.T) { }) t.Run("iptc-2016.jpg", func(t *testing.T) { - data, err := Exif("testdata/iptc-2016.jpg", fs.FormatJpeg, true) + data, err := Exif("testdata/iptc-2016.jpg", fs.ImageJPEG, true) if err != nil { t.Fatal(err) @@ -65,7 +66,7 @@ func TestExif(t *testing.T) { }) t.Run("photoshop.jpg", func(t *testing.T) { - data, err := Exif("testdata/photoshop.jpg", fs.FormatJpeg, true) + data, err := Exif("testdata/photoshop.jpg", fs.ImageJPEG, true) if err != nil { t.Fatal(err) @@ -98,7 +99,7 @@ func TestExif(t *testing.T) { }) t.Run("ladybug.jpg", func(t *testing.T) { - data, err := Exif("testdata/ladybug.jpg", fs.FormatJpeg, true) + data, err := Exif("testdata/ladybug.jpg", fs.ImageJPEG, true) if err != nil { t.Fatal(err) @@ -131,7 +132,7 @@ func TestExif(t *testing.T) { }) t.Run("gopro_hd2.jpg", func(t *testing.T) { - data, err := Exif("testdata/gopro_hd2.jpg", fs.FormatJpeg, true) + data, err := Exif("testdata/gopro_hd2.jpg", fs.ImageJPEG, true) if err != nil { t.Fatal(err) @@ -161,7 +162,7 @@ func TestExif(t *testing.T) { }) t.Run("tweethog.png", func(t *testing.T) { - _, err := Exif("testdata/tweethog.png", fs.FormatPng, true) + _, err := Exif("testdata/tweethog.png", fs.ImagePNG, true) if err == nil { t.Fatal("err should NOT be nil") @@ -171,7 +172,7 @@ func TestExif(t *testing.T) { }) t.Run("iphone_7.heic", func(t *testing.T) { - data, err := Exif("testdata/iphone_7.heic", fs.FormatHEIF, true) + data, err := Exif("testdata/iphone_7.heic", fs.ImageHEIF, true) if err != nil { t.Fatal(err) } @@ -192,7 +193,7 @@ func TestExif(t *testing.T) { }) t.Run("gps-2000.jpg", func(t *testing.T) { - data, err := Exif("testdata/gps-2000.jpg", fs.FormatJpeg, true) + data, err := Exif("testdata/gps-2000.jpg", fs.ImageJPEG, true) if err != nil { t.Fatal(err) @@ -220,7 +221,7 @@ func TestExif(t *testing.T) { }) t.Run("image-2011.jpg", func(t *testing.T) { - data, err := Exif("testdata/image-2011.jpg", fs.FormatJpeg, true) + data, err := Exif("testdata/image-2011.jpg", fs.ImageJPEG, true) if err != nil { t.Fatal(err) @@ -257,7 +258,7 @@ func TestExif(t *testing.T) { }) t.Run("ship.jpg", func(t *testing.T) { - data, err := Exif("testdata/ship.jpg", fs.FormatJpeg, true) + data, err := Exif("testdata/ship.jpg", fs.ImageJPEG, true) if err != nil { t.Fatal(err) @@ -279,7 +280,7 @@ func TestExif(t *testing.T) { }) t.Run("no-exif-data.jpg", func(t *testing.T) { - _, err := Exif("testdata/no-exif-data.jpg", fs.FormatJpeg, false) + _, err := Exif("testdata/no-exif-data.jpg", fs.ImageJPEG, false) if err == nil { t.Fatal("err should NOT be nil") @@ -289,7 +290,7 @@ func TestExif(t *testing.T) { }) t.Run("no-exif-data.jpg/BruteForce", func(t *testing.T) { - _, err := Exif("testdata/no-exif-data.jpg", fs.FormatJpeg, true) + _, err := Exif("testdata/no-exif-data.jpg", fs.ImageJPEG, true) if err == nil { t.Fatal("err should NOT be nil") @@ -299,7 +300,7 @@ func TestExif(t *testing.T) { }) t.Run("screenshot.png", func(t *testing.T) { - data, err := Exif("testdata/screenshot.png", fs.FormatPng, true) + data, err := Exif("testdata/screenshot.png", fs.ImagePNG, true) if err != nil { t.Fatal(err) @@ -310,7 +311,7 @@ func TestExif(t *testing.T) { }) t.Run("orientation.jpg", func(t *testing.T) { - data, err := Exif("testdata/orientation.jpg", fs.FormatJpeg, true) + data, err := Exif("testdata/orientation.jpg", fs.ImageJPEG, true) if err != nil { t.Fatal(err) @@ -338,19 +339,19 @@ func TestExif(t *testing.T) { }) t.Run("gopher-preview.jpg", func(t *testing.T) { - _, err := Exif("testdata/gopher-preview.jpg", fs.FormatJpeg, false) + _, err := Exif("testdata/gopher-preview.jpg", fs.ImageJPEG, false) assert.EqualError(t, err, "found no exif header") }) t.Run("gopher-preview.jpg/BruteForce", func(t *testing.T) { - _, err := Exif("testdata/gopher-preview.jpg", fs.FormatJpeg, true) + _, err := Exif("testdata/gopher-preview.jpg", fs.ImageJPEG, true) assert.EqualError(t, err, "found no exif data") }) t.Run("huawei-gps-error.jpg", func(t *testing.T) { - data, err := Exif("testdata/huawei-gps-error.jpg", fs.FormatJpeg, true) + data, err := Exif("testdata/huawei-gps-error.jpg", fs.ImageJPEG, true) if err != nil { t.Fatal(err) @@ -372,7 +373,7 @@ func TestExif(t *testing.T) { }) t.Run("panorama360.jpg", func(t *testing.T) { - data, err := Exif("testdata/panorama360.jpg", fs.FormatJpeg, true) + data, err := Exif("testdata/panorama360.jpg", fs.ImageJPEG, true) if err != nil { t.Fatal(err) @@ -404,7 +405,7 @@ func TestExif(t *testing.T) { }) t.Run("exif-example.tiff", func(t *testing.T) { - data, err := Exif("testdata/exif-example.tiff", fs.FormatTiff, true) + data, err := Exif("testdata/exif-example.tiff", fs.ImageTIFF, true) if err != nil { t.Fatal(err) @@ -436,7 +437,7 @@ func TestExif(t *testing.T) { }) t.Run("out-of-range-500.jpg", func(t *testing.T) { - data, err := Exif("testdata/out-of-range-500.jpg", fs.FormatJpeg, true) + data, err := Exif("testdata/out-of-range-500.jpg", fs.ImageJPEG, true) if err != nil { t.Fatal(err) @@ -468,7 +469,7 @@ func TestExif(t *testing.T) { }) t.Run("digikam.jpg", func(t *testing.T) { - data, err := Exif("testdata/digikam.jpg", fs.FormatJpeg, true) + data, err := Exif("testdata/digikam.jpg", fs.ImageJPEG, true) if err != nil { t.Fatal(err) @@ -503,7 +504,7 @@ func TestExif(t *testing.T) { }) t.Run("notebook.jpg", func(t *testing.T) { - data, err := Exif("testdata/notebook.jpg", fs.FormatJpeg, true) + data, err := Exif("testdata/notebook.jpg", fs.ImageJPEG, true) if err != nil { t.Fatal(err) @@ -524,7 +525,7 @@ func TestExif(t *testing.T) { }) t.Run("snow.jpg", func(t *testing.T) { - data, err := Exif("testdata/snow.jpg", fs.FormatJpeg, true) + data, err := Exif("testdata/snow.jpg", fs.ImageJPEG, true) if err != nil { t.Fatal(err) @@ -545,7 +546,7 @@ func TestExif(t *testing.T) { }) t.Run("keywords.jpg", func(t *testing.T) { - data, err := Exif("testdata/keywords.jpg", fs.FormatJpeg, true) + data, err := Exif("testdata/keywords.jpg", fs.ImageJPEG, true) if err != nil { t.Fatal(err) @@ -565,7 +566,7 @@ func TestExif(t *testing.T) { }) t.Run("Iceland-P3.jpg", func(t *testing.T) { - data, err := Exif("testdata/Iceland-P3.jpg", fs.FormatJpeg, true) + data, err := Exif("testdata/Iceland-P3.jpg", fs.ImageJPEG, true) if err != nil { t.Fatal(err) @@ -597,7 +598,7 @@ func TestExif(t *testing.T) { }) t.Run("Iceland-sRGB.jpg", func(t *testing.T) { - data, err := Exif("testdata/Iceland-sRGB.jpg", fs.FormatJpeg, true) + data, err := Exif("testdata/Iceland-sRGB.jpg", fs.ImageJPEG, true) if err != nil { t.Fatal(err) @@ -628,7 +629,7 @@ func TestExif(t *testing.T) { assert.Equal(t, "", data.ColorProfile) }) t.Run("animated.gif", func(t *testing.T) { - _, err := Exif("testdata/animated.gif", fs.FormatGif, true) + _, err := Exif("testdata/animated.gif", fs.ImageGIF, true) if err == nil { t.Fatal("error expected") diff --git a/internal/meta/gps.go b/internal/meta/gps.go index 0ef8dc984..a6f7ec843 100644 --- a/internal/meta/gps.go +++ b/internal/meta/gps.go @@ -4,7 +4,7 @@ import ( "regexp" "strconv" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/dsoprea/go-exif/v3" ) @@ -23,7 +23,7 @@ func GpsToLatLng(s string) (lat, lng float32) { // Floating point numbers? if fl := GpsFloatRegexp.FindAllString(s, -1); len(fl) == 2 { if lat, err := strconv.ParseFloat(fl[0], 64); err != nil { - log.Infof("metadata: %s is not a valid gps position", sanitize.Log(fl[0])) + log.Infof("metadata: %s is not a valid gps position", clean.Log(fl[0])) } else if lng, err := strconv.ParseFloat(fl[1], 64); err == nil { return float32(lat), float32(lng) } @@ -93,7 +93,7 @@ func ParseFloat(s string) float64 { // Parse floating point number. if result, err := strconv.ParseFloat(s, 64); err != nil { - log.Debugf("metadata: %s is not a valid gps position", sanitize.Log(s)) + log.Debugf("metadata: %s is not a valid gps position", clean.Log(s)) return 0 } else { return result diff --git a/internal/meta/json.go b/internal/meta/json.go index 0ec9f4e4a..3750a7e38 100644 --- a/internal/meta/json.go +++ b/internal/meta/json.go @@ -7,8 +7,8 @@ import ( "path/filepath" "runtime/debug" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // JSON parses a json sidecar file (as used by Exiftool) and returns a Data struct. @@ -22,11 +22,11 @@ func JSON(jsonName, originalName string) (data Data, err error) { func (data *Data) JSON(jsonName, originalName string) (err error) { defer func() { if e := recover(); e != nil { - err = fmt.Errorf("metadata: %s in %s (json panic)\nstack: %s", e, sanitize.Log(filepath.Base(jsonName)), debug.Stack()) + err = fmt.Errorf("metadata: %s in %s (json panic)\nstack: %s", e, clean.Log(filepath.Base(jsonName)), debug.Stack()) } }() - quotedName := sanitize.Log(filepath.Base(jsonName)) + quotedName := clean.Log(filepath.Base(jsonName)) if !fs.FileExists(jsonName) { return fmt.Errorf("metadata: %s not found", quotedName) diff --git a/internal/meta/json_exiftool.go b/internal/meta/json_exiftool.go index 6ed59e19f..75ba45f9f 100644 --- a/internal/meta/json_exiftool.go +++ b/internal/meta/json_exiftool.go @@ -9,8 +9,10 @@ import ( "strings" "time" + "github.com/photoprism/photoprism/pkg/projection" + + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/rnd" - "github.com/photoprism/photoprism/pkg/sanitize" "github.com/photoprism/photoprism/pkg/txt" "github.com/tidwall/gjson" "gopkg.in/photoprism/go-tz.v2/tz" @@ -30,7 +32,7 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) { j := gjson.GetBytes(jsonData, "@flatten|@join") if !j.IsObject() { - return fmt.Errorf("metadata: data is not an object in %s (exiftool)", sanitize.Log(filepath.Base(originalName))) + return fmt.Errorf("metadata: data is not an object in %s (exiftool)", clean.Log(filepath.Base(originalName))) } jsonStrings := make(map[string]string) @@ -41,7 +43,7 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) { } if fileName, ok := jsonStrings["FileName"]; ok && fileName != "" && originalName != "" && fileName != originalName { - return fmt.Errorf("metadata: original name %s does not match %s (exiftool)", sanitize.Log(originalName), sanitize.Log(fileName)) + return fmt.Errorf("metadata: original name %s does not match %s (exiftool)", clean.Log(originalName), clean.Log(fileName)) } v := reflect.ValueOf(data).Elem() @@ -112,6 +114,8 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) { case Keywords: existing := fieldValue.Interface().(Keywords) fieldValue.Set(reflect.ValueOf(txt.AddToWords(existing, strings.TrimSpace(jsonValue.String())))) + case projection.Type: + fieldValue.Set(reflect.ValueOf(projection.Type(strings.TrimSpace(jsonValue.String())))) case string: if !fieldValue.IsZero() { continue @@ -288,7 +292,7 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) { data.InstanceID = rnd.SanitizeUUID(data.InstanceID) } - if data.Projection == "equirectangular" { + if projection.Equirectangular.Equal(data.Projection) { data.AddKeywords(KeywordPanorama) } diff --git a/internal/meta/json_test.go b/internal/meta/json_test.go index f3ecdc19c..06264b8c1 100644 --- a/internal/meta/json_test.go +++ b/internal/meta/json_test.go @@ -4,7 +4,10 @@ import ( "testing" "time" - "github.com/photoprism/photoprism/internal/video" + "github.com/photoprism/photoprism/pkg/video" + + "github.com/photoprism/photoprism/pkg/projection" + "github.com/stretchr/testify/assert" ) @@ -467,7 +470,7 @@ func TestJSON(t *testing.T) { assert.Equal(t, "", data.CameraSerial) assert.Equal(t, 0, data.FocalLength) assert.Equal(t, 1, data.Orientation) - assert.Equal(t, "equirectangular", data.Projection) + assert.Equal(t, projection.Equirectangular.String(), data.Projection) }) t.Run("P7250006.json", func(t *testing.T) { diff --git a/internal/meta/keywords.go b/internal/meta/keywords.go index f96208459..e49dce21c 100644 --- a/internal/meta/keywords.go +++ b/internal/meta/keywords.go @@ -3,6 +3,8 @@ package meta import ( "strings" + "github.com/photoprism/photoprism/pkg/projection" + "github.com/photoprism/photoprism/pkg/txt" ) @@ -11,7 +13,7 @@ const ( KeywordHdr = "hdr" KeywordBurst = "burst" KeywordPanorama = "panorama" - KeywordEquirectangular = "equirectangular" + KeywordEquirectangular = string(projection.Equirectangular) ) // Keywords represents a list of metadata keywords. diff --git a/internal/meta/xmp.go b/internal/meta/xmp.go index 1e906c595..6cf6156c8 100644 --- a/internal/meta/xmp.go +++ b/internal/meta/xmp.go @@ -5,7 +5,7 @@ import ( "path/filepath" "runtime/debug" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // XMP parses an XMP file and returns a Data struct. @@ -19,14 +19,14 @@ func XMP(fileName string) (data Data, err error) { func (data *Data) XMP(fileName string) (err error) { defer func() { if e := recover(); e != nil { - err = fmt.Errorf("metadata: %s in %s (xmp panic)\nstack: %s", e, sanitize.Log(filepath.Base(fileName)), debug.Stack()) + err = fmt.Errorf("metadata: %s in %s (xmp panic)\nstack: %s", e, clean.Log(filepath.Base(fileName)), debug.Stack()) } }() doc := XmpDocument{} if err := doc.Load(fileName); err != nil { - return fmt.Errorf("metadata: cannot read %s (xmp)", sanitize.Log(filepath.Base(fileName))) + return fmt.Errorf("metadata: cannot read %s (xmp)", clean.Log(filepath.Base(fileName))) } if doc.Title() != "" { diff --git a/internal/nsfw/detector.go b/internal/nsfw/detector.go index 188e3948c..910b0bf30 100644 --- a/internal/nsfw/detector.go +++ b/internal/nsfw/detector.go @@ -7,8 +7,8 @@ import ( "path/filepath" "sync" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" tf "github.com/tensorflow/tensorflow/tensorflow/go" "github.com/tensorflow/tensorflow/tensorflow/go/op" ) @@ -30,7 +30,7 @@ func New(modelPath string) *Detector { // File returns matching labels for a jpeg media file. func (t *Detector) File(filename string) (result Labels, err error) { if fs.MimeType(filename) != "image/jpeg" { - return result, fmt.Errorf("nsfw: %s is not a jpeg file", sanitize.Log(filepath.Base(filename))) + return result, fmt.Errorf("nsfw: %s is not a jpeg file", clean.Log(filepath.Base(filename))) } imageBuffer, err := os.ReadFile(filename) @@ -118,7 +118,7 @@ func (t *Detector) loadModel() error { return nil } - log.Infof("nsfw: loading %s", sanitize.Log(filepath.Base(t.modelPath))) + log.Infof("nsfw: loading %s", clean.Log(filepath.Base(t.modelPath))) // Load model model, err := tf.LoadSavedModel(t.modelPath, t.modelTags, nil) diff --git a/internal/photoprism/albums.go b/internal/photoprism/albums.go index f853c29c4..ff433714a 100644 --- a/internal/photoprism/albums.go +++ b/internal/photoprism/albums.go @@ -6,8 +6,8 @@ import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/query" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // BackupAlbums creates a YAML file backup of all albums. @@ -36,7 +36,7 @@ func BackupAlbums(backupPath string, force bool) (count int, result error) { log.Errorf("album: %s (update yaml)", err) result = err } else { - log.Tracef("backup: saved album yaml file %s", sanitize.Log(filepath.Base(fileName))) + log.Tracef("backup: saved album yaml file %s", clean.Log(filepath.Base(fileName))) count++ } } @@ -87,14 +87,14 @@ func RestoreAlbums(backupPath string, force bool) (count int, result error) { a := entity.Album{} if err := a.LoadFromYaml(fileName); err != nil { - log.Errorf("restore: %s in %s", err, sanitize.Log(filepath.Base(fileName))) + log.Errorf("restore: %s in %s", err, clean.Log(filepath.Base(fileName))) result = err } else if a.AlbumType == "" || len(a.Photos) == 0 && a.AlbumFilter == "" { - log.Debugf("restore: skipping %s", sanitize.Log(filepath.Base(fileName))) + log.Debugf("restore: skipping %s", clean.Log(filepath.Base(fileName))) } else if err := a.Find(); err == nil { - log.Infof("%s: %s already exists", a.AlbumType, sanitize.Log(a.AlbumTitle)) + log.Infof("%s: %s already exists", a.AlbumType, clean.Log(a.AlbumTitle)) } else if err := a.Create(); err != nil { - log.Errorf("%s: %s in %s", a.AlbumType, err, sanitize.Log(filepath.Base(fileName))) + log.Errorf("%s: %s in %s", a.AlbumType, err, clean.Log(filepath.Base(fileName))) } else { count++ } diff --git a/internal/photoprism/cleanup.go b/internal/photoprism/cleanup.go index c922e7b1b..8240ce261 100644 --- a/internal/photoprism/cleanup.go +++ b/internal/photoprism/cleanup.go @@ -15,9 +15,9 @@ import ( "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/mutex" "github.com/photoprism/photoprism/internal/query" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fastwalk" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // CleanUp represents a worker that deletes unneeded data and files. @@ -82,7 +82,7 @@ func (w *CleanUp) Start(opt CleanUpOptions) (thumbs int, orphans int, err error) } hash := base[:i] - logName := sanitize.Log(fs.RelName(fileName, thumbPath)) + logName := clean.Log(fs.RelName(fileName, thumbPath)) if ok := fileHashes[hash]; ok { // Do nothing. @@ -119,7 +119,7 @@ func (w *CleanUp) Start(opt CleanUpOptions) (thumbs int, orphans int, err error) if opt.Dry { orphans++ - log.Infof("cleanup: orphan photo %s would be removed", sanitize.Log(p.PhotoUID)) + log.Infof("cleanup: orphan photo %s would be removed", clean.Log(p.PhotoUID)) continue } diff --git a/internal/photoprism/colors.go b/internal/photoprism/colors.go index 7bab280f5..4a2a91e3d 100644 --- a/internal/photoprism/colors.go +++ b/internal/photoprism/colors.go @@ -8,20 +8,20 @@ import ( "github.com/lucasb-eyer/go-colorful" "github.com/photoprism/photoprism/internal/thumb" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/colors" - "github.com/photoprism/photoprism/pkg/sanitize" ) // Colors returns the ColorPerception of an image (only JPEG supported). func (m *MediaFile) Colors(thumbPath string) (perception colors.ColorPerception, err error) { if !m.IsJpeg() { - return perception, fmt.Errorf("%s is not a jpeg", sanitize.Log(m.BaseName())) + return perception, fmt.Errorf("%s is not a jpeg", clean.Log(m.BaseName())) } img, err := m.Resample(thumbPath, thumb.Colors) if err != nil { - log.Debugf("colors: %s in %s (resample)", err, sanitize.Log(m.BaseName())) + log.Debugf("colors: %s in %s (resample)", err, clean.Log(m.BaseName())) return perception, err } diff --git a/internal/photoprism/convert.go b/internal/photoprism/convert.go index 17ca2272d..1ca1f6f56 100644 --- a/internal/photoprism/convert.go +++ b/internal/photoprism/convert.go @@ -12,8 +12,8 @@ import ( "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/mutex" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // Convert represents a converter that can convert RAW/HEIF images to JPEG. @@ -71,7 +71,7 @@ func (c *Convert) Start(path string, force bool) (err error) { } ignore.Log = func(fileName string) { - log.Infof("convert: ignoring %s", sanitize.Log(filepath.Base(fileName))) + log.Infof("convert: ignoring %s", clean.Log(filepath.Base(fileName))) } err = godirwalk.Walk(path, &godirwalk.Options{ diff --git a/internal/photoprism/convert_avc.go b/internal/photoprism/convert_avc.go index 3bfae4201..e19c4852b 100644 --- a/internal/photoprism/convert_avc.go +++ b/internal/photoprism/convert_avc.go @@ -13,8 +13,8 @@ import ( "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/ffmpeg" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // ToAvc converts a single video file to MPEG-4 AVC. @@ -27,7 +27,7 @@ func (c *Convert) ToAvc(f *MediaFile, encoder ffmpeg.AvcEncoder, noMutex, force return nil, fmt.Errorf("convert: %s not found", f.RootRelName()) } - avcName := fs.FormatAVC.FindFirst(f.FileName(), []string{c.conf.SidecarPath(), fs.HiddenPath}, c.conf.OriginalsPath(), false) + avcName := fs.VideoAVC.FindFirst(f.FileName(), []string{c.conf.SidecarPath(), fs.HiddenPath}, c.conf.OriginalsPath(), false) mediaFile, err := NewMediaFile(avcName) @@ -36,7 +36,7 @@ func (c *Convert) ToAvc(f *MediaFile, encoder ffmpeg.AvcEncoder, noMutex, force } if !c.conf.SidecarWritable() { - return nil, fmt.Errorf("convert: transcoding disabled in read only mode (%s)", f.RootRelName()) + return nil, fmt.Errorf("convert: transcoding disabled in read-only mode (%s)", f.RootRelName()) } if c.conf.DisableFFmpeg() { @@ -44,7 +44,7 @@ func (c *Convert) ToAvc(f *MediaFile, encoder ffmpeg.AvcEncoder, noMutex, force } fileName := f.RelName(c.conf.OriginalsPath()) - avcName = fs.FileName(f.FileName(), c.conf.SidecarPath(), c.conf.OriginalsPath(), fs.AvcExt) + avcName = fs.FileName(f.FileName(), c.conf.SidecarPath(), c.conf.OriginalsPath(), fs.ExtAVC) cmd, useMutex, err := c.AvcConvertCommand(f, avcName, encoder) @@ -66,9 +66,9 @@ func (c *Convert) ToAvc(f *MediaFile, encoder ffmpeg.AvcEncoder, noMutex, force } else if !force || !avcFile.InSidecar() { return avcFile, nil } else if err = avcFile.Remove(); err != nil { - return avcFile, fmt.Errorf("convert: failed removing %s (%s)", sanitize.Log(avcFile.RootRelName()), err) + return avcFile, fmt.Errorf("convert: failed removing %s (%s)", clean.Log(avcFile.RootRelName()), err) } else { - log.Infof("convert: replacing %s", sanitize.Log(avcFile.RootRelName())) + log.Infof("convert: replacing %s", clean.Log(avcFile.RootRelName())) } } @@ -85,7 +85,7 @@ func (c *Convert) ToAvc(f *MediaFile, encoder ffmpeg.AvcEncoder, noMutex, force "xmpName": "", }) - log.Infof("%s: transcoding %s to %s", encoder, fileName, fs.FormatAVC) + log.Infof("%s: transcoding %s to %s", encoder, fileName, fs.VideoAVC) // Log exact command for debugging in trace mode. log.Trace(cmd.String()) @@ -109,7 +109,7 @@ func (c *Convert) ToAvc(f *MediaFile, encoder ffmpeg.AvcEncoder, noMutex, force if !fs.FileExists(avcName) { // Do nothing. } else if err = os.Remove(avcName); err != nil { - return nil, fmt.Errorf("convert: failed removing %s (%s)", sanitize.Log(RootRelName(avcName)), err) + return nil, fmt.Errorf("convert: failed removing %s (%s)", clean.Log(RootRelName(avcName)), err) } // Try again using software encoder. @@ -138,9 +138,9 @@ func (c *Convert) AvcConvertCommand(f *MediaFile, avcName string, encoder ffmpeg case bitrate == "": return nil, false, fmt.Errorf("convert: transcoding bitrate is empty - possible bug") case ffmpegBin == "": - return nil, false, fmt.Errorf("convert: ffmpeg must be installed to transcode %s to avc", sanitize.Log(f.BaseName())) + return nil, false, fmt.Errorf("convert: ffmpeg must be installed to transcode %s to avc", clean.Log(f.BaseName())) case !f.IsAnimated(): - return nil, false, fmt.Errorf("convert: file type %s of %s cannot be transcoded to avc", f.FileType(), sanitize.Log(f.BaseName())) + return nil, false, fmt.Errorf("convert: file type %s of %s cannot be transcoded to avc", f.FileType(), clean.Log(f.BaseName())) } return ffmpeg.AvcConvertCommand(fileName, avcName, ffmpegBin, c.AvcBitrate(f), encoder) diff --git a/internal/photoprism/convert_jpeg.go b/internal/photoprism/convert_jpeg.go index a3b77750e..de966a5ce 100644 --- a/internal/photoprism/convert_jpeg.go +++ b/internal/photoprism/convert_jpeg.go @@ -12,8 +12,8 @@ import ( "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/thumb" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // ToJpeg converts a single image file to JPEG if possible. @@ -23,14 +23,14 @@ func (c *Convert) ToJpeg(f *MediaFile, force bool) (*MediaFile, error) { } if !f.Exists() { - return nil, fmt.Errorf("convert: %s not found", sanitize.Log(f.RootRelName())) + return nil, fmt.Errorf("convert: %s not found", clean.Log(f.RootRelName())) } if f.IsJpeg() { return f, nil } - jpegName := fs.FormatJpeg.FindFirst(f.FileName(), []string{c.conf.SidecarPath(), fs.HiddenPath}, c.conf.OriginalsPath(), false) + jpegName := fs.ImageJPEG.FindFirst(f.FileName(), []string{c.conf.SidecarPath(), fs.HiddenPath}, c.conf.OriginalsPath(), false) mediaFile, err := NewMediaFile(jpegName) @@ -38,23 +38,23 @@ func (c *Convert) ToJpeg(f *MediaFile, force bool) (*MediaFile, error) { if err == nil && mediaFile.IsJpeg() { if force && mediaFile.InSidecar() { if err := mediaFile.Remove(); err != nil { - return mediaFile, fmt.Errorf("convert: failed removing %s (%s)", sanitize.Log(mediaFile.RootRelName()), err) + return mediaFile, fmt.Errorf("convert: failed removing %s (%s)", clean.Log(mediaFile.RootRelName()), err) } else { - log.Infof("convert: replacing %s", sanitize.Log(mediaFile.RootRelName())) + log.Infof("convert: replacing %s", clean.Log(mediaFile.RootRelName())) } } else { return mediaFile, nil } } else { - jpegName = fs.FileName(f.FileName(), c.conf.SidecarPath(), c.conf.OriginalsPath(), fs.JpegExt) + jpegName = fs.FileName(f.FileName(), c.conf.SidecarPath(), c.conf.OriginalsPath(), fs.ExtJPEG) } if !c.conf.SidecarWritable() { - return nil, fmt.Errorf("convert: disabled in read only mode (%s)", sanitize.Log(f.RootRelName())) + return nil, fmt.Errorf("convert: disabled in read-only mode (%s)", clean.Log(f.RootRelName())) } fileName := f.RelName(c.conf.OriginalsPath()) - xmpName := fs.FormatXMP.Find(f.FileName(), false) + xmpName := fs.XmpFile.Find(f.FileName(), false) event.Publish("index.converting", event.Data{ "fileType": f.FileType(), @@ -66,7 +66,7 @@ func (c *Convert) ToJpeg(f *MediaFile, force bool) (*MediaFile, error) { start := time.Now() if f.IsImageOther() { - log.Infof("convert: converting %s to %s (%s)", sanitize.Log(filepath.Base(fileName)), sanitize.Log(filepath.Base(jpegName)), f.FileType()) + log.Infof("convert: converting %s to %s (%s)", clean.Log(filepath.Base(fileName)), clean.Log(filepath.Base(jpegName)), f.FileType()) _, err = thumb.Jpeg(f.FileName(), jpegName, f.Orientation()) @@ -74,7 +74,7 @@ func (c *Convert) ToJpeg(f *MediaFile, force bool) (*MediaFile, error) { return nil, err } - log.Infof("convert: %s created in %s (%s)", sanitize.Log(filepath.Base(jpegName)), time.Since(start), f.FileType()) + log.Infof("convert: %s created in %s (%s)", clean.Log(filepath.Base(jpegName)), time.Since(start), f.FileType()) return NewMediaFile(jpegName) } @@ -102,7 +102,7 @@ func (c *Convert) ToJpeg(f *MediaFile, force bool) (*MediaFile, error) { cmd.Stdout = &out cmd.Stderr = &stderr - log.Infof("convert: converting %s to %s (%s)", sanitize.Log(filepath.Base(fileName)), sanitize.Log(filepath.Base(jpegName)), filepath.Base(cmd.Path)) + log.Infof("convert: converting %s to %s (%s)", clean.Log(filepath.Base(fileName)), clean.Log(filepath.Base(jpegName)), filepath.Base(cmd.Path)) // Log exact command for debugging in trace mode. log.Trace(cmd.String()) @@ -116,7 +116,7 @@ func (c *Convert) ToJpeg(f *MediaFile, force bool) (*MediaFile, error) { } } - log.Infof("convert: %s created in %s (%s)", sanitize.Log(filepath.Base(jpegName)), time.Since(start), filepath.Base(cmd.Path)) + log.Infof("convert: %s created in %s (%s)", clean.Log(filepath.Base(jpegName)), time.Since(start), filepath.Base(cmd.Path)) return NewMediaFile(jpegName) } diff --git a/internal/photoprism/convert_json.go b/internal/photoprism/convert_json.go index cc36d2e25..3abfde330 100644 --- a/internal/photoprism/convert_json.go +++ b/internal/photoprism/convert_json.go @@ -8,8 +8,8 @@ import ( "os/exec" "path/filepath" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // ToJson uses exiftool to export metadata to a json file. @@ -28,7 +28,7 @@ func (c *Convert) ToJson(f *MediaFile) (jsonName string, err error) { return jsonName, nil } - log.Debugf("exiftool: extracting metadata from %s", sanitize.Log(f.RootRelName())) + log.Debugf("exiftool: extracting metadata from %s", clean.Log(f.RootRelName())) cmd := exec.Command(c.conf.ExifToolBin(), "-n", "-m", "-api", "LargeFileSupport", "-j", f.FileName()) diff --git a/internal/photoprism/convert_worker.go b/internal/photoprism/convert_worker.go index af57e4dd7..9503c4428 100644 --- a/internal/photoprism/convert_worker.go +++ b/internal/photoprism/convert_worker.go @@ -3,7 +3,7 @@ package photoprism import ( "strings" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) type ConvertJob struct { @@ -15,7 +15,7 @@ type ConvertJob struct { func ConvertWorker(jobs <-chan ConvertJob) { logError := func(err error, job ConvertJob) { fileName := job.file.RelName(job.convert.conf.OriginalsPath()) - log.Errorf("convert: %s for %s", strings.TrimSpace(err.Error()), sanitize.Log(fileName)) + log.Errorf("convert: %s for %s", strings.TrimSpace(err.Error()), clean.Log(fileName)) } for job := range jobs { diff --git a/internal/photoprism/delete.go b/internal/photoprism/delete.go index b146b2e1e..c779ec4b4 100644 --- a/internal/photoprism/delete.go +++ b/internal/photoprism/delete.go @@ -5,8 +5,8 @@ import ( "path/filepath" "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // Delete permanently removes a photo and all its files. @@ -24,16 +24,16 @@ func Delete(p entity.Photo) error { for _, file := range files { fileName := FileName(file.FileRoot, file.FileName) - log.Debugf("delete: removing file %s", sanitize.Log(file.FileName)) + log.Debugf("delete: removing file %s", clean.Log(file.FileName)) if f, err := NewMediaFile(fileName); err == nil { if sidecarJson := f.SidecarJsonName(); fs.FileExists(sidecarJson) { - log.Debugf("delete: removing json sidecar %s", sanitize.Log(filepath.Base(sidecarJson))) + log.Debugf("delete: removing json sidecar %s", clean.Log(filepath.Base(sidecarJson))) logWarn("delete", os.Remove(sidecarJson)) } if exifJson, err := f.ExifToolJsonName(); err == nil && fs.FileExists(exifJson) { - log.Debugf("delete: removing exiftool sidecar %s", sanitize.Log(filepath.Base(exifJson))) + log.Debugf("delete: removing exiftool sidecar %s", clean.Log(filepath.Base(exifJson))) logWarn("delete", os.Remove(exifJson)) } @@ -47,7 +47,7 @@ func Delete(p entity.Photo) error { // Remove sidecar backup. if fs.FileExists(yamlFileName) { - log.Debugf("delete: removing yaml sidecar %s", sanitize.Log(filepath.Base(yamlFileName))) + log.Debugf("delete: removing yaml sidecar %s", clean.Log(filepath.Base(yamlFileName))) logWarn("delete", os.Remove(yamlFileName)) } diff --git a/internal/photoprism/faces_audit.go b/internal/photoprism/faces_audit.go index 6d858d1b7..38e4f81e3 100644 --- a/internal/photoprism/faces_audit.go +++ b/internal/photoprism/faces_audit.go @@ -6,7 +6,7 @@ import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/face" "github.com/photoprism/photoprism/internal/query" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // Audit face clusters and subjects. @@ -161,7 +161,7 @@ func (w *Faces) Audit(fix bool) (err error) { } else if m.SubjUID != "" { log.Infof("faces: marker %s with %s subject %s (%s) conflicts with face %s (%s) of subject %s (%s)", m.MarkerUID, entity.SrcString(m.SubjSrc), entity.SubjNames.Log(m.SubjUID), m.SubjUID, m.FaceID, entity.SrcString(f.FaceSrc), entity.SubjNames.Log(f.SubjUID), f.SubjUID) } else if m.MarkerName != "" { - log.Infof("faces: marker %s with %s subject name %s conflicts with face %s (%s) of subject %s (%s)", m.MarkerUID, entity.SrcString(m.SubjSrc), sanitize.Log(m.MarkerName), m.FaceID, entity.SrcString(f.FaceSrc), entity.SubjNames.Log(f.SubjUID), f.SubjUID) + log.Infof("faces: marker %s with %s subject name %s conflicts with face %s (%s) of subject %s (%s)", m.MarkerUID, entity.SrcString(m.SubjSrc), clean.Log(m.MarkerName), m.FaceID, entity.SrcString(f.FaceSrc), entity.SubjNames.Log(f.SubjUID), f.SubjUID) } else { log.Infof("faces: marker %s with unknown subject (%s) conflicts with face %s (%s) of subject %s (%s)", m.MarkerUID, entity.SrcString(m.SubjSrc), m.FaceID, entity.SrcString(f.FaceSrc), entity.SubjNames.Log(f.SubjUID), f.SubjUID) } diff --git a/internal/photoprism/import.go b/internal/photoprism/import.go index 74f73f961..e83d77373 100644 --- a/internal/photoprism/import.go +++ b/internal/photoprism/import.go @@ -11,14 +11,16 @@ import ( "strings" "sync" + "github.com/photoprism/photoprism/pkg/media" + "github.com/karrick/godirwalk" "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/mutex" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // Import represents an importer that can copy/move MediaFiles to the originals directory. @@ -150,7 +152,7 @@ func (imp *Import) Start(opt ImportOptions) fs.Done { done[fileName] = fs.Found - if !fs.IsMedia(fileName) { + if !media.MainFile(fileName) { return nil } @@ -164,7 +166,7 @@ func (imp *Import) Start(opt ImportOptions) fs.Done { // Ignore RAW images? if mf.IsRaw() && skipRaw { - log.Infof("import: skipped raw %s", sanitize.Log(mf.RootRelName())) + log.Infof("import: skipped raw %s", clean.Log(mf.RootRelName())) return nil } @@ -218,9 +220,9 @@ func (imp *Import) Start(opt ImportOptions) fs.Done { for _, directory := range directories { if fs.IsEmpty(directory) { if err := os.Remove(directory); err != nil { - log.Errorf("import: failed deleting empty folder %s (%s)", sanitize.Log(fs.RelName(directory, importPath)), err) + log.Errorf("import: failed deleting empty folder %s (%s)", clean.Log(fs.RelName(directory, importPath)), err) } else { - log.Infof("import: deleted empty folder %s", sanitize.Log(fs.RelName(directory, importPath))) + log.Infof("import: deleted empty folder %s", clean.Log(fs.RelName(directory, importPath))) } } } @@ -234,7 +236,7 @@ func (imp *Import) Start(opt ImportOptions) fs.Done { } if err := os.Remove(file); err != nil { - log.Errorf("import: failed removing %s (%s)", sanitize.Log(fs.RelName(file, importPath)), err.Error()) + log.Errorf("import: failed removing %s (%s)", clean.Log(fs.RelName(file, importPath)), err.Error()) } } } @@ -277,7 +279,7 @@ func (imp *Import) DestinationFilename(mainFile *MediaFile, mediaFile *MediaFile if f, err := entity.FirstFileByHash(mediaFile.Hash()); err == nil { existingFilename := FileName(f.FileRoot, f.FileName) if fs.FileExists(existingFilename) { - return existingFilename, fmt.Errorf("%s is identical to %s (sha1 %s)", sanitize.Log(filepath.Base(mediaFile.FileName())), sanitize.Log(f.FileName), mediaFile.Hash()) + return existingFilename, fmt.Errorf("%s is identical to %s (sha1 %s)", clean.Log(filepath.Base(mediaFile.FileName())), clean.Log(f.FileName), mediaFile.Hash()) } else { return existingFilename, nil } @@ -293,7 +295,7 @@ func (imp *Import) DestinationFilename(mainFile *MediaFile, mediaFile *MediaFile for fs.FileExists(result) { if mediaFile.Hash() == fs.Hash(result) { - return result, fmt.Errorf("%s already exists", sanitize.Log(fs.RelName(result, imp.originalsPath()))) + return result, fmt.Errorf("%s already exists", clean.Log(fs.RelName(result, imp.originalsPath()))) } iteration++ diff --git a/internal/photoprism/import_worker.go b/internal/photoprism/import_worker.go index e12a643c1..bb88c4b60 100644 --- a/internal/photoprism/import_worker.go +++ b/internal/photoprism/import_worker.go @@ -8,8 +8,8 @@ import ( "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/query" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) type ImportJob struct { @@ -31,16 +31,16 @@ func ImportWorker(jobs <-chan ImportJob) { related := job.Related if related.Main == nil { - log.Warnf("import: %s belongs to no supported media file", sanitize.Log(fs.RelName(job.FileName, impPath))) + log.Warnf("import: %s belongs to no supported media file", clean.Log(fs.RelName(job.FileName, impPath))) continue } // Extract metadata to a JSON file with Exiftool. if related.Main.NeedsExifToolJson() { if jsonName, err := imp.convert.ToJson(related.Main); err != nil { - log.Debugf("import: %s in %s (extract metadata)", sanitize.Log(err.Error()), sanitize.Log(related.Main.BaseName())) + log.Debugf("import: %s in %s (extract metadata)", clean.Log(err.Error()), clean.Log(related.Main.BaseName())) } else if err := related.Main.ReadExifToolJson(); err != nil { - log.Errorf("import: %s in %s (read metadata)", sanitize.Log(err.Error()), sanitize.Log(related.Main.BaseName())) + log.Errorf("import: %s in %s (read metadata)", clean.Log(err.Error()), clean.Log(related.Main.BaseName())) } else { log.Debugf("import: created %s", filepath.Base(jsonName)) } @@ -62,7 +62,7 @@ func ImportWorker(jobs <-chan ImportJob) { if fs.PathExists(destDir) { // Do nothing. } else if err := os.MkdirAll(destDir, os.ModePerm); err != nil { - log.Errorf("import: failed creating folder for %s (%s)", sanitize.Log(f.BaseName()), err.Error()) + log.Errorf("import: failed creating folder for %s (%s)", clean.Log(f.BaseName()), err.Error()) } else { destDirRel := fs.RelName(destDir, imp.originalsPath()) @@ -75,20 +75,20 @@ func ImportWorker(jobs <-chan ImportJob) { if related.Main.HasSameName(f) { destMainFileName = destFileName - log.Infof("import: moving main %s file %s to %s", f.FileType(), sanitize.Log(relFileName), sanitize.Log(fs.RelName(destFileName, imp.originalsPath()))) + log.Infof("import: moving main %s file %s to %s", f.FileType(), clean.Log(relFileName), clean.Log(fs.RelName(destFileName, imp.originalsPath()))) } else { - log.Infof("import: moving related %s file %s to %s", f.FileType(), sanitize.Log(relFileName), sanitize.Log(fs.RelName(destFileName, imp.originalsPath()))) + log.Infof("import: moving related %s file %s to %s", f.FileType(), clean.Log(relFileName), clean.Log(fs.RelName(destFileName, imp.originalsPath()))) } if impOpt.Move { if err := f.Move(destFileName); err != nil { - logRelName := sanitize.Log(fs.RelName(destMainFileName, imp.originalsPath())) + logRelName := clean.Log(fs.RelName(destMainFileName, imp.originalsPath())) log.Debugf("import: %s", err.Error()) log.Warnf("import: failed moving file to %s, is another import running at the same time?", logRelName) } } else { if err := f.Copy(destFileName); err != nil { - logRelName := sanitize.Log(fs.RelName(destMainFileName, imp.originalsPath())) + logRelName := clean.Log(fs.RelName(destMainFileName, imp.originalsPath())) log.Debugf("import: %s", err.Error()) log.Warnf("import: failed copying file to %s, is another import running at the same time?", logRelName) } @@ -108,9 +108,9 @@ func ImportWorker(jobs <-chan ImportJob) { // Remove duplicates to save storage. if impOpt.RemoveExistingFiles { if err := f.Remove(); err != nil { - log.Errorf("import: failed deleting %s (%s)", sanitize.Log(f.BaseName()), err.Error()) + log.Errorf("import: failed deleting %s (%s)", clean.Log(f.BaseName()), err.Error()) } else { - log.Infof("import: deleted %s (already exists)", sanitize.Log(relFileName)) + log.Infof("import: deleted %s (already exists)", clean.Log(relFileName)) } } } @@ -120,14 +120,14 @@ func ImportWorker(jobs <-chan ImportJob) { f, err := NewMediaFile(destMainFileName) if err != nil { - log.Errorf("import: %s in %s", err.Error(), sanitize.Log(fs.RelName(destMainFileName, imp.originalsPath()))) + log.Errorf("import: %s in %s", err.Error(), clean.Log(fs.RelName(destMainFileName, imp.originalsPath()))) continue } // Extract metadata to a JSON file with Exiftool. if f.NeedsExifToolJson() { if jsonName, err := imp.convert.ToJson(f); err != nil { - log.Debugf("import: %s in %s (extract metadata)", sanitize.Log(err.Error()), sanitize.Log(f.RootRelName())) + log.Debugf("import: %s in %s (extract metadata)", clean.Log(err.Error()), clean.Log(f.RootRelName())) } else { log.Debugf("import: created %s", filepath.Base(jsonName)) } @@ -136,10 +136,10 @@ func ImportWorker(jobs <-chan ImportJob) { // Create JPEG sidecar for media files in other formats so that thumbnails can be created. if o.Convert && f.IsMedia() && !f.HasJpeg() { if jpegFile, err := imp.convert.ToJpeg(f, false); err != nil { - log.Errorf("import: %s in %s (convert to jpeg)", err.Error(), sanitize.Log(f.RootRelName())) + log.Errorf("import: %s in %s (convert to jpeg)", err.Error(), clean.Log(f.RootRelName())) continue } else { - log.Debugf("import: created %s", sanitize.Log(jpegFile.BaseName())) + log.Debugf("import: created %s", clean.Log(jpegFile.BaseName())) } } @@ -147,10 +147,10 @@ func ImportWorker(jobs <-chan ImportJob) { if jpg, err := f.Jpeg(); err != nil { log.Error(err) } else if exceeds, actual := jpg.ExceedsResolution(o.ResolutionLimit); exceeds { - log.Errorf("index: %s exceeds resolution limit (%d / %d MP)", sanitize.Log(f.RootRelName()), actual, o.ResolutionLimit) + log.Errorf("index: %s exceeds resolution limit (%d / %d MP)", clean.Log(f.RootRelName()), actual, o.ResolutionLimit) continue } else if err := jpg.CreateThumbnails(imp.thumbPath(), false); err != nil { - log.Errorf("import: failed creating thumbnails for %s (%s)", sanitize.Log(f.RootRelName()), err.Error()) + log.Errorf("import: failed creating thumbnails for %s (%s)", clean.Log(f.RootRelName()), err.Error()) continue } @@ -159,7 +159,7 @@ func ImportWorker(jobs <-chan ImportJob) { // Skip import if the finding related files results in an error. if err != nil { - log.Errorf("import: %s in %s (find related files)", err.Error(), sanitize.Log(fs.RelName(destMainFileName, imp.originalsPath()))) + log.Errorf("import: %s in %s (find related files)", err.Error(), clean.Log(fs.RelName(destMainFileName, imp.originalsPath()))) continue } @@ -172,10 +172,10 @@ func ImportWorker(jobs <-chan ImportJob) { // Enforce file size and resolution limits. if exceeds, actual := f.ExceedsFileSize(o.OriginalsLimit); exceeds { - log.Warnf("import: %s exceeds file size limit (%d / %d MB)", sanitize.Log(f.RootRelName()), actual, o.OriginalsLimit) + log.Warnf("import: %s exceeds file size limit (%d / %d MB)", clean.Log(f.RootRelName()), actual, o.OriginalsLimit) continue } else if exceeds, actual = f.ExceedsResolution(o.ResolutionLimit); exceeds { - log.Warnf("import: %s exceeds resolution limit (%d / %d MP)", sanitize.Log(f.RootRelName()), actual, o.ResolutionLimit) + log.Warnf("import: %s exceeds resolution limit (%d / %d MP)", clean.Log(f.RootRelName()), actual, o.ResolutionLimit) continue } @@ -183,7 +183,7 @@ func ImportWorker(jobs <-chan ImportJob) { res := ind.MediaFile(f, o, originalName, "") // Log result. - log.Infof("import: %s main %s file %s", res, f.FileType(), sanitize.Log(f.RootRelName())) + log.Infof("import: %s main %s file %s", res, f.FileType(), clean.Log(f.RootRelName())) done[f.FileName()] = true if !res.Success() { @@ -198,7 +198,7 @@ func ImportWorker(jobs <-chan ImportJob) { } } } else { - log.Warnf("import: found no main file for %s, conversion to jpeg may have failed", sanitize.Log(f.RootRelName())) + log.Warnf("import: found no main file for %s, conversion to jpeg may have failed", clean.Log(f.RootRelName())) } for _, f := range related.Files { @@ -214,15 +214,15 @@ func ImportWorker(jobs <-chan ImportJob) { // Show warning if sidecar file exceeds size or resolution limit. if exceeds, actual := f.ExceedsFileSize(o.OriginalsLimit); exceeds { - log.Warnf("import: sidecar file %s exceeds size limit (%d / %d MB)", sanitize.Log(f.RootRelName()), actual, o.OriginalsLimit) + log.Warnf("import: sidecar file %s exceeds size limit (%d / %d MB)", clean.Log(f.RootRelName()), actual, o.OriginalsLimit) } else if exceeds, actual = f.ExceedsResolution(o.ResolutionLimit); exceeds { - log.Warnf("import: sidecar file %s exceeds resolution limit (%d / %d MP)", sanitize.Log(f.RootRelName()), actual, o.ResolutionLimit) + log.Warnf("import: sidecar file %s exceeds resolution limit (%d / %d MP)", clean.Log(f.RootRelName()), actual, o.ResolutionLimit) } // Extract metadata to a JSON file with Exiftool. if f.NeedsExifToolJson() { if jsonName, err := imp.convert.ToJson(f); err != nil { - log.Debugf("import: %s in %s (extract metadata)", sanitize.Log(err.Error()), sanitize.Log(f.RootRelName())) + log.Debugf("import: %s in %s (extract metadata)", clean.Log(err.Error()), clean.Log(f.RootRelName())) } else { log.Debugf("import: created %s", filepath.Base(jsonName)) } @@ -237,7 +237,7 @@ func ImportWorker(jobs <-chan ImportJob) { } // Log result. - log.Infof("import: %s related %s file %s", res, f.FileType(), sanitize.Log(f.RootRelName())) + log.Infof("import: %s related %s file %s", res, f.FileType(), clean.Log(f.RootRelName())) } } diff --git a/internal/photoprism/index.go b/internal/photoprism/index.go index 1bd4f8f47..758e1bfc6 100644 --- a/internal/photoprism/index.go +++ b/internal/photoprism/index.go @@ -9,6 +9,8 @@ import ( "strings" "sync" + "github.com/photoprism/photoprism/pkg/media" + "github.com/karrick/godirwalk" "github.com/photoprism/photoprism/internal/classify" @@ -18,8 +20,8 @@ import ( "github.com/photoprism/photoprism/internal/face" "github.com/photoprism/photoprism/internal/mutex" "github.com/photoprism/photoprism/internal/nsfw" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // Index represents an indexer that indexes files in the originals directory. @@ -89,7 +91,7 @@ func (ind *Index) Start(o IndexOptions) fs.Done { optionsPath := filepath.Join(originalsPath, o.Path) if !fs.PathExists(optionsPath) { - event.Error(fmt.Sprintf("index: %s does not exist", sanitize.Log(optionsPath))) + event.Error(fmt.Sprintf("index: %s does not exist", clean.Log(optionsPath))) return done } @@ -171,7 +173,7 @@ func (ind *Index) Start(o IndexOptions) fs.Done { done[fileName] = fs.Found - if !fs.IsMedia(fileName) { + if !media.MainFile(fileName) { return nil } @@ -185,7 +187,7 @@ func (ind *Index) Start(o IndexOptions) fs.Done { // Ignore RAW images? if mf.IsRaw() && skipRaw { - log.Infof("index: skipped raw %s", sanitize.Log(mf.RootRelName())) + log.Infof("index: skipped raw %s", clean.Log(mf.RootRelName())) return nil } diff --git a/internal/photoprism/index_faces.go b/internal/photoprism/index_faces.go index 60de2f423..060d1d1a7 100644 --- a/internal/photoprism/index_faces.go +++ b/internal/photoprism/index_faces.go @@ -7,7 +7,7 @@ import ( "github.com/photoprism/photoprism/internal/face" "github.com/photoprism/photoprism/internal/thumb" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // Faces finds faces in JPEG media files and returns them. @@ -28,12 +28,12 @@ func (ind *Index) Faces(jpeg *MediaFile, expected int) face.Faces { thumbName, err := jpeg.Thumbnail(Config().ThumbPath(), thumbSize) if err != nil { - log.Debugf("index: %s in %s (faces)", err, sanitize.Log(jpeg.BaseName())) + log.Debugf("index: %s in %s (faces)", err, clean.Log(jpeg.BaseName())) return face.Faces{} } if thumbName == "" { - log.Debugf("index: thumb %s not found in %s (faces)", thumbSize, sanitize.Log(jpeg.BaseName())) + log.Debugf("index: thumb %s not found in %s (faces)", thumbSize, clean.Log(jpeg.BaseName())) return face.Faces{} } @@ -42,11 +42,11 @@ func (ind *Index) Faces(jpeg *MediaFile, expected int) face.Faces { faces, err := ind.faceNet.Detect(thumbName, Config().FaceSize(), true, expected) if err != nil { - log.Debugf("%s in %s", err, sanitize.Log(jpeg.BaseName())) + log.Debugf("%s in %s", err, clean.Log(jpeg.BaseName())) } if l := len(faces); l > 0 { - log.Infof("index: found %s in %s [%s]", english.Plural(l, "face", "faces"), sanitize.Log(jpeg.BaseName()), time.Since(start)) + log.Infof("index: found %s in %s [%s]", english.Plural(l, "face", "faces"), clean.Log(jpeg.BaseName()), time.Since(start)) } return faces diff --git a/internal/photoprism/index_labels.go b/internal/photoprism/index_labels.go index 3b0f00200..214e47845 100644 --- a/internal/photoprism/index_labels.go +++ b/internal/photoprism/index_labels.go @@ -6,7 +6,7 @@ import ( "github.com/photoprism/photoprism/internal/classify" "github.com/photoprism/photoprism/internal/thumb" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // Labels classifies a JPEG image and returns matching labels. @@ -27,14 +27,14 @@ func (ind *Index) Labels(jpeg *MediaFile) (results classify.Labels) { filename, err := jpeg.Thumbnail(Config().ThumbPath(), size) if err != nil { - log.Debugf("%s in %s", err, sanitize.Log(jpeg.BaseName())) + log.Debugf("%s in %s", err, clean.Log(jpeg.BaseName())) continue } imageLabels, err := ind.tensorFlow.File(filename) if err != nil { - log.Debugf("%s in %s", err, sanitize.Log(jpeg.BaseName())) + log.Debugf("%s in %s", err, clean.Log(jpeg.BaseName())) continue } @@ -57,9 +57,9 @@ func (ind *Index) Labels(jpeg *MediaFile) (results classify.Labels) { } if l := len(labels); l == 1 { - log.Infof("index: matched %d label with %s [%s]", l, sanitize.Log(jpeg.BaseName()), time.Since(start)) + log.Infof("index: matched %d label with %s [%s]", l, clean.Log(jpeg.BaseName()), time.Since(start)) } else if l > 1 { - log.Infof("index: matched %d labels with %s [%s]", l, sanitize.Log(jpeg.BaseName()), time.Since(start)) + log.Infof("index: matched %d labels with %s [%s]", l, clean.Log(jpeg.BaseName()), time.Since(start)) } return results diff --git a/internal/photoprism/index_main.go b/internal/photoprism/index_main.go index 008c17490..c4e8eab13 100644 --- a/internal/photoprism/index_main.go +++ b/internal/photoprism/index_main.go @@ -6,14 +6,14 @@ import ( "github.com/photoprism/photoprism/internal/query" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // IndexMain indexes the main file from a group of related files and returns the result. func IndexMain(related *RelatedFiles, ind *Index, o IndexOptions) (result IndexResult) { // Skip if main file is nil. if related.Main == nil { - result.Err = fmt.Errorf("index: no main file for %s", sanitize.Log(related.String())) + result.Err = fmt.Errorf("index: no main file for %s", clean.Log(related.String())) result.Status = IndexFailed return result } @@ -22,11 +22,11 @@ func IndexMain(related *RelatedFiles, ind *Index, o IndexOptions) (result IndexR // Enforce file size and resolution limits. if exceeds, actual := f.ExceedsFileSize(o.OriginalsLimit); exceeds { - result.Err = fmt.Errorf("index: %s exceeds file size limit (%d / %d MB)", sanitize.Log(f.RootRelName()), actual, o.OriginalsLimit) + result.Err = fmt.Errorf("index: %s exceeds file size limit (%d / %d MB)", clean.Log(f.RootRelName()), actual, o.OriginalsLimit) result.Status = IndexFailed return result } else if exceeds, actual = f.ExceedsResolution(o.ResolutionLimit); exceeds { - result.Err = fmt.Errorf("index: %s exceeds resolution limit (%d / %d MP)", sanitize.Log(f.RootRelName()), actual, o.ResolutionLimit) + result.Err = fmt.Errorf("index: %s exceeds resolution limit (%d / %d MP)", clean.Log(f.RootRelName()), actual, o.ResolutionLimit) result.Status = IndexFailed return result } @@ -34,7 +34,7 @@ func IndexMain(related *RelatedFiles, ind *Index, o IndexOptions) (result IndexR // Extract metadata to a JSON file with Exiftool. if f.NeedsExifToolJson() { if jsonName, err := ind.convert.ToJson(f); err != nil { - log.Debugf("index: %s in %s (extract metadata)", sanitize.Log(err.Error()), sanitize.Log(f.RootRelName())) + log.Debugf("index: %s in %s (extract metadata)", clean.Log(err.Error()), clean.Log(f.RootRelName())) } else { log.Debugf("index: created %s", filepath.Base(jsonName)) } @@ -43,18 +43,18 @@ func IndexMain(related *RelatedFiles, ind *Index, o IndexOptions) (result IndexR // Create JPEG sidecar for media files in other formats so that thumbnails can be created. if o.Convert && f.IsMedia() && !f.HasJpeg() { if jpg, err := ind.convert.ToJpeg(f, false); err != nil { - result.Err = fmt.Errorf("index: failed converting %s to jpeg (%s)", sanitize.Log(f.RootRelName()), err.Error()) + result.Err = fmt.Errorf("index: failed converting %s to jpeg (%s)", clean.Log(f.RootRelName()), err.Error()) result.Status = IndexFailed return result } else if exceeds, actual := jpg.ExceedsResolution(o.ResolutionLimit); exceeds { - result.Err = fmt.Errorf("index: %s exceeds resolution limit (%d / %d MP)", sanitize.Log(f.RootRelName()), actual, o.ResolutionLimit) + result.Err = fmt.Errorf("index: %s exceeds resolution limit (%d / %d MP)", clean.Log(f.RootRelName()), actual, o.ResolutionLimit) result.Status = IndexFailed return result } else { - log.Debugf("index: created %s", sanitize.Log(jpg.BaseName())) + log.Debugf("index: created %s", clean.Log(jpg.BaseName())) if err := jpg.CreateThumbnails(ind.thumbPath(), false); err != nil { - result.Err = fmt.Errorf("index: failed creating thumbnails for %s (%s)", sanitize.Log(f.RootRelName()), err.Error()) + result.Err = fmt.Errorf("index: failed creating thumbnails for %s (%s)", clean.Log(f.RootRelName()), err.Error()) result.Status = IndexFailed return result } @@ -77,12 +77,12 @@ func IndexMain(related *RelatedFiles, ind *Index, o IndexOptions) (result IndexR log.Error(result.Err) if exists { - log.Errorf("index: %s updating main %s file %s", result, f.FileType(), sanitize.Log(f.RootRelName())) + log.Errorf("index: %s updating main %s file %s", result, f.FileType(), clean.Log(f.RootRelName())) } else { - log.Errorf("index: %s adding main %s file %s", result, f.FileType(), sanitize.Log(f.RootRelName())) + log.Errorf("index: %s adding main %s file %s", result, f.FileType(), clean.Log(f.RootRelName())) } } else { - log.Infof("index: %s main %s file %s", result, f.FileType(), sanitize.Log(f.RootRelName())) + log.Infof("index: %s main %s file %s", result, f.FileType(), clean.Log(f.RootRelName())) } return result diff --git a/internal/photoprism/index_mediafile.go b/internal/photoprism/index_mediafile.go index 52672c59a..a9737ed3a 100644 --- a/internal/photoprism/index_mediafile.go +++ b/internal/photoprism/index_mediafile.go @@ -15,8 +15,8 @@ import ( "github.com/photoprism/photoprism/internal/meta" "github.com/photoprism/photoprism/internal/query" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" "github.com/photoprism/photoprism/pkg/txt" ) @@ -55,7 +55,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName, photoUID fileRoot, fileBase, filePath, fileName := m.PathNameInfo(stripSequence) fullBase := m.BasePrefix(false) - logName := sanitize.Log(fileName) + logName := clean.Log(fileName) fileSize, modTime, err := m.Stat() if err != nil { @@ -163,7 +163,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName, photoUID if o.Stack { // Same unique ID? if photoQuery.Error != nil && Config().Settings().StackUUID() && m.MetaData().HasDocumentID() { - photoQuery = entity.UnscopedDb().First(&photo, "uuid <> '' AND uuid = ?", sanitize.Log(m.MetaData().DocumentID)) + photoQuery = entity.UnscopedDb().First(&photo, "uuid <> '' AND uuid = ?", clean.Log(m.MetaData().DocumentID)) if photoQuery.Error == nil { // Found. @@ -201,19 +201,19 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName, photoUID // Detect and report changed photo UID. if photoExists && photoUID != "" && photoUID != file.PhotoUID { fileChanged = true - log.Debugf("index: %s has new photo uid %s", sanitize.Log(m.BaseName()), photoUID) + log.Debugf("index: %s has new photo uid %s", clean.Log(m.BaseName()), photoUID) } // Detect and report file changes. if fileRenamed { fileChanged = true - log.Debugf("index: %s was renamed", sanitize.Log(m.BaseName())) + log.Debugf("index: %s was renamed", clean.Log(m.BaseName())) } else if file.Changed(fileSize, modTime) { fileChanged = true - log.Debugf("index: %s was modified (new size %d, old size %d, new timestamp %d, old timestamp %d)", sanitize.Log(m.BaseName()), fileSize, file.FileSize, modTime.Unix(), file.ModTime) + log.Debugf("index: %s was modified (new size %d, old size %d, new timestamp %d, old timestamp %d)", clean.Log(m.BaseName()), fileSize, file.FileSize, modTime.Unix(), file.ModTime) } else if file.Missing() { fileChanged = true - log.Debugf("index: %s was missing", sanitize.Log(m.BaseName())) + log.Debugf("index: %s was missing", clean.Log(m.BaseName())) } } @@ -237,7 +237,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName, photoUID // Create default thumbnails if needed. if err := m.CreateThumbnails(ind.thumbPath(), false); err != nil { - result.Err = fmt.Errorf("index: failed creating thumbnails for %s (%s)", sanitize.Log(m.RootRelName()), err.Error()) + result.Err = fmt.Errorf("index: failed creating thumbnails for %s (%s)", clean.Log(m.RootRelName()), err.Error()) result.Status = IndexFailed return result } @@ -253,14 +253,14 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName, photoUID photo.PhotoStack = entity.IsStackable } - if yamlName := fs.FormatYaml.FindFirst(m.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), stripSequence); yamlName != "" { + if yamlName := fs.YamlFile.FindFirst(m.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), stripSequence); yamlName != "" { if err := photo.LoadFromYaml(yamlName); err != nil { log.Errorf("index: %s in %s (restore from yaml)", err.Error(), logName) } else if err := photo.Find(); err != nil { - log.Infof("index: %s restored from %s", sanitize.Log(m.BaseName()), sanitize.Log(filepath.Base(yamlName))) + log.Infof("index: %s restored from %s", clean.Log(m.BaseName()), clean.Log(filepath.Base(yamlName))) } else { photoExists = true - log.Infof("index: uid %s restored from %s", photo.PhotoUID, sanitize.Log(filepath.Base(yamlName))) + log.Infof("index: uid %s restored from %s", photo.PhotoUID, clean.Log(filepath.Base(yamlName))) } } } @@ -393,7 +393,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName, photoUID if metaData := m.MetaData(); metaData.Error == nil { file.FileCodec = metaData.Codec - file.SetMetaUTC(metaData.TakenAt) + file.SetMediaUTC(metaData.TakenAt) file.SetFrames(metaData.Frames) file.SetProjection(metaData.Projection) file.SetHDR(metaData.IsHDR()) @@ -408,7 +408,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName, photoUID } if metaData.HasInstanceID() { - log.Infof("index: %s has instance_id %s", logName, sanitize.Log(metaData.InstanceID)) + log.Infof("index: %s has instance_id %s", logName, clean.Log(metaData.InstanceID)) file.InstanceID = metaData.InstanceID } @@ -452,13 +452,13 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName, photoUID details.SetSoftware(metaData.Software, entity.SrcMeta) if metaData.HasDocumentID() && photo.UUID == "" { - log.Infof("index: %s has document_id %s", logName, sanitize.Log(metaData.DocumentID)) + log.Infof("index: %s has document_id %s", logName, clean.Log(metaData.DocumentID)) photo.UUID = metaData.DocumentID } if metaData.HasInstanceID() { - log.Infof("index: %s has instance_id %s", logName, sanitize.Log(metaData.InstanceID)) + log.Infof("index: %s has instance_id %s", logName, clean.Log(metaData.InstanceID)) file.InstanceID = metaData.InstanceID } @@ -475,7 +475,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName, photoUID file.FileHeight = m.Height() file.FileAspectRatio = m.AspectRatio() file.FilePortrait = m.Portrait() - file.SetMetaUTC(metaData.TakenAt) + file.SetMediaUTC(metaData.TakenAt) file.SetDuration(metaData.Duration) file.SetFPS(metaData.FPS) file.SetFrames(metaData.Frames) @@ -521,13 +521,13 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName, photoUID details.SetSoftware(metaData.Software, entity.SrcMeta) if metaData.HasDocumentID() && photo.UUID == "" { - log.Infof("index: %s has document_id %s", logName, sanitize.Log(metaData.DocumentID)) + log.Infof("index: %s has document_id %s", logName, clean.Log(metaData.DocumentID)) photo.UUID = metaData.DocumentID } if metaData.HasInstanceID() { - log.Infof("index: %s has instance_id %s", logName, sanitize.Log(metaData.InstanceID)) + log.Infof("index: %s has instance_id %s", logName, clean.Log(metaData.InstanceID)) file.InstanceID = metaData.InstanceID } @@ -544,7 +544,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName, photoUID file.FileHeight = m.Height() file.FileAspectRatio = m.AspectRatio() file.FilePortrait = m.Portrait() - file.SetMetaUTC(metaData.TakenAt) + file.SetMediaUTC(metaData.TakenAt) file.SetDuration(metaData.Duration) file.SetFPS(metaData.FPS) file.SetFrames(metaData.Frames) @@ -640,7 +640,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName, photoUID details.SetSoftware(metaData.Software, entity.SrcMeta) if metaData.HasDocumentID() && photo.UUID == "" { - log.Debugf("index: %s has document_id %s", logName, sanitize.Log(metaData.DocumentID)) + log.Debugf("index: %s has document_id %s", logName, clean.Log(metaData.DocumentID)) photo.UUID = metaData.DocumentID } @@ -676,14 +676,14 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName, photoUID // Set remaining file properties. file.FileSidecar = m.IsSidecar() file.FileVideo = m.IsVideo() - file.FileType = string(m.FileType()) - file.MediaType = string(m.MediaType()) + file.FileType = m.FileType().String() + file.MediaType = m.Media().String() file.FileMime = m.MimeType() file.FileOrientation = m.Orientation() file.ModTime = modTime.Unix() // Detect ICC color profile for JPEGs if still unknown at this point. - if file.FileColorProfile == "" && file.FileType == string(fs.FormatJpeg) { + if file.FileColorProfile == "" && fs.ImageJPEG.Equal(file.FileType) { file.SetColorProfile(m.ColorProfile()) } @@ -868,7 +868,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName, photoUID if err := photo.SaveAsYaml(yamlFile); err != nil { log.Errorf("index: %s in %s (update yaml)", err.Error(), logName) } else { - log.Debugf("index: updated yaml file %s", sanitize.Log(filepath.Base(yamlFile))) + log.Debugf("index: updated yaml file %s", clean.Log(filepath.Base(yamlFile))) } } diff --git a/internal/photoprism/index_nsfw.go b/internal/photoprism/index_nsfw.go index 3e67ab8f0..099f2b68c 100644 --- a/internal/photoprism/index_nsfw.go +++ b/internal/photoprism/index_nsfw.go @@ -3,7 +3,7 @@ package photoprism import ( "github.com/photoprism/photoprism/internal/nsfw" "github.com/photoprism/photoprism/internal/thumb" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // NSFW returns true if media file might be offensive and detection is enabled. @@ -20,7 +20,7 @@ func (ind *Index) NSFW(m *MediaFile) bool { return false } else { if nsfwLabels.NSFW(nsfw.ThresholdHigh) { - log.Warnf("index: %s might contain offensive content", sanitize.Log(m.RelName(Config().OriginalsPath()))) + log.Warnf("index: %s might contain offensive content", clean.Log(m.RelName(Config().OriginalsPath()))) return true } } diff --git a/internal/photoprism/index_related.go b/internal/photoprism/index_related.go index 8a36d4e1f..dc77e75eb 100644 --- a/internal/photoprism/index_related.go +++ b/internal/photoprism/index_related.go @@ -8,14 +8,14 @@ import ( "github.com/photoprism/photoprism/internal/query" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // IndexRelated indexes a group of related files and returns the result. func IndexRelated(related RelatedFiles, ind *Index, o IndexOptions) (result IndexResult) { // Skip if main file is nil. if related.Main == nil { - result.Err = fmt.Errorf("index: no main file for %s", sanitize.Log(related.String())) + result.Err = fmt.Errorf("index: no main file for %s", clean.Log(related.String())) result.Status = IndexFailed return result } @@ -59,15 +59,15 @@ func IndexRelated(related RelatedFiles, ind *Index, o IndexOptions) (result Inde // Show warning if sidecar file exceeds size or resolution limit. if exceeds, actual := f.ExceedsFileSize(o.OriginalsLimit); exceeds { - log.Warnf("index: sidecar file %s exceeds size limit (%d / %d MB)", sanitize.Log(f.RootRelName()), actual, o.OriginalsLimit) + log.Warnf("index: sidecar file %s exceeds size limit (%d / %d MB)", clean.Log(f.RootRelName()), actual, o.OriginalsLimit) } else if exceeds, actual = f.ExceedsResolution(o.ResolutionLimit); exceeds { - log.Warnf("index: sidecar file %s exceeds resolution limit (%d / %d MP)", sanitize.Log(f.RootRelName()), actual, o.ResolutionLimit) + log.Warnf("index: sidecar file %s exceeds resolution limit (%d / %d MP)", clean.Log(f.RootRelName()), actual, o.ResolutionLimit) } // Extract metadata to a JSON file with Exiftool. if f.NeedsExifToolJson() { if jsonName, err := ind.convert.ToJson(f); err != nil { - log.Debugf("index: %s in %s (extract metadata)", sanitize.Log(err.Error()), sanitize.Log(f.RootRelName())) + log.Debugf("index: %s in %s (extract metadata)", clean.Log(err.Error()), clean.Log(f.RootRelName())) } else { log.Debugf("index: created %s", filepath.Base(jsonName)) } @@ -76,14 +76,14 @@ func IndexRelated(related RelatedFiles, ind *Index, o IndexOptions) (result Inde // Create JPEG sidecar for media files in other formats so that thumbnails can be created. if o.Convert && f.IsMedia() && !f.HasJpeg() { if jpg, err := ind.convert.ToJpeg(f, false); err != nil { - result.Err = fmt.Errorf("index: failed converting %s to jpeg (%s)", sanitize.Log(f.RootRelName()), err.Error()) + result.Err = fmt.Errorf("index: failed converting %s to jpeg (%s)", clean.Log(f.RootRelName()), err.Error()) result.Status = IndexFailed return result } else { - log.Debugf("index: created %s", sanitize.Log(jpg.BaseName())) + log.Debugf("index: created %s", clean.Log(jpg.BaseName())) if err := jpg.CreateThumbnails(ind.thumbPath(), false); err != nil { - result.Err = fmt.Errorf("index: failed creating thumbnails for %s (%s)", sanitize.Log(f.RootRelName()), err.Error()) + result.Err = fmt.Errorf("index: failed creating thumbnails for %s (%s)", clean.Log(f.RootRelName()), err.Error()) result.Status = IndexFailed return result } @@ -101,7 +101,7 @@ func IndexRelated(related RelatedFiles, ind *Index, o IndexOptions) (result Inde } // Log index result. - log.Infof("index: %s related %s file %s", res, f.FileType(), sanitize.Log(f.RootRelName())) + log.Infof("index: %s related %s file %s", res, f.FileType(), clean.Log(f.RootRelName())) } return result diff --git a/internal/photoprism/index_related_test.go b/internal/photoprism/index_related_test.go index 1bd752508..a6bf6d962 100644 --- a/internal/photoprism/index_related_test.go +++ b/internal/photoprism/index_related_test.go @@ -30,7 +30,7 @@ func TestIndexRelated(t *testing.T) { t.Fatal(err) } - testToken := rnd.Token(8) + testToken := rnd.GenerateToken(8) testPath := filepath.Join(conf.OriginalsPath(), testToken) for _, f := range testRelated.Files { @@ -91,7 +91,7 @@ func TestIndexRelated(t *testing.T) { t.Fatal(err) } - testToken := rnd.Token(8) + testToken := rnd.GenerateToken(8) testPath := filepath.Join(conf.OriginalsPath(), testToken) for _, f := range testRelated.Files { diff --git a/internal/photoprism/mediafile.go b/internal/photoprism/mediafile.go index 6def86b1e..5d8e5cd40 100644 --- a/internal/photoprism/mediafile.go +++ b/internal/photoprism/mediafile.go @@ -18,6 +18,8 @@ import ( "sync" "time" + "github.com/photoprism/photoprism/pkg/media" + _ "golang.org/x/image/bmp" _ "golang.org/x/image/tiff" _ "golang.org/x/image/webp" @@ -32,8 +34,8 @@ import ( "github.com/photoprism/photoprism/internal/thumb" "github.com/photoprism/photoprism/pkg/capture" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" "github.com/photoprism/photoprism/pkg/txt" ) @@ -44,7 +46,7 @@ type MediaFile struct { statErr error modTime time.Time fileSize int64 - fileType fs.Format + fileType fs.Type mimeType string takenAt time.Time takenAtSrc string @@ -67,7 +69,7 @@ func NewMediaFile(fileName string) (*MediaFile, error) { m := &MediaFile{ fileName: fileName, fileRoot: entity.RootUnknown, - fileType: fs.FormatOther, + fileType: fs.UnknownType, metaData: meta.NewData(), width: -1, height: -1, @@ -75,9 +77,9 @@ func NewMediaFile(fileName string) (*MediaFile, error) { // Check if file exists and is not empty. if size, _, err := m.Stat(); err != nil { - return m, fmt.Errorf("%s not found", sanitize.Log(m.RootRelName())) + return m, fmt.Errorf("%s not found", clean.Log(m.RootRelName())) } else if size == 0 { - return m, fmt.Errorf("%s is empty", sanitize.Log(m.RootRelName())) + return m, fmt.Errorf("%s is empty", clean.Log(m.RootRelName())) } return m, nil @@ -137,7 +139,7 @@ func (m *MediaFile) TakenAt() (time.Time, string) { m.takenAt = data.TakenAt.UTC() m.takenAtSrc = entity.SrcMeta - log.Infof("media: %s was taken at %s (%s)", sanitize.Log(filepath.Base(m.fileName)), m.takenAt.String(), m.takenAtSrc) + log.Infof("media: %s was taken at %s (%s)", clean.Log(filepath.Base(m.fileName)), m.takenAt.String(), m.takenAtSrc) return m.takenAt, m.takenAtSrc } @@ -146,7 +148,7 @@ func (m *MediaFile) TakenAt() (time.Time, string) { m.takenAt = nameTime m.takenAtSrc = entity.SrcName - log.Infof("media: %s was taken at %s (%s)", sanitize.Log(filepath.Base(m.fileName)), m.takenAt.String(), m.takenAtSrc) + log.Infof("media: %s was taken at %s (%s)", clean.Log(filepath.Base(m.fileName)), m.takenAt.String(), m.takenAtSrc) return m.takenAt, m.takenAtSrc } @@ -157,17 +159,17 @@ func (m *MediaFile) TakenAt() (time.Time, string) { if err != nil { log.Warnf("media: %s (file stat)", err.Error()) - log.Infof("media: %s was taken at %s (now)", sanitize.Log(filepath.Base(m.fileName)), m.takenAt.String()) + log.Infof("media: %s was taken at %s (now)", clean.Log(filepath.Base(m.fileName)), m.takenAt.String()) return m.takenAt, m.takenAtSrc } if fileInfo.HasBirthTime() { m.takenAt = fileInfo.BirthTime().UTC() - log.Infof("media: %s was taken at %s (file birth time)", sanitize.Log(filepath.Base(m.fileName)), m.takenAt.String()) + log.Infof("media: %s was taken at %s (file birth time)", clean.Log(filepath.Base(m.fileName)), m.takenAt.String()) } else { m.takenAt = fileInfo.ModTime().UTC() - log.Infof("media: %s was taken at %s (file mod time)", sanitize.Log(filepath.Base(m.fileName)), m.takenAt.String()) + log.Infof("media: %s was taken at %s (file mod time)", clean.Log(filepath.Base(m.fileName)), m.takenAt.String()) } return m.takenAt, m.takenAtSrc @@ -340,7 +342,7 @@ func (m *MediaFile) RelatedFiles(stripSequence bool) (result RelatedFiles, err e // Ignore RAW images? if f.IsRaw() && skipRaw { - log.Debugf("media: skipped related raw file %s", sanitize.Log(f.RootRelName())) + log.Debugf("media: skipped related raw file %s", clean.Log(f.RootRelName())) continue } @@ -371,12 +373,12 @@ func (m *MediaFile) RelatedFiles(stripSequence bool) (result RelatedFiles, err e t = "unknown type" } - return result, fmt.Errorf("no supported files found for %s (%s)", sanitize.Log(m.BaseName()), t) + return result, fmt.Errorf("no supported files found for %s (%s)", clean.Log(m.BaseName()), t) } // Add hidden JPEG if exists. if !result.ContainsJpeg() { - if jpegName := fs.FormatJpeg.FindFirst(result.Main.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), stripSequence); jpegName != "" { + if jpegName := fs.ImageJPEG.FindFirst(result.Main.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), stripSequence); jpegName != "" { if resultFile, err := NewMediaFile(jpegName); err == nil { result.Files = append(result.Files, resultFile) } @@ -688,7 +690,7 @@ func (m *MediaFile) IsGif() bool { // IsTiff returns true if this is a TIFF image. func (m *MediaFile) IsTiff() bool { - return m.HasFileType(fs.FormatTiff) && m.MimeType() == fs.MimeTypeTiff + return m.HasFileType(fs.ImageTIFF) && m.MimeType() == fs.MimeTypeTiff } // IsHEIF returns true if this is a High Efficiency Image File Format image. @@ -708,7 +710,7 @@ func (m *MediaFile) IsWebP() bool { // IsVideo returns true if this is a video file. func (m *MediaFile) IsVideo() bool { - return strings.HasPrefix(m.MimeType(), "video/") || m.MediaType() == fs.MediaVideo + return strings.HasPrefix(m.MimeType(), "video/") || m.Media() == media.Video } // IsAnimatedGif returns true if it is an animated GIF. @@ -723,35 +725,35 @@ func (m *MediaFile) IsAnimated() bool { // IsJson return true if this media file is a json sidecar file. func (m *MediaFile) IsJson() bool { - return m.HasFileType(fs.FormatJson) + return m.HasFileType(fs.JsonFile) } // FileType returns the file type (jpg, gif, tiff,...). -func (m *MediaFile) FileType() fs.Format { +func (m *MediaFile) FileType() fs.Type { switch { case m.IsJpeg(): - return fs.FormatJpeg + return fs.ImageJPEG case m.IsPng(): - return fs.FormatPng + return fs.ImagePNG case m.IsGif(): - return fs.FormatGif + return fs.ImageGIF case m.IsHEIF(): - return fs.FormatHEIF + return fs.ImageHEIF case m.IsBitmap(): - return fs.FormatBitmap + return fs.ImageBMP default: - return fs.FileFormat(m.fileName) + return fs.FileType(m.fileName) } } -// MediaType returns the media type (video, image, raw, sidecar,...). -func (m *MediaFile) MediaType() fs.MediaType { - return fs.GetMediaType(m.fileName) +// Media returns the media content type (video, image, raw, sidecar,...). +func (m *MediaFile) Media() media.Type { + return media.FromName(m.fileName) } // HasFileType returns true if this is the given type. -func (m *MediaFile) HasFileType(fileType fs.Format) bool { - if fileType == fs.FormatJpeg { +func (m *MediaFile) HasFileType(fileType fs.Type) bool { + if fileType == fs.ImageJPEG { return m.IsJpeg() } @@ -760,12 +762,12 @@ func (m *MediaFile) HasFileType(fileType fs.Format) bool { // IsRaw returns true if this is a RAW file. func (m *MediaFile) IsRaw() bool { - return m.HasFileType(fs.FormatRaw) + return m.HasFileType(fs.RawImage) } // IsXMP returns true if this is a XMP sidecar file. func (m *MediaFile) IsXMP() bool { - return m.FileType() == fs.FormatXMP + return m.FileType() == fs.XmpFile } // InOriginals checks if the file is stored in the 'originals' folder. @@ -780,12 +782,12 @@ func (m *MediaFile) InSidecar() bool { // IsSidecar checks if the file is a metadata sidecar file, independent of the storage location. func (m *MediaFile) IsSidecar() bool { - return m.MediaType() == fs.MediaSidecar + return m.Media() == media.Sidecar } // IsPlayableVideo checks if the file is a video in playable format. func (m *MediaFile) IsPlayableVideo() bool { - return m.IsVideo() && (m.HasFileType(fs.FormatMp4) || m.HasFileType(fs.FormatAVC)) + return m.IsVideo() && (m.HasFileType(fs.VideoMP4) || m.HasFileType(fs.VideoAVC)) } // IsImageOther returns true if this is a PNG, GIF, BMP, TIFF, or WebP file. @@ -811,11 +813,11 @@ func (m *MediaFile) IsImage() bool { // IsLive checks if the file is a live photo. func (m *MediaFile) IsLive() bool { if m.IsHEIF() { - return fs.FormatMov.FindFirst(m.FileName(), []string{}, Config().OriginalsPath(), false) != "" + return fs.VideoMOV.FindFirst(m.FileName(), []string{}, Config().OriginalsPath(), false) != "" } if m.IsVideo() { - return fs.FormatHEIF.FindFirst(m.FileName(), []string{}, Config().OriginalsPath(), false) != "" + return fs.ImageHEIF.FindFirst(m.FileName(), []string{}, Config().OriginalsPath(), false) != "" } return false @@ -841,7 +843,7 @@ func (m *MediaFile) Jpeg() (*MediaFile, error) { return m, nil } - jpegFilename := fs.FormatJpeg.FindFirst(m.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), false) + jpegFilename := fs.ImageJPEG.FindFirst(m.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), false) if jpegFilename == "" { return nil, fmt.Errorf("no jpeg found for %s", m.FileName()) @@ -861,7 +863,7 @@ func (m *MediaFile) HasJpeg() bool { return true } - jpegName := fs.FormatJpeg.FindFirst(m.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), false) + jpegName := fs.ImageJPEG.FindFirst(m.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), false) if jpegName == "" { m.hasJpeg = false @@ -874,7 +876,7 @@ func (m *MediaFile) HasJpeg() bool { func (m *MediaFile) decodeDimensions() error { if !m.IsMedia() { - return fmt.Errorf("failed decoding dimensions of %s file", sanitize.Log(m.Extension())) + return fmt.Errorf("failed decoding dimensions of %s file", clean.Log(m.Extension())) } // Media dimensions already known? @@ -918,7 +920,7 @@ func (m *MediaFile) decodeDimensions() error { func (m *MediaFile) DecodeConfig() (_ *image.Config, err error) { defer func() { if r := recover(); r != nil { - err = fmt.Errorf("panic %s while decoding %s dimensions\nstack: %s", r, sanitize.Log(m.Extension()), debug.Stack()) + err = fmt.Errorf("panic %s while decoding %s dimensions\nstack: %s", r, clean.Log(m.Extension()), debug.Stack()) } }() @@ -927,7 +929,7 @@ func (m *MediaFile) DecodeConfig() (_ *image.Config, err error) { } if !m.IsImageNative() { - return nil, fmt.Errorf("%s not supported natively", sanitize.Log(m.Extension())) + return nil, fmt.Errorf("%s not supported natively", clean.Log(m.Extension())) } m.fileMutex.Lock() @@ -1066,7 +1068,7 @@ func (m *MediaFile) Thumbnail(path string, sizeName thumb.Name) (filename string thumbnail, err := thumb.FromFile(m.FileName(), m.Hash(), path, size.Width, size.Height, m.Orientation(), size.Options...) if err != nil { - err = fmt.Errorf("media: failed creating thumbnail for %s (%s)", sanitize.Log(m.BaseName()), err) + err = fmt.Errorf("media: failed creating thumbnail for %s (%s)", clean.Log(m.BaseName()), err) log.Debug(err) return "", err } @@ -1120,7 +1122,7 @@ func (m *MediaFile) CreateThumbnails(thumbPath string, force bool) (err error) { } if fileName, err := thumb.FileName(hash, thumbPath, size.Width, size.Height, size.Options...); err != nil { - log.Errorf("media: failed creating %s (%s)", sanitize.Log(string(name)), err) + log.Errorf("media: failed creating %s (%s)", clean.Log(string(name)), err) return err } else { @@ -1132,7 +1134,7 @@ func (m *MediaFile) CreateThumbnails(thumbPath string, force bool) (err error) { img, err := thumb.Open(m.FileName(), m.Orientation()) if err != nil { - log.Debugf("media: %s in %s", err.Error(), sanitize.Log(m.BaseName())) + log.Debugf("media: %s in %s", err.Error(), clean.Log(m.BaseName())) return err } @@ -1151,7 +1153,7 @@ func (m *MediaFile) CreateThumbnails(thumbPath string, force bool) (err error) { } if err != nil { - log.Errorf("media: failed creating %s (%s)", sanitize.Log(string(name)), err) + log.Errorf("media: failed creating %s (%s)", clean.Log(string(name)), err) return err } @@ -1186,9 +1188,9 @@ func (m *MediaFile) RenameSidecars(oldFileName string) (renamed map[string]strin renamed[fs.RelName(srcName, sidecarPath)] = fs.RelName(destName, sidecarPath) if err := os.Remove(srcName); err != nil { - log.Errorf("media: failed removing sidecar %s", sanitize.Log(fs.RelName(srcName, sidecarPath))) + log.Errorf("media: failed removing sidecar %s", clean.Log(fs.RelName(srcName, sidecarPath))) } else { - log.Infof("media: removed sidecar %s", sanitize.Log(fs.RelName(srcName, sidecarPath))) + log.Infof("media: removed sidecar %s", clean.Log(fs.RelName(srcName, sidecarPath))) } continue @@ -1197,7 +1199,7 @@ func (m *MediaFile) RenameSidecars(oldFileName string) (renamed map[string]strin if err := fs.Move(srcName, destName); err != nil { return renamed, err } else { - log.Infof("media: moved existing sidecar to %s", sanitize.Log(newName+filepath.Ext(srcName))) + log.Infof("media: moved existing sidecar to %s", clean.Log(newName+filepath.Ext(srcName))) renamed[fs.RelName(srcName, sidecarPath)] = fs.RelName(destName, sidecarPath) } } @@ -1222,9 +1224,9 @@ func (m *MediaFile) RemoveSidecars() (err error) { for _, sidecarName := range matches { if err = os.Remove(sidecarName); err != nil { - log.Errorf("media: failed removing sidecar %s", sanitize.Log(fs.RelName(sidecarName, sidecarPath))) + log.Errorf("media: failed removing sidecar %s", clean.Log(fs.RelName(sidecarName, sidecarPath))) } else { - log.Infof("media: removed sidecar %s", sanitize.Log(fs.RelName(sidecarName, sidecarPath))) + log.Infof("media: removed sidecar %s", clean.Log(fs.RelName(sidecarName, sidecarPath))) } } @@ -1238,7 +1240,7 @@ func (m *MediaFile) ColorProfile() string { } start := time.Now() - logName := sanitize.Log(m.BaseName()) + logName := clean.Log(m.BaseName()) m.fileMutex.Lock() defer m.fileMutex.Unlock() @@ -1265,7 +1267,7 @@ func (m *MediaFile) ColorProfile() string { if iccProfile, err := md.ICCProfile(); err != nil || iccProfile == nil { // Do nothing. } else if profile, err := iccProfile.Description(); err == nil && profile != "" { - log.Debugf("media: %s has color profile %s [%s]", logName, sanitize.Log(profile), time.Since(start)) + log.Debugf("media: %s has color profile %s [%s]", logName, clean.Log(profile), time.Since(start)) m.colorProfile = profile return m.colorProfile } diff --git a/internal/photoprism/mediafile_meta.go b/internal/photoprism/mediafile_meta.go index 8123f3090..c2a651587 100644 --- a/internal/photoprism/mediafile_meta.go +++ b/internal/photoprism/mediafile_meta.go @@ -7,8 +7,8 @@ import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/meta" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // HasSidecarJson returns true if this file has or is a json sidecar file. @@ -17,7 +17,7 @@ func (m *MediaFile) HasSidecarJson() bool { return true } - return fs.FormatJson.FindFirst(m.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), false) != "" + return fs.JsonFile.FindFirst(m.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), false) != "" } // SidecarJsonName returns the corresponding JSON sidecar file name as used by Google Photos (and potentially other apps). @@ -79,8 +79,8 @@ func (m *MediaFile) MetaData() (result meta.Data) { // Parse regular JSON sidecar files ("img_1234.json") if !m.IsSidecar() { - if jsonFiles := fs.FormatJson.FindAll(m.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), false); len(jsonFiles) == 0 { - log.Tracef("metadata: found no additional sidecar file for %s", sanitize.Log(filepath.Base(m.FileName()))) + if jsonFiles := fs.JsonFile.FindAll(m.FileName(), []string{Config().SidecarPath(), fs.HiddenPath}, Config().OriginalsPath(), false); len(jsonFiles) == 0 { + log.Tracef("metadata: found no additional sidecar file for %s", clean.Log(filepath.Base(m.FileName()))) } else { for _, jsonFile := range jsonFiles { jsonErr := m.metaData.JSON(jsonFile, m.BaseName()) @@ -102,7 +102,7 @@ func (m *MediaFile) MetaData() (result meta.Data) { if err != nil { m.metaData.Error = err - log.Debugf("metadata: %s in %s", err, sanitize.Log(m.BaseName())) + log.Debugf("metadata: %s in %s", err, clean.Log(m.BaseName())) } }) diff --git a/internal/photoprism/mediafile_meta_test.go b/internal/photoprism/mediafile_meta_test.go index 0b61a7348..3564c6590 100644 --- a/internal/photoprism/mediafile_meta_test.go +++ b/internal/photoprism/mediafile_meta_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "testing" + "github.com/photoprism/photoprism/pkg/projection" + "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/meta" "github.com/stretchr/testify/assert" @@ -230,7 +232,7 @@ func TestMediaFile_Exif_JPEG(t *testing.T) { assert.Equal(t, "", data.CameraSerial) assert.Equal(t, 6, data.FocalLength) assert.Equal(t, 1, data.Orientation) - assert.Equal(t, "equirectangular", data.Projection) + assert.Equal(t, projection.Equirectangular.String(), data.Projection) }) t.Run("digikam.jpg", func(t *testing.T) { diff --git a/internal/photoprism/mediafile_test.go b/internal/photoprism/mediafile_test.go index ebd9c5a53..5e71af0f1 100644 --- a/internal/photoprism/mediafile_test.go +++ b/internal/photoprism/mediafile_test.go @@ -1006,7 +1006,7 @@ func TestMediaFile_Extension(t *testing.T) { if err != nil { t.Fatal(err) } - assert.Equal(t, fs.JpegExt, mediaFile.Extension()) + assert.Equal(t, fs.ExtJPEG, mediaFile.Extension()) }) } @@ -1153,7 +1153,7 @@ func TestMediaFile_IsPng(t *testing.T) { t.Fatal(err) } - assert.Equal(t, fs.FormatPng, mediaFile.FileType()) + assert.Equal(t, fs.ImagePNG, mediaFile.FileType()) assert.Equal(t, "image/png", mediaFile.MimeType()) assert.Equal(t, true, mediaFile.IsPng()) }) @@ -1167,7 +1167,7 @@ func TestMediaFile_IsTiff(t *testing.T) { if err != nil { t.Fatal(err) } - assert.Equal(t, fs.FormatJson, mediaFile.FileType()) + assert.Equal(t, fs.JsonFile, mediaFile.FileType()) assert.Equal(t, "", mediaFile.MimeType()) assert.Equal(t, false, mediaFile.IsTiff()) }) @@ -1176,7 +1176,7 @@ func TestMediaFile_IsTiff(t *testing.T) { if err != nil { t.Fatal(err) } - assert.Equal(t, fs.FormatTiff, mediaFile.FileType()) + assert.Equal(t, fs.ImageTIFF, mediaFile.FileType()) assert.Equal(t, "image/tiff", mediaFile.MimeType()) assert.Equal(t, true, mediaFile.IsTiff()) }) @@ -1185,7 +1185,7 @@ func TestMediaFile_IsTiff(t *testing.T) { if err != nil { t.Fatal(err) } - assert.Equal(t, fs.FormatTiff, mediaFile.FileType()) + assert.Equal(t, fs.ImageTIFF, mediaFile.FileType()) assert.Equal(t, "image/tiff", mediaFile.MimeType()) assert.Equal(t, true, mediaFile.IsTiff()) }) @@ -1229,7 +1229,7 @@ func TestMediaFile_IsImageOther(t *testing.T) { if err != nil { t.Fatal(err) } - assert.Equal(t, fs.FormatBitmap, mediaFile.FileType()) + assert.Equal(t, fs.ImageBMP, mediaFile.FileType()) assert.Equal(t, "image/bmp", mediaFile.MimeType()) assert.Equal(t, false, mediaFile.IsJpeg()) assert.Equal(t, false, mediaFile.IsGif()) @@ -1247,7 +1247,7 @@ func TestMediaFile_IsImageOther(t *testing.T) { t.Fatal(err) } - assert.Equal(t, fs.FormatGif, mediaFile.FileType()) + assert.Equal(t, fs.ImageGIF, mediaFile.FileType()) assert.Equal(t, "image/gif", mediaFile.MimeType()) assert.Equal(t, false, mediaFile.IsJpeg()) assert.Equal(t, true, mediaFile.IsGif()) @@ -1266,7 +1266,7 @@ func TestMediaFile_IsImageOther(t *testing.T) { t.Fatal(err) } - assert.Equal(t, fs.FormatWebP, mediaFile.FileType()) + assert.Equal(t, fs.ImageWebP, mediaFile.FileType()) assert.Equal(t, fs.MimeTypeWebP, mediaFile.MimeType()) assert.Equal(t, false, mediaFile.IsJpeg()) assert.Equal(t, false, mediaFile.IsGif()) @@ -2162,7 +2162,7 @@ func TestMediaFile_FileType(t *testing.T) { assert.True(t, m.IsJpeg()) assert.Equal(t, "jpg", string(m.FileType())) - assert.Equal(t, fs.FormatJpeg, m.FileType()) + assert.Equal(t, fs.ImageJPEG, m.FileType()) assert.Equal(t, ".png", m.Extension()) } diff --git a/internal/photoprism/moments.go b/internal/photoprism/moments.go index eaddb8e3b..f7489059a 100644 --- a/internal/photoprism/moments.go +++ b/internal/photoprism/moments.go @@ -13,7 +13,7 @@ import ( "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/mutex" "github.com/photoprism/photoprism/internal/query" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // Moments represents a worker that creates albums based on popular locations, dates and labels. @@ -95,11 +95,11 @@ func (w *Moments) Start() (err error) { if a := entity.FindFolderAlbum(mom.Path); a != nil { if a.DeletedAt != nil { // Nothing to do. - log.Tracef("moments: %s was deleted (%s)", sanitize.Log(a.AlbumTitle), a.AlbumFilter) + log.Tracef("moments: %s was deleted (%s)", clean.Log(a.AlbumTitle), a.AlbumFilter) } else if err := a.UpdateFolder(mom.Path, f.Serialize()); err != nil { log.Errorf("moments: %s (update folder)", err.Error()) } else { - log.Tracef("moments: %s already exists (%s)", sanitize.Log(a.AlbumTitle), a.AlbumFilter) + log.Tracef("moments: %s already exists (%s)", clean.Log(a.AlbumTitle), a.AlbumFilter) } } else if a := entity.NewFolderAlbum(mom.Title(), mom.Path, f.Serialize()); a != nil { a.AlbumYear = mom.FolderYear @@ -110,7 +110,7 @@ func (w *Moments) Start() (err error) { if err := a.Create(); err != nil { log.Errorf("moments: %s (create folder)", err) } else { - log.Infof("moments: added %s (%s)", sanitize.Log(a.AlbumTitle), a.AlbumFilter) + log.Infof("moments: added %s (%s)", clean.Log(a.AlbumTitle), a.AlbumFilter) } } } @@ -127,17 +127,17 @@ func (w *Moments) Start() (err error) { } if !a.Deleted() { - log.Tracef("moments: %s already exists (%s)", sanitize.Log(a.AlbumTitle), a.AlbumFilter) + log.Tracef("moments: %s already exists (%s)", clean.Log(a.AlbumTitle), a.AlbumFilter) } else if err := a.Restore(); err != nil { log.Errorf("moments: %s (restore month)", err.Error()) } else { - log.Infof("moments: %s restored", sanitize.Log(a.AlbumTitle)) + log.Infof("moments: %s restored", clean.Log(a.AlbumTitle)) } } else if a := entity.NewMonthAlbum(mom.Title(), mom.Slug(), mom.Year, mom.Month); a != nil { if err := a.Create(); err != nil { log.Errorf("moments: %s", err) } else { - log.Infof("moments: added %s (%s)", sanitize.Log(a.AlbumTitle), a.AlbumFilter) + log.Infof("moments: added %s (%s)", clean.Log(a.AlbumTitle), a.AlbumFilter) } } } @@ -161,9 +161,9 @@ func (w *Moments) Start() (err error) { if a.DeletedAt != nil { // Nothing to do. - log.Tracef("moments: %s was deleted (%s)", sanitize.Log(a.AlbumTitle), a.AlbumFilter) + log.Tracef("moments: %s was deleted (%s)", clean.Log(a.AlbumTitle), a.AlbumFilter) } else { - log.Tracef("moments: %s already exists (%s)", sanitize.Log(a.AlbumTitle), a.AlbumFilter) + log.Tracef("moments: %s already exists (%s)", clean.Log(a.AlbumTitle), a.AlbumFilter) } } else if a := entity.NewMomentsAlbum(mom.Title(), mom.Slug(), f.Serialize()); a != nil { a.AlbumYear = mom.Year @@ -172,7 +172,7 @@ func (w *Moments) Start() (err error) { if err := a.Create(); err != nil { log.Errorf("moments: %s", err) } else { - log.Infof("moments: added %s (%s)", sanitize.Log(a.AlbumTitle), a.AlbumFilter) + log.Infof("moments: added %s (%s)", clean.Log(a.AlbumTitle), a.AlbumFilter) } } } @@ -195,11 +195,11 @@ func (w *Moments) Start() (err error) { } if !a.Deleted() { - log.Tracef("moments: %s already exists (%s)", sanitize.Log(a.AlbumTitle), a.AlbumFilter) + log.Tracef("moments: %s already exists (%s)", clean.Log(a.AlbumTitle), a.AlbumFilter) } else if err := a.Restore(); err != nil { log.Errorf("moments: %s (restore state)", err.Error()) } else { - log.Infof("moments: %s restored", sanitize.Log(a.AlbumTitle)) + log.Infof("moments: %s restored", clean.Log(a.AlbumTitle)) } } else if a := entity.NewStateAlbum(mom.Title(), mom.Slug(), f.Serialize()); a != nil { a.AlbumLocation = mom.CountryName() @@ -209,7 +209,7 @@ func (w *Moments) Start() (err error) { if err := a.Create(); err != nil { log.Errorf("moments: %s", err) } else { - log.Infof("moments: added %s (%s)", sanitize.Log(a.AlbumTitle), a.AlbumFilter) + log.Infof("moments: added %s (%s)", clean.Log(a.AlbumTitle), a.AlbumFilter) } } } @@ -233,20 +233,20 @@ func (w *Moments) Start() (err error) { } if a.DeletedAt != nil || f.Serialize() == a.AlbumFilter { - log.Tracef("moments: %s already exists (%s)", sanitize.Log(a.AlbumTitle), a.AlbumFilter) + log.Tracef("moments: %s already exists (%s)", clean.Log(a.AlbumTitle), a.AlbumFilter) continue } if err := a.Update("AlbumFilter", f.Serialize()); err != nil { log.Errorf("moments: %s", err.Error()) } else { - log.Debugf("moments: updated %s (%s)", sanitize.Log(a.AlbumTitle), f.Serialize()) + log.Debugf("moments: updated %s (%s)", clean.Log(a.AlbumTitle), f.Serialize()) } } else if a := entity.NewMomentsAlbum(mom.Title(), mom.Slug(), f.Serialize()); a != nil { if err := a.Create(); err != nil { log.Errorf("moments: %s", err.Error()) } else { - log.Infof("moments: added %s (%s)", sanitize.Log(a.AlbumTitle), a.AlbumFilter) + log.Infof("moments: added %s (%s)", clean.Log(a.AlbumTitle), a.AlbumFilter) } } else { log.Errorf("moments: failed to create new moment %s (%s)", mom.Title(), f.Serialize()) diff --git a/internal/photoprism/purge.go b/internal/photoprism/purge.go index ad4ff29ee..cae42a2bf 100644 --- a/internal/photoprism/purge.go +++ b/internal/photoprism/purge.go @@ -12,8 +12,8 @@ import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/mutex" "github.com/photoprism/photoprism/internal/query" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // Purge represents a worker that removes missing files from search results. @@ -87,20 +87,20 @@ func (w *Purge) Start(opt PurgeOptions) (purgedFiles map[string]bool, purgedPhot if file.FileMissing { if fs.FileExists(fileName) { if opt.Dry { - log.Infof("purge: found %s", sanitize.Log(file.FileName)) + log.Infof("purge: found %s", clean.Log(file.FileName)) continue } if err := file.Found(); err != nil { log.Errorf("purge: %s", err) } else { - log.Infof("purge: found %s", sanitize.Log(file.FileName)) + log.Infof("purge: found %s", clean.Log(file.FileName)) } } } else if !fs.FileExists(fileName) { if opt.Dry { purgedFiles[fileName] = true - log.Infof("purge: file %s would be flagged as missing", sanitize.Log(file.FileName)) + log.Infof("purge: file %s would be flagged as missing", clean.Log(file.FileName)) continue } @@ -113,7 +113,7 @@ func (w *Purge) Start(opt PurgeOptions) (purgedFiles map[string]bool, purgedPhot w.files.Remove(file.FileName, file.FileRoot) purgedFiles[fileName] = true - log.Infof("purge: flagged file %s as missing", sanitize.Log(file.FileName)) + log.Infof("purge: flagged file %s as missing", clean.Log(file.FileName)) if !wasPrimary { continue @@ -162,7 +162,7 @@ func (w *Purge) Start(opt PurgeOptions) (purgedFiles map[string]bool, purgedPhot if !fs.FileExists(fileName) { if opt.Dry { purgedFiles[fileName] = true - log.Infof("purge: duplicate %s would be removed", sanitize.Log(file.FileName)) + log.Infof("purge: duplicate %s would be removed", clean.Log(file.FileName)) continue } @@ -171,7 +171,7 @@ func (w *Purge) Start(opt PurgeOptions) (purgedFiles map[string]bool, purgedPhot } else { w.files.Remove(file.FileName, file.FileRoot) purgedFiles[fileName] = true - log.Infof("purge: removed duplicate %s", sanitize.Log(file.FileName)) + log.Infof("purge: removed duplicate %s", clean.Log(file.FileName)) } } } @@ -210,7 +210,7 @@ func (w *Purge) Start(opt PurgeOptions) (purgedFiles map[string]bool, purgedPhot if opt.Dry { purgedPhotos[photo.PhotoUID] = true - log.Infof("purge: %s would be removed", sanitize.Log(photo.PhotoName)) + log.Infof("purge: %s would be removed", clean.Log(photo.PhotoName)) continue } @@ -220,9 +220,9 @@ func (w *Purge) Start(opt PurgeOptions) (purgedFiles map[string]bool, purgedPhot purgedPhotos[photo.PhotoUID] = true if opt.Hard { - log.Infof("purge: permanently removed %s", sanitize.Log(photo.PhotoName)) + log.Infof("purge: permanently removed %s", clean.Log(photo.PhotoName)) } else { - log.Infof("purge: flagged photo %s as deleted", sanitize.Log(photo.PhotoName)) + log.Infof("purge: flagged photo %s as deleted", clean.Log(photo.PhotoName)) } // Remove files from lookup table. diff --git a/internal/photoprism/related.go b/internal/photoprism/related.go index 991defa6a..b69fa6b69 100644 --- a/internal/photoprism/related.go +++ b/internal/photoprism/related.go @@ -3,7 +3,7 @@ package photoprism import ( "strings" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // RelatedFiles represents a list of related files to be indexed or imported. @@ -67,5 +67,5 @@ func (m RelatedFiles) MainLogName() string { return "" } - return sanitize.Log(m.Main.RootRelName()) + return clean.Log(m.Main.RootRelName()) } diff --git a/internal/photoprism/related_test.go b/internal/photoprism/related_test.go index ded649d52..8eba74609 100644 --- a/internal/photoprism/related_test.go +++ b/internal/photoprism/related_test.go @@ -156,7 +156,7 @@ func TestRelatedFiles_MainFileType(t *testing.T) { Files: MediaFiles{}, Main: mediaFile, } - assert.Equal(t, string(fs.FormatJpeg), relatedFiles.MainFileType()) + assert.Equal(t, string(fs.ImageJPEG), relatedFiles.MainFileType()) }) t.Run("Heif", func(t *testing.T) { mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/telegram_2020-01-30_09-57-18.jpg") @@ -175,7 +175,7 @@ func TestRelatedFiles_MainFileType(t *testing.T) { Files: MediaFiles{mediaFile, mediaFile2}, Main: mediaFile3, } - assert.Equal(t, string(fs.FormatHEIF), relatedFiles.MainFileType()) + assert.Equal(t, string(fs.ImageHEIF), relatedFiles.MainFileType()) }) } diff --git a/internal/query/account_uploads.go b/internal/query/account_uploads.go index 2344fe442..c3395ef26 100644 --- a/internal/query/account_uploads.go +++ b/internal/query/account_uploads.go @@ -11,7 +11,7 @@ func AccountUploads(a entity.Account, limit int) (results entity.Files, err erro Where("files.id NOT IN (SELECT file_id FROM files_sync WHERE file_id > 0 AND account_id = ?)", a.ID) if !a.SyncRaw { - s = s.Where("files.file_type <> ? OR files.file_type IS NULL", fs.FormatRaw) + s = s.Where("files.file_type <> ? OR files.file_type IS NULL", fs.RawImage) } s = s.Order("files.file_name ASC") diff --git a/internal/query/albums.go b/internal/query/albums.go index 4a98b8413..657026e02 100644 --- a/internal/query/albums.go +++ b/internal/query/albums.go @@ -7,7 +7,7 @@ import ( "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/mutex" "github.com/photoprism/photoprism/internal/search" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // Albums returns a slice of albums. @@ -52,7 +52,7 @@ func AlbumCoverByUID(uid string) (file entity.File, err error) { if err := a.Delete(); err != nil { log.Errorf("%s: %s (hide)", a.AlbumType, err) } else { - log.Infof("%s: %s hidden", a.AlbumType, sanitize.Log(a.AlbumTitle)) + log.Infof("%s: %s hidden", a.AlbumType, clean.Log(a.AlbumTitle)) } } diff --git a/internal/query/categories.go b/internal/query/categories.go index 59e459f9b..cd0b67626 100644 --- a/internal/query/categories.go +++ b/internal/query/categories.go @@ -1,8 +1,6 @@ package query -import ( - "strings" -) +import "github.com/photoprism/photoprism/pkg/txt" type CategoryLabel struct { Name string @@ -24,7 +22,7 @@ func CategoryLabels(limit, offset int) (results []CategoryLabel) { } for i, l := range results { - results[i].Title = strings.Title(l.Name) + results[i].Title = txt.Title(l.Name) } return results diff --git a/internal/query/counts.go b/internal/query/counts.go index 8805a26e6..42cf8cf0f 100644 --- a/internal/query/counts.go +++ b/internal/query/counts.go @@ -34,7 +34,7 @@ func (c *Counts) Refresh() { Take(c) Db().Table("photos"). - Select("SUM(photo_type = 'video' AND photo_quality >= 0 AND photo_private = 0) AS videos, SUM(photo_type IN ('image','raw','live','animated') AND photo_quality < 3 AND photo_quality >= 0 AND photo_private = 0) AS review, SUM(photo_quality = -1) AS hidden, SUM(photo_type IN ('image','raw','live','animated') AND photo_private = 0 AND photo_quality >= 0) AS photos, SUM(photo_favorite = 1 AND photo_quality >= 0) AS favorites, SUM(photo_private = 1 AND photo_quality >= 0) AS private"). + Select("SUM(photo_type = 'video' AND photo_quality > -1 AND photo_private = 0) AS videos, SUM(photo_type IN ('image','raw','live','animated') AND photo_quality < 3 AND photo_quality > -1 AND photo_private = 0) AS review, SUM(photo_quality = -1) AS hidden, SUM(photo_type IN ('image','raw','live','animated') AND photo_private = 0 AND photo_quality > -1) AS photos, SUM(photo_favorite = 1 AND photo_quality > -1) AS favorites, SUM(photo_private = 1 AND photo_quality > -1) AS private"). Where("photos.id NOT IN (SELECT photo_id FROM files WHERE file_primary = 1 AND (file_missing = 1 OR file_error <> ''))"). Where("deleted_at IS NULL"). Take(c) @@ -61,11 +61,11 @@ func (c *Counts) Refresh() { Db().Table("places"). Select("SUM(photo_count > 0) AS places"). - Where("id != 'zz'"). + Where("id <> 'zz'"). Take(c) Db().Table("photos"). - Select("SUM(photo_type = 'video' AND photo_quality >= 0 AND photo_private = 0) AS videos, SUM(photo_type IN ('image','raw','live','animated') AND photo_quality < 3 AND photo_quality >= 0 AND photo_private = 0) AS review, SUM(photo_quality = -1) AS hidden, SUM(photo_type IN ('image','raw','live','animated') AND photo_private = 0 AND photo_quality >= 0) AS photos, SUM(photo_favorite = 1 AND photo_quality >= 0) AS favorites, SUM(photo_private = 1 AND photo_quality >= 0) AS private"). + Select("SUM(photo_type = 'video' AND photo_quality > -1 AND photo_private = 0) AS videos, SUM(photo_type IN ('image','raw','live','animated') AND photo_quality < 3 AND photo_quality > -1 AND photo_private = 0) AS review, SUM(photo_quality = -1) AS hidden, SUM(photo_type IN ('image','raw','live','animated') AND photo_private = 0 AND photo_quality > -1) AS photos, SUM(photo_favorite = 1 AND photo_quality > -1) AS favorites, SUM(photo_private = 1 AND photo_quality > -1) AS private"). Where("photos.id NOT IN (SELECT photo_id FROM files WHERE file_primary = 1 AND (file_missing = 1 OR file_error <> ''))"). Where("deleted_at IS NULL"). Take(c) diff --git a/internal/query/faces.go b/internal/query/faces.go index a64d2a21b..99f825e18 100644 --- a/internal/query/faces.go +++ b/internal/query/faces.go @@ -6,7 +6,7 @@ import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/face" "github.com/photoprism/photoprism/internal/mutex" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // IDs represents a list of identifier strings. @@ -166,15 +166,15 @@ func MergeFaces(merge entity.Faces) (merged *entity.Face, err error) { for i := 1; i < len(merge); i++ { if merge[i].SubjUID != subjUID { return merged, fmt.Errorf("faces: cannot merge clusters with conflicting subjects %s <> %s", - sanitize.Log(subjUID), sanitize.Log(merge[i].SubjUID)) + clean.Log(subjUID), clean.Log(merge[i].SubjUID)) } } // Find or create merged face cluster. if merged = entity.NewFace(merge[0].SubjUID, merge[0].FaceSrc, merge.Embeddings()); merged == nil { - return merged, fmt.Errorf("faces: new cluster is nil for subject %s", sanitize.Log(subjUID)) + return merged, fmt.Errorf("faces: new cluster is nil for subject %s", clean.Log(subjUID)) } else if merged = entity.FirstOrCreateFace(merged); merged == nil { - return merged, fmt.Errorf("faces: failed creating new cluster for subject %s", sanitize.Log(subjUID)) + return merged, fmt.Errorf("faces: failed creating new cluster for subject %s", clean.Log(subjUID)) } else if err := merged.MatchMarkers(append(merge.IDs(), "")); err != nil { return merged, err } @@ -183,9 +183,9 @@ func MergeFaces(merge entity.Faces) (merged *entity.Face, err error) { if removed, err := PurgeOrphanFaces(merge.IDs()); err != nil { return merged, err } else if removed > 0 { - log.Debugf("faces: removed %d orphans for subject %s", removed, sanitize.Log(subjUID)) + log.Debugf("faces: removed %d orphans for subject %s", removed, clean.Log(subjUID)) } else { - log.Warnf("faces: failed removing merged clusters for subject %s", sanitize.Log(subjUID)) + log.Warnf("faces: failed removing merged clusters for subject %s", clean.Log(subjUID)) } return merged, err diff --git a/internal/query/file_selection.go b/internal/query/file_selection.go index cacceb430..1e1b7ea38 100644 --- a/internal/query/file_selection.go +++ b/internal/query/file_selection.go @@ -6,29 +6,51 @@ import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/form" + "github.com/photoprism/photoprism/pkg/media" ) +const MegaByte = 1024 * 1024 + // FileSelection represents a selection filter to include/exclude certain files. type FileSelection struct { - Video bool - Sidecar bool - PrimaryOnly bool - OriginalsOnly bool - SizeLimit int - Include []string - Exclude []string + MaxSize int + Media []string + OmitMedia []string + Types []string + OmitTypes []string + Primary bool + Originals bool + Hidden bool + Private bool + Archived bool } -// FileSelectionAll returns options that include videos and sidecar files. -func FileSelectionAll() FileSelection { +// DownloadSelection selects files to download. +func DownloadSelection(mediaRaw, mediaSidecar, originals bool) FileSelection { + omitMedia := make([]string, 0, 2) + + if !mediaRaw { + omitMedia = append(omitMedia, media.Raw.String()) + } + + if !mediaSidecar { + omitMedia = append(omitMedia, media.Sidecar.String()) + } + return FileSelection{ - Video: true, - Sidecar: true, - PrimaryOnly: false, - OriginalsOnly: false, - SizeLimit: 0, - Include: []string{}, - Exclude: []string{}, + OmitMedia: omitMedia, + Originals: originals, + Private: true, + Archived: true, + } +} + +// ShareSelection selects files to share, for example for upload via WebDAV. +func ShareSelection(primary bool) FileSelection { + return FileSelection{ + Originals: !primary, + Primary: primary, + MaxSize: 1024 * MegaByte, } } @@ -39,7 +61,6 @@ func SelectedFiles(f form.Selection, o FileSelection) (results entity.Files, err } var concat string - switch DbDialect() { case MySQL: concat = "CONCAT(a.path, '/%')" @@ -49,6 +70,7 @@ func SelectedFiles(f form.Selection, o FileSelection) (results entity.Files, err return results, fmt.Errorf("unknown sql dialect: %s", DbDialect()) } + // Search condition. where := fmt.Sprintf(`photos.photo_uid IN (?) OR photos.place_id IN (?) OR photos.photo_uid IN (SELECT photo_uid FROM files WHERE file_uid IN (?)) @@ -61,42 +83,65 @@ func SelectedFiles(f form.Selection, o FileSelection) (results entity.Files, err OR photos.id IN (SELECT pl.photo_id FROM photos_labels pl JOIN categories c ON c.label_id = pl.label_id JOIN labels lc ON lc.id = c.category_id AND lc.deleted_at IS NULL WHERE lc.label_uid IN (?))`, concat, entity.Marker{}.TableName()) + // Build search query. s := UnscopedDb().Table("files"). Select("files.*"). Joins("JOIN photos ON photos.id = files.photo_id"). - Where("photos.deleted_at IS NULL"). - Where("files.file_missing = 0"). + Where("files.file_missing = 0 AND files.file_name <> '' AND files.file_hash <> ''"). Where(where, f.Photos, f.Places, f.Files, f.Files, f.Files, f.Albums, f.Subjects, f.Labels, f.Labels). Group("files.id") - if o.OriginalsOnly { - s = s.Where("file_root = '/'") + // File size limit? + if o.MaxSize > 0 { + s = s.Where("file_size < ?", o.MaxSize) } - if o.PrimaryOnly { + // Specific media types only? + if len(o.Media) > 0 { + s = s.Where("media_type IN (?)", o.Media) + } + + // Exclude media types? + if len(o.OmitMedia) > 0 { + s = s.Where("media_type NOT IN (?)", o.OmitMedia) + } + + // Specific file types only? + if len(o.Types) > 0 { + s = s.Where("file_type IN (?)", o.Types) + } + + // Exclude file types? + if len(o.OmitTypes) > 0 { + s = s.Where("file_type NOT IN (?)", o.OmitTypes) + } + + // Primary files only? + if o.Primary { s = s.Where("file_primary = 1") } - if !o.Sidecar { - s = s.Where("file_sidecar = 0") + // Files in originals only? + if o.Originals { + s = s.Where("file_root = '/'") } - if !o.Video { - s = s.Where("file_video = 0") + // Exclude private? + if !o.Private { + s = s.Where("photos.photo_private <> 1") } - if o.SizeLimit > 0 { - s = s.Where("file_size < ?", o.SizeLimit) + // Exclude hidden photos? + if !o.Hidden { + s = s.Where("photos.photo_quality > -1") } - if len(o.Include) > 0 { - s = s.Where("file_type IN (?)", o.Include) - } - - if len(o.Exclude) > 0 { - s = s.Where("file_type NOT IN (?)", o.Exclude) + // Exclude archived photos? + if !o.Archived { + s = s.Where("photos.deleted_at IS NULL") } + // Find and return. if result := s.Scan(&results); result.Error != nil { return results, result.Error } diff --git a/internal/query/files.go b/internal/query/files.go index 4bbad168f..8e70fe954 100644 --- a/internal/query/files.go +++ b/internal/query/files.go @@ -78,7 +78,7 @@ func VideoByPhotoUID(photoUID string) (*entity.File, error) { return &f, fmt.Errorf("photo uid required") } - err := Db().Where("photo_uid = ? AND (file_video = 1 OR file_type = ?)", photoUID, fs.FormatGif). + err := Db().Where("photo_uid = ? AND (file_video = 1 OR file_type = ?)", photoUID, fs.ImageGIF). Order("file_video DESC, file_duration DESC, file_frames DESC"). Preload("Photo").First(&f).Error return &f, err diff --git a/internal/query/moments.go b/internal/query/moments.go index c5cdccf56..d0e14630e 100644 --- a/internal/query/moments.go +++ b/internal/query/moments.go @@ -7,7 +7,7 @@ import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/maps" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/txt" ) @@ -105,7 +105,7 @@ func (m Moment) CountryName() string { // Slug returns an identifier string for a moment. func (m Moment) Slug() (s string) { - state := sanitize.State(m.State, m.Country) + state := clean.State(m.State, m.Country) if state == "" { return m.TitleSlug() @@ -131,7 +131,7 @@ func (m Moment) TitleSlug() string { // Title returns an english title for the moment. func (m Moment) Title() string { - state := sanitize.State(m.State, m.Country) + state := clean.State(m.State, m.Country) if m.Year == 0 && m.Month == 0 { if m.Label != "" { diff --git a/internal/query/photo.go b/internal/query/photo.go index affd5e7bc..d8f30a5aa 100644 --- a/internal/query/photo.go +++ b/internal/query/photo.go @@ -77,7 +77,7 @@ func PhotosMissing(limit int, offset int) (entities entity.Photos, err error) { err = Db(). Select("photos.*"). Where("id NOT IN (SELECT photo_id FROM files WHERE file_missing = 0 AND file_root = '/' AND deleted_at IS NULL)"). - Where("photos.photo_type <> ?", entity.TypeMeta). + Where("photos.photo_type <> ?", entity.MediaText). Group("photos.id"). Limit(limit).Offset(offset).Find(&entities).Error diff --git a/internal/query/photo_selection_test.go b/internal/query/photo_selection_test.go index c915ace4e..24936d8f0 100644 --- a/internal/query/photo_selection_test.go +++ b/internal/query/photo_selection_test.go @@ -36,28 +36,55 @@ func TestPhotoSelection(t *testing.T) { } func TestFileSelection(t *testing.T) { - t.Run("no items selected", func(t *testing.T) { - f := form.Selection{ - Photos: []string{}, + none := form.Selection{Photos: []string{}} + + one := form.Selection{Photos: []string{"pt9jtdre2lvl0yh8"}} + + two := form.Selection{Photos: []string{"pt9jtdre2lvl0yh7", "pt9jtdre2lvl0yh8"}} + + many := form.Selection{ + Files: []string{"ft8es39w45bnlqdw"}, + Photos: []string{"pt9jtdre2lvl0y21", "pt9jtdre2lvl0y19", "pr2xu7myk7wrbk38", "pt9jtdre2lvl0yh7", "pt9jtdre2lvl0yh8"}, + } + + t.Run("EmptySelection", func(t *testing.T) { + sel := DownloadSelection(true, false, true) + if results, err := SelectedFiles(none, sel); err == nil { + t.Fatal("error expected") + } else { + assert.Empty(t, results) } - - r, err := SelectedFiles(f, FileSelectionAll()) - - assert.Equal(t, "no items selected", err.Error()) - assert.Empty(t, r) }) - t.Run("files selected", func(t *testing.T) { - f := form.Selection{ - Photos: []string{"pt9jtdre2lvl0yh7", "pt9jtdre2lvl0yh8"}, - } - - r, err := SelectedFiles(f, FileSelectionAll()) - - if err != nil { + t.Run("DownloadSelectionRawSidecarPrivate", func(t *testing.T) { + sel := DownloadSelection(true, true, false) + if results, err := SelectedFiles(one, sel); err != nil { t.Fatal(err) + } else { + assert.Len(t, results, 2) + } + }) + t.Run("DownloadSelectionRawOriginals", func(t *testing.T) { + sel := DownloadSelection(true, false, true) + if results, err := SelectedFiles(two, sel); err != nil { + t.Fatal(err) + } else { + assert.Len(t, results, 2) + } + }) + t.Run("ShareSelectionOriginals", func(t *testing.T) { + sel := ShareSelection(false) + if results, err := SelectedFiles(many, sel); err != nil { + t.Fatal(err) + } else { + assert.Len(t, results, 6) + } + }) + t.Run("ShareSelectionPrimary", func(t *testing.T) { + sel := ShareSelection(true) + if results, err := SelectedFiles(many, sel); err != nil { + t.Fatal(err) + } else { + assert.Len(t, results, 4) } - - assert.Equal(t, 3, len(r)) - assert.IsType(t, entity.Files{}, r) }) } diff --git a/internal/query/subjects.go b/internal/query/subjects.go index 76c3eccb5..ba160c784 100644 --- a/internal/query/subjects.go +++ b/internal/query/subjects.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/photoprism/photoprism/internal/entity" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // People returns the sorted names of the first 2000 people. @@ -93,10 +93,10 @@ func CreateMarkerSubjects() (affected int64, err error) { if name == m.MarkerName && subj != nil { // Do nothing. } else if subj = entity.NewSubject(m.MarkerName, entity.SubjPerson, entity.SrcMarker); subj == nil { - log.Errorf("faces: invalid subject %s", sanitize.Log(m.MarkerName)) + log.Errorf("faces: invalid subject %s", clean.Log(m.MarkerName)) continue } else if subj = entity.FirstOrCreateSubject(subj); subj == nil { - log.Errorf("faces: failed adding subject %s", sanitize.Log(m.MarkerName)) + log.Errorf("faces: failed adding subject %s", clean.Log(m.MarkerName)) continue } else { affected++ diff --git a/internal/remote/discover.go b/internal/remote/discover.go index 1bb12b528..85a9d833d 100644 --- a/internal/remote/discover.go +++ b/internal/remote/discover.go @@ -60,7 +60,7 @@ func Discover(rawUrl, user, pass string) (result Account, err error) { serviceUrl.User = nil if w := txt.Keywords(serviceUrl.Host); len(w) > 0 { - result.AccName = strings.Title(w[0]) + result.AccName = txt.Title(w[0]) } else { result.AccName = serviceUrl.Host } diff --git a/internal/remote/webdav/webdav.go b/internal/remote/webdav/webdav.go index 3d70b07db..1250c0e19 100644 --- a/internal/remote/webdav/webdav.go +++ b/internal/remote/webdav/webdav.go @@ -36,8 +36,8 @@ import ( "github.com/studio-b12/gowebdav" "github.com/photoprism/photoprism/internal/event" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // Global log instance. @@ -180,14 +180,14 @@ func (c Client) fetchDirs(root string, recursive bool, start time.Time, timeout func (c Client) Download(from, to string, force bool) (err error) { defer func() { if r := recover(); r != nil { - log.Errorf("webdav: %s (panic)\nstack: %s", r, sanitize.Log(from)) - err = fmt.Errorf("webdav: unexpected error while downloading %s", sanitize.Log(from)) + log.Errorf("webdav: %s (panic)\nstack: %s", r, clean.Log(from)) + err = fmt.Errorf("webdav: unexpected error while downloading %s", clean.Log(from)) } }() // Skip if file already exists. if _, err := os.Stat(to); err == nil && !force { - return fmt.Errorf("webdav: download skipped, %s already exists", sanitize.Log(to)) + return fmt.Errorf("webdav: download skipped, %s already exists", clean.Log(to)) } dir := path.Dir(to) @@ -196,10 +196,10 @@ func (c Client) Download(from, to string, force bool) (err error) { if err != nil { // Create local storage path. if err := os.MkdirAll(dir, os.ModePerm); err != nil { - return fmt.Errorf("webdav: cannot create folder %s (%s)", sanitize.Log(dir), err) + return fmt.Errorf("webdav: cannot create folder %s (%s)", clean.Log(dir), err) } } else if !dirInfo.IsDir() { - return fmt.Errorf("webdav: %s is not a folder", sanitize.Log(dir)) + return fmt.Errorf("webdav: %s is not a folder", clean.Log(dir)) } var bytes []byte @@ -209,8 +209,8 @@ func (c Client) Download(from, to string, force bool) (err error) { // Error? if err != nil { - log.Errorf("webdav: %s", sanitize.Log(err.Error())) - return fmt.Errorf("webdav: failed downloading %s", sanitize.Log(from)) + log.Errorf("webdav: %s", clean.Log(err.Error())) + return fmt.Errorf("webdav: failed downloading %s", clean.Log(from)) } // Write data to file and return. @@ -230,7 +230,7 @@ func (c Client) DownloadDir(from, to string, recursive, force bool) (errs []erro if _, err = os.Stat(dest); err == nil { // File already exists. - msg := fmt.Errorf("webdav: %s already exists", sanitize.Log(dest)) + msg := fmt.Errorf("webdav: %s already exists", clean.Log(dest)) log.Warn(msg) errs = append(errs, msg) continue diff --git a/internal/remote/webdav/webdav_test.go b/internal/remote/webdav/webdav_test.go index 4c61e60db..196cbb1f1 100644 --- a/internal/remote/webdav/webdav_test.go +++ b/internal/remote/webdav/webdav_test.go @@ -141,7 +141,7 @@ func TestClient_UploadAndDelete(t *testing.T) { assert.IsType(t, Client{}, c) - tempName := rnd.UUID() + fs.JpegExt + tempName := rnd.UUID() + fs.ExtJPEG if err := c.Upload("testdata/example.jpg", tempName); err != nil { t.Fatal(err) diff --git a/internal/search/conditions.go b/internal/search/conditions.go index 6853e8726..f06abd1ae 100644 --- a/internal/search/conditions.go +++ b/internal/search/conditions.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/txt" "github.com/jinzhu/inflection" @@ -12,7 +12,7 @@ import ( // Like escapes a string for use in a query. func Like(s string) string { - return strings.Trim(sanitize.SqlString(s), " |&*%") + return strings.Trim(clean.SqlString(s), " |&*%") } // LikeAny returns a single where condition matching the search words. @@ -21,7 +21,7 @@ func LikeAny(col, s string, keywords, exact bool) (wheres []string) { return wheres } - s = txt.StripOr(sanitize.SearchQuery(s)) + s = txt.StripOr(clean.SearchQuery(s)) var wildcardThreshold int diff --git a/internal/search/conditions_test.go b/internal/search/conditions_test.go index 2c84817b3..85a4dc01d 100644 --- a/internal/search/conditions_test.go +++ b/internal/search/conditions_test.go @@ -7,7 +7,7 @@ import ( "github.com/photoprism/photoprism/internal/entity" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/txt" ) @@ -232,7 +232,7 @@ func TestLikeAllNames(t *testing.T) { } }) t.Run("Plus", func(t *testing.T) { - if w := LikeAllNames(Cols{"name"}, sanitize.SearchQuery("Paul + Paula")); len(w) == 2 { + if w := LikeAllNames(Cols{"name"}, clean.SearchQuery("Paul + Paula")); len(w) == 2 { assert.Equal(t, "name LIKE '%Paul%'", w[0]) assert.Equal(t, "name LIKE '%Paula%'", w[1]) } else { @@ -240,7 +240,7 @@ func TestLikeAllNames(t *testing.T) { } }) t.Run("And", func(t *testing.T) { - if w := LikeAllNames(Cols{"name"}, sanitize.SearchQuery("P and Paula")); len(w) == 2 { + if w := LikeAllNames(Cols{"name"}, clean.SearchQuery("P and Paula")); len(w) == 2 { assert.Equal(t, "name LIKE '%P%'", w[0]) assert.Equal(t, "name LIKE '%Paula%'", w[1]) } else { @@ -248,7 +248,7 @@ func TestLikeAllNames(t *testing.T) { } }) t.Run("Or", func(t *testing.T) { - if w := LikeAllNames(Cols{"name"}, sanitize.SearchQuery("Paul or Paula")); len(w) == 1 { + if w := LikeAllNames(Cols{"name"}, clean.SearchQuery("Paul or Paula")); len(w) == 1 { assert.Equal(t, "name LIKE '%Paul%' OR name LIKE '%Paula%'", w[0]) } else { t.Fatalf("unexpected result: %#v", w) diff --git a/internal/search/labels.go b/internal/search/labels.go index 8bc4381bc..a58a428d5 100644 --- a/internal/search/labels.go +++ b/internal/search/labels.go @@ -5,7 +5,7 @@ import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/form" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/txt" ) @@ -59,7 +59,7 @@ func Labels(f form.SearchLabels) (results []Label, err error) { likeString := "%" + f.Query + "%" if result := Db().First(&label, "label_slug = ? OR custom_slug = ?", slugString, slugString); result.Error != nil { - log.Infof("search: label %s not found", sanitize.Log(f.Query)) + log.Infof("search: label %s not found", clean.Log(f.Query)) s = s.Where("labels.label_name LIKE ?", likeString) } else { @@ -71,7 +71,7 @@ func Labels(f form.SearchLabels) (results []Label, err error) { labelIds = append(labelIds, category.LabelID) } - log.Infof("search: label %s includes %d categories", sanitize.Log(label.LabelName), len(labelIds)) + log.Infof("search: label %s includes %d categories", clean.Log(label.LabelName), len(labelIds)) s = s.Where("labels.id IN (?)", labelIds) } diff --git a/internal/search/photos.go b/internal/search/photos.go index 2fd2ff321..9d17bb66e 100644 --- a/internal/search/photos.go +++ b/internal/search/photos.go @@ -17,9 +17,15 @@ import ( "github.com/photoprism/photoprism/pkg/txt" ) +// PhotosColsAll contains all supported result column names. var PhotosColsAll = SelectString(Photo{}, []string{"*"}) + +// PhotosColsView contains the result column names necessary for the photo viewer. var PhotosColsView = SelectString(Photo{}, SelectCols(GeoResult{}, []string{"*"})) +// FileTypes contains a list of browser-compatible file formats returned by search queries. +var FileTypes = []string{fs.ImageJPEG.String(), fs.ImagePNG.String(), fs.ImageGIF.String(), fs.ImageWebP.String()} + // Photos finds photos based on the search form provided and returns them as PhotoResults. func Photos(f form.SearchPhotos) (results PhotoResults, count int, err error) { return searchPhotos(f, PhotosColsAll) @@ -76,9 +82,9 @@ func searchPhotos(f form.SearchPhotos, resultCols string) (results PhotoResults, return PhotoResults{}, 0, fmt.Errorf("invalid sort order") } - // Show hidden files? + // Limit the result file types if hidden images/videos should not be found. if !f.Hidden { - s = s.Where("files.file_type IN (?) OR files.file_video = 1", []string{"jpg", "gif"}) + s = s.Where("files.file_type IN (?) OR files.file_video = 1", FileTypes) if f.Error { s = s.Where("files.file_error <> ''") @@ -92,6 +98,7 @@ func searchPhotos(f form.SearchPhotos, resultCols string) (results PhotoResults, s = s.Where("files.file_primary = 1") } + // Find only certain unique IDs? if txt.NotEmpty(f.UID) { s = s.Where("photos.photo_uid IN (?)", SplitOr(strings.ToLower(f.UID))) @@ -162,6 +169,9 @@ func searchPhotos(f form.SearchPhotos, resultCols string) (results PhotoResults, case terms["svg"]: f.Query = strings.ReplaceAll(f.Query, "svg", "") f.Vector = true + case terms["animated"]: + f.Query = strings.ReplaceAll(f.Query, "animated", "") + f.Animated = true case terms["gifs"]: f.Query = strings.ReplaceAll(f.Query, "gifs", "") f.Animated = true @@ -275,7 +285,7 @@ func searchPhotos(f form.SearchPhotos, resultCols string) (results PhotoResults, // Filter for one or more subjects? if txt.NotEmpty(f.Subject) { for _, subj := range SplitAnd(strings.ToLower(f.Subject)) { - if subjects := SplitOr(subj); rnd.ContainsUIDs(subjects, 'j') { + if subjects := SplitOr(subj); rnd.ValidIDs(subjects, 'j') { s = s.Where(fmt.Sprintf("files.photo_id IN (SELECT photo_id FROM files f JOIN %s m ON f.file_uid = m.file_uid AND m.marker_invalid = 0 WHERE subj_uid IN (?))", entity.Marker{}.TableName()), subjects) } else { @@ -515,7 +525,7 @@ func searchPhotos(f form.SearchPhotos, resultCols string) (results PhotoResults, } // Filter by album? - if rnd.IsPPID(f.Album, 'a') { + if rnd.EntityUID(f.Album, 'a') { if f.Filter != "" { s = s.Where("files.photo_uid NOT IN (SELECT photo_uid FROM photos_albums pa WHERE pa.hidden = 1 AND pa.album_uid = ?)", f.Album) } else { diff --git a/internal/search/photos_geo.go b/internal/search/photos_geo.go index 653a6d6ed..6bf1f910e 100644 --- a/internal/search/photos_geo.go +++ b/internal/search/photos_geo.go @@ -80,6 +80,9 @@ func PhotosGeo(f form.SearchPhotosGeo) (results GeoResults, err error) { case terms["svg"]: f.Query = strings.ReplaceAll(f.Query, "svg", "") f.Vector = true + case terms["animated"]: + f.Query = strings.ReplaceAll(f.Query, "animated", "") + f.Animated = true case terms["gifs"]: f.Query = strings.ReplaceAll(f.Query, "gifs", "") f.Animated = true @@ -181,7 +184,7 @@ func PhotosGeo(f form.SearchPhotosGeo) (results GeoResults, err error) { // Filter for one or more subjects? if f.Subject != "" { for _, subj := range SplitAnd(strings.ToLower(f.Subject)) { - if subjects := SplitOr(subj); rnd.ContainsUIDs(subjects, 'j') { + if subjects := SplitOr(subj); rnd.ValidIDs(subjects, 'j') { s = s.Where(fmt.Sprintf("photos.id IN (SELECT photo_id FROM files f JOIN %s m ON f.file_uid = m.file_uid AND m.marker_invalid = 0 WHERE subj_uid IN (?))", entity.Marker{}.TableName()), subjects) } else { @@ -197,7 +200,7 @@ func PhotosGeo(f form.SearchPhotosGeo) (results GeoResults, err error) { } // Filter by album? - if rnd.IsPPID(f.Album, 'a') { + if rnd.EntityUID(f.Album, 'a') { if f.Filter != "" { s = s.Where("photos.photo_uid NOT IN (SELECT photo_uid FROM photos_albums pa WHERE pa.hidden = 1 AND pa.album_uid = ?)", f.Album) } else { diff --git a/internal/search/photos_results.go b/internal/search/photos_results.go index a30359d46..01c0bac62 100644 --- a/internal/search/photos_results.go +++ b/internal/search/photos_results.go @@ -2,13 +2,13 @@ package search import ( "fmt" - "strings" "time" "github.com/gosimple/slug" "github.com/ulule/deepcopier" "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/pkg/txt" ) // Photo represents a photo search result. @@ -117,7 +117,7 @@ func (photo *Photo) ShareBase(seq int) string { var name string if photo.PhotoTitle != "" { - name = strings.Title(slug.MakeLang(photo.PhotoTitle, "en")) + name = txt.Title(slug.MakeLang(photo.PhotoTitle, "en")) } else { name = photo.PhotoUID } diff --git a/internal/search/subjects.go b/internal/search/subjects.go index 1cf6a3482..dde8b6aef 100644 --- a/internal/search/subjects.go +++ b/internal/search/subjects.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/txt" @@ -162,5 +162,5 @@ func SubjectUIDs(s string) (result []string, names []string, remaining string) { result = append(result, strings.Join(subj, txt.Or)) } - return result, names, sanitize.SearchQuery(remaining) + return result, names, clean.SearchQuery(remaining) } diff --git a/internal/server/logger.go b/internal/server/logger.go index fcb37a36e..a8c227a69 100644 --- a/internal/server/logger.go +++ b/internal/server/logger.go @@ -4,7 +4,7 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // Logger instances a Logger middleware for Gin. @@ -33,7 +33,7 @@ func Logger() gin.HandlerFunc { // Use debug level to keep production logs clean. log.Debugf("http: %s %s (%3d) [%v]", method, - sanitize.Log(path), + clean.Log(path), statusCode, latency, ) diff --git a/internal/server/security.go b/internal/server/security.go index 70683760d..547f12163 100644 --- a/internal/server/security.go +++ b/internal/server/security.go @@ -6,7 +6,7 @@ import ( "strings" "github.com/gin-gonic/gin" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) const ( @@ -82,7 +82,7 @@ func (s *security) process(w http.ResponseWriter, r *http.Request) error { if !isGoodHost { s.opt.BadHostHandler.ServeHTTP(w, r) - return fmt.Errorf("http: bad host %s", sanitize.Log(r.Host)) + return fmt.Errorf("http: bad host %s", clean.Log(r.Host)) } } diff --git a/internal/server/webdav.go b/internal/server/webdav.go index 6148e08e2..4357e1db0 100644 --- a/internal/server/webdav.go +++ b/internal/server/webdav.go @@ -6,8 +6,8 @@ import ( "path/filepath" "strings" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" "github.com/gin-gonic/gin" "github.com/photoprism/photoprism/internal/auto" @@ -20,11 +20,11 @@ const WebDAVImport = "/import" // MarkUploadAsFavorite sets the favorite flag for newly uploaded files. func MarkUploadAsFavorite(fileName string) { - yamlName := fs.AbsPrefix(fileName, false) + fs.YamlExt + yamlName := fs.AbsPrefix(fileName, false) + fs.ExtYAML // Abort if YAML file already exists to avoid overwriting metadata. if fs.FileExists(yamlName) { - log.Warnf("webdav: %s already exists", sanitize.Log(filepath.Base(yamlName))) + log.Warnf("webdav: %s already exists", clean.Log(filepath.Base(yamlName))) return } @@ -41,7 +41,7 @@ func MarkUploadAsFavorite(fileName string) { } // Log success. - log.Infof("webdav: marked %s as favorite", sanitize.Log(filepath.Base(fileName))) + log.Infof("webdav: marked %s as favorite", clean.Log(filepath.Base(fileName))) } // WebDAV handles any requests to /originals|import/* @@ -66,11 +66,11 @@ func WebDAV(path string, router *gin.RouterGroup, conf *config.Config) { if err != nil { switch r.Method { case MethodPut, MethodPost, MethodPatch, MethodDelete, MethodCopy, MethodMove: - log.Errorf("webdav: %s in %s %s", sanitize.Log(err.Error()), sanitize.Log(r.Method), sanitize.Log(r.URL.String())) + log.Errorf("webdav: %s in %s %s", clean.Log(err.Error()), clean.Log(r.Method), clean.Log(r.URL.String())) case MethodPropfind: - log.Tracef("webdav: %s in %s %s", sanitize.Log(err.Error()), sanitize.Log(r.Method), sanitize.Log(r.URL.String())) + log.Tracef("webdav: %s in %s %s", clean.Log(err.Error()), clean.Log(r.Method), clean.Log(r.URL.String())) default: - log.Debugf("webdav: %s in %s %s", sanitize.Log(err.Error()), sanitize.Log(r.Method), sanitize.Log(r.URL.String())) + log.Debugf("webdav: %s in %s %s", clean.Log(err.Error()), clean.Log(r.Method), clean.Log(r.URL.String())) } } else { @@ -85,7 +85,7 @@ func WebDAV(path string, router *gin.RouterGroup, conf *config.Config) { switch r.Method { case MethodPut, MethodPost, MethodPatch, MethodDelete, MethodCopy, MethodMove: - log.Infof("webdav: %s %s", sanitize.Log(r.Method), sanitize.Log(r.URL.String())) + log.Infof("webdav: %s %s", clean.Log(r.Method), clean.Log(r.URL.String())) if router.BasePath() == WebDAVOriginals { auto.ShouldIndex() @@ -93,7 +93,7 @@ func WebDAV(path string, router *gin.RouterGroup, conf *config.Config) { auto.ShouldImport() } default: - log.Tracef("webdav: %s %s", sanitize.Log(r.Method), sanitize.Log(r.URL.String())) + log.Tracef("webdav: %s %s", clean.Log(r.Method), clean.Log(r.URL.String())) } } }, diff --git a/internal/thumb/create.go b/internal/thumb/create.go index 438a05662..53e884d76 100644 --- a/internal/thumb/create.go +++ b/internal/thumb/create.go @@ -11,8 +11,8 @@ import ( "github.com/disintegration/imaging" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/sanitize" ) // Suffix returns the thumb cache file suffix. @@ -35,7 +35,7 @@ func FileName(hash string, thumbPath string, width, height int, opts ...Resample } if len(hash) < 4 { - return "", fmt.Errorf("resample: file hash is empty or too short (%s)", sanitize.Log(hash)) + return "", fmt.Errorf("resample: file hash is empty or too short (%s)", clean.Log(hash)) } if len(thumbPath) == 0 { @@ -57,11 +57,11 @@ func FileName(hash string, thumbPath string, width, height int, opts ...Resample // FromCache returns the thumb cache file name for an image. func FromCache(imageFilename, hash, thumbPath string, width, height int, opts ...ResampleOption) (fileName string, err error) { if len(hash) < 4 { - return "", fmt.Errorf("resample: invalid file hash %s", sanitize.Log(hash)) + return "", fmt.Errorf("resample: invalid file hash %s", clean.Log(hash)) } if len(imageFilename) < 4 { - return "", fmt.Errorf("resample: invalid file name %s", sanitize.Log(imageFilename)) + return "", fmt.Errorf("resample: invalid file name %s", clean.Log(imageFilename)) } fileName, err = FileName(hash, thumbPath, width, height, opts...) @@ -98,7 +98,7 @@ func FromFile(imageFilename, hash, thumbPath string, width, height, orientation img, err := Open(imageFilename, orientation) if err != nil { - log.Debugf("resample: %s in %s", err, sanitize.Log(filepath.Base(imageFilename))) + log.Debugf("resample: %s in %s", err, clean.Log(filepath.Base(imageFilename))) return "", err } @@ -124,7 +124,7 @@ func Create(img image.Image, fileName string, width, height int, opts ...Resampl var quality imaging.EncodeOption - if filepath.Ext(fileName) == "."+string(fs.FormatPng) { + if filepath.Ext(fileName) == "."+string(fs.ImagePNG) { quality = imaging.PNGCompressionLevel(png.DefaultCompression) } else if width <= 150 && height <= 150 { quality = JpegQualitySmall.EncodeOption() @@ -135,7 +135,7 @@ func Create(img image.Image, fileName string, width, height int, opts ...Resampl err = imaging.Save(result, fileName, quality) if err != nil { - log.Debugf("resample: failed to save %s", sanitize.Log(filepath.Base(fileName))) + log.Debugf("resample: failed to save %s", clean.Log(filepath.Base(fileName))) return result, err } diff --git a/internal/thumb/create_test.go b/internal/thumb/create_test.go index fe5fd7d44..bcec797e2 100644 --- a/internal/thumb/create_test.go +++ b/internal/thumb/create_test.go @@ -16,21 +16,21 @@ func TestResampleOptions(t *testing.T) { assert.Equal(t, ResampleFillCenter, method) assert.Equal(t, imaging.Lanczos.Support, filter.Support) - assert.Equal(t, fs.FormatPng, format) + assert.Equal(t, fs.ImagePNG, format) }) t.Run("ResampleNearestNeighbor, FillTopLeft", func(t *testing.T) { method, filter, format := ResampleOptions(ResampleNearestNeighbor, ResampleFillTopLeft) assert.Equal(t, ResampleFillTopLeft, method) assert.Equal(t, imaging.NearestNeighbor.Support, filter.Support) - assert.Equal(t, fs.FormatJpeg, format) + assert.Equal(t, fs.ImageJPEG, format) }) t.Run("ResampleNearestNeighbor, FillBottomRight", func(t *testing.T) { method, filter, format := ResampleOptions(ResampleNearestNeighbor, ResampleFillBottomRight) assert.Equal(t, ResampleFillBottomRight, method) assert.Equal(t, imaging.NearestNeighbor.Support, filter.Support) - assert.Equal(t, fs.FormatJpeg, format) + assert.Equal(t, fs.ImageJPEG, format) }) } diff --git a/internal/thumb/jpeg.go b/internal/thumb/jpeg.go index 51e0ee95d..bddabd194 100644 --- a/internal/thumb/jpeg.go +++ b/internal/thumb/jpeg.go @@ -5,14 +5,14 @@ import ( "path/filepath" "github.com/disintegration/imaging" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) func Jpeg(srcFilename, jpgFilename string, orientation int) (img image.Image, err error) { img, err = imaging.Open(srcFilename) if err != nil { - log.Errorf("resample: cannot open %s", sanitize.Log(filepath.Base(srcFilename))) + log.Errorf("resample: cannot open %s", clean.Log(filepath.Base(srcFilename))) return img, err } @@ -23,7 +23,7 @@ func Jpeg(srcFilename, jpgFilename string, orientation int) (img image.Image, er quality := JpegQuality.EncodeOption() if err = imaging.Save(img, jpgFilename, quality); err != nil { - log.Errorf("resample: failed to save %s", sanitize.Log(filepath.Base(jpgFilename))) + log.Errorf("resample: failed to save %s", clean.Log(filepath.Base(jpgFilename))) return img, err } diff --git a/internal/thumb/jpeg_test.go b/internal/thumb/jpeg_test.go index 28513e45c..825931887 100644 --- a/internal/thumb/jpeg_test.go +++ b/internal/thumb/jpeg_test.go @@ -14,7 +14,7 @@ func TestJpeg(t *testing.T) { for _, ext := range formats { t.Run(ext, func(t *testing.T) { src := "testdata/example." + ext - dst := "testdata/example." + ext + fs.JpegExt + dst := "testdata/example." + ext + fs.ExtJPEG assert.NoFileExists(t, dst) @@ -36,7 +36,7 @@ func TestJpeg(t *testing.T) { }) t.Run("OrientationFlipH", func(t *testing.T) { src := "testdata/example." + ext - dst := "testdata/example." + ext + fs.JpegExt + dst := "testdata/example." + ext + fs.ExtJPEG assert.NoFileExists(t, dst) @@ -58,7 +58,7 @@ func TestJpeg(t *testing.T) { }) t.Run("OrientationFlipV", func(t *testing.T) { src := "testdata/example." + ext - dst := "testdata/example." + ext + fs.JpegExt + dst := "testdata/example." + ext + fs.ExtJPEG assert.NoFileExists(t, dst) @@ -80,7 +80,7 @@ func TestJpeg(t *testing.T) { }) t.Run("OrientationRotate90", func(t *testing.T) { src := "testdata/example." + ext - dst := "testdata/example." + ext + fs.JpegExt + dst := "testdata/example." + ext + fs.ExtJPEG assert.NoFileExists(t, dst) @@ -102,7 +102,7 @@ func TestJpeg(t *testing.T) { }) t.Run("OrientationRotate180", func(t *testing.T) { src := "testdata/example." + ext - dst := "testdata/example." + ext + fs.JpegExt + dst := "testdata/example." + ext + fs.ExtJPEG assert.NoFileExists(t, dst) @@ -124,7 +124,7 @@ func TestJpeg(t *testing.T) { }) t.Run("OrientationTranspose", func(t *testing.T) { src := "testdata/example." + ext - dst := "testdata/example." + ext + fs.JpegExt + dst := "testdata/example." + ext + fs.ExtJPEG assert.NoFileExists(t, dst) @@ -146,7 +146,7 @@ func TestJpeg(t *testing.T) { }) t.Run("OrientationTransverse", func(t *testing.T) { src := "testdata/example." + ext - dst := "testdata/example." + ext + fs.JpegExt + dst := "testdata/example." + ext + fs.ExtJPEG assert.NoFileExists(t, dst) @@ -168,7 +168,7 @@ func TestJpeg(t *testing.T) { }) t.Run("OrientationUnspecified", func(t *testing.T) { src := "testdata/example." + ext - dst := "testdata/example." + ext + fs.JpegExt + dst := "testdata/example." + ext + fs.ExtJPEG assert.NoFileExists(t, dst) @@ -190,7 +190,7 @@ func TestJpeg(t *testing.T) { }) t.Run("OrientationNormal", func(t *testing.T) { src := "testdata/example." + ext - dst := "testdata/example." + ext + fs.JpegExt + dst := "testdata/example." + ext + fs.ExtJPEG assert.NoFileExists(t, dst) @@ -212,7 +212,7 @@ func TestJpeg(t *testing.T) { }) t.Run("invalid orientation", func(t *testing.T) { src := "testdata/example." + ext - dst := "testdata/example." + ext + fs.JpegExt + dst := "testdata/example." + ext + fs.ExtJPEG assert.NoFileExists(t, dst) diff --git a/internal/thumb/names.go b/internal/thumb/names.go index 542986210..e91dc2941 100644 --- a/internal/thumb/names.go +++ b/internal/thumb/names.go @@ -7,7 +7,7 @@ type Name string // Jpeg returns the thumbnail name with a jpeg file extension suffix as string. func (n Name) Jpeg() string { - return string(n) + fs.JpegExt + return string(n) + fs.ExtJPEG } // String returns the thumbnail name as string. diff --git a/internal/thumb/open.go b/internal/thumb/open.go index 37143bb6b..8fa11cf58 100644 --- a/internal/thumb/open.go +++ b/internal/thumb/open.go @@ -18,7 +18,7 @@ func Open(fileName string, orientation int) (result image.Image, err error) { } // Open JPEG? - if StandardRGB && fs.FileFormat(fileName) == fs.FormatJpeg { + if StandardRGB && fs.FileType(fileName) == fs.ImageJPEG { return OpenJpeg(fileName, orientation) } diff --git a/internal/thumb/open_jpeg.go b/internal/thumb/open_jpeg.go index e06c4f5ed..85c3301a6 100644 --- a/internal/thumb/open_jpeg.go +++ b/internal/thumb/open_jpeg.go @@ -9,8 +9,8 @@ import ( "github.com/disintegration/imaging" "github.com/mandykoh/prism/meta/autometa" + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/colors" - "github.com/photoprism/photoprism/pkg/sanitize" ) // OpenJpeg loads a JPEG image from disk, rotates it, and converts the color profile if necessary. @@ -19,7 +19,7 @@ func OpenJpeg(fileName string, orientation int) (result image.Image, err error) return result, fmt.Errorf("filename missing") } - logName := sanitize.Log(filepath.Base(fileName)) + logName := clean.Log(filepath.Base(fileName)) // Open file. fileReader, err := os.Open(fileName) @@ -52,7 +52,7 @@ func OpenJpeg(fileName string, orientation int) (result image.Image, err error) // Do nothing. log.Tracef("resample: %s has no color profile", logName) } else if profile, err := iccProfile.Description(); err == nil && profile != "" { - log.Tracef("resample: %s has color profile %s", logName, sanitize.Log(profile)) + log.Tracef("resample: %s has color profile %s", logName, clean.Log(profile)) switch { case colors.ProfileDisplayP3.Equal(profile): img = colors.ToSRGB(img, colors.ProfileDisplayP3) diff --git a/internal/thumb/resample_options.go b/internal/thumb/resample_options.go index 1d30c7d22..0ce0f5a6a 100644 --- a/internal/thumb/resample_options.go +++ b/internal/thumb/resample_options.go @@ -27,15 +27,15 @@ var ResampleMethods = map[ResampleOption]string{ } // ResampleOptions extracts filter, format, and method from resample options. -func ResampleOptions(opts ...ResampleOption) (method ResampleOption, filter imaging.ResampleFilter, format fs.Format) { +func ResampleOptions(opts ...ResampleOption) (method ResampleOption, filter imaging.ResampleFilter, format fs.Type) { method = ResampleFit filter = imaging.Lanczos - format = fs.FormatJpeg + format = fs.ImageJPEG for _, option := range opts { switch option { case ResamplePng: - format = fs.FormatPng + format = fs.ImagePNG case ResampleNearestNeighbor: filter = imaging.NearestNeighbor case ResampleDefault: diff --git a/internal/video/codecs.go b/internal/video/codecs.go deleted file mode 100644 index a3c2af656..000000000 --- a/internal/video/codecs.go +++ /dev/null @@ -1,9 +0,0 @@ -package video - -type Codec string - -const ( - CodecAVC Codec = "avc1" - CodecHEVC Codec = "hvc1" - CodecAV1 Codec = "av01" -) diff --git a/internal/video/formats.go b/internal/video/formats.go deleted file mode 100644 index fc63f4c57..000000000 --- a/internal/video/formats.go +++ /dev/null @@ -1,57 +0,0 @@ -package video - -import ( - "github.com/photoprism/photoprism/pkg/fs" -) - -// Format represents a video format standard. -type Format struct { - File fs.Format - Codec Codec - Width int - Height int - Public bool -} - -// FormatNames maps names to video format standards. -type FormatNames map[string]Format - -var MP4 = Format{ - File: fs.FormatMp4, - Codec: CodecAVC, - Width: 0, - Height: 0, - Public: true, -} - -var AVC = Format{ - File: fs.FormatAVC, - Codec: CodecAVC, - Width: 0, - Height: 0, - Public: true, -} - -var AV1 = Format{ - File: fs.FormatAV1, - Codec: CodecAV1, - Width: 0, - Height: 0, - Public: false, -} - -var HEVC = Format{ - File: fs.FormatHEVC, - Codec: CodecHEVC, - Width: 0, - Height: 0, - Public: false, -} - -var Formats = FormatNames{ - "": AVC, - "mp4": MP4, - "avc": AVC, - "av1": AV1, - "hevc": HEVC, -} diff --git a/internal/workers/sync_refresh.go b/internal/workers/sync_refresh.go index 086e54e71..8b1c25874 100644 --- a/internal/workers/sync_refresh.go +++ b/internal/workers/sync_refresh.go @@ -5,7 +5,7 @@ import ( "github.com/photoprism/photoprism/internal/mutex" "github.com/photoprism/photoprism/internal/remote" "github.com/photoprism/photoprism/internal/remote/webdav" - "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/media" ) // Updates the local list of remote files so that they can be downloaded in batches @@ -49,11 +49,11 @@ func (worker *Sync) refresh(a entity.Account) (complete bool, err error) { f.RemoteSize = file.Size // Select supported types for download - mediaType := fs.GetMediaType(file.Name) - switch mediaType { - case fs.MediaImage, fs.MediaSidecar: + content := media.FromName(file.Name) + switch content { + case media.Image, media.Sidecar: f.Status = entity.FileSyncNew - case fs.MediaRaw, fs.MediaVideo: + case media.Raw, media.Video: if a.SyncRaw { f.Status = entity.FileSyncNew } @@ -66,7 +66,7 @@ func (worker *Sync) refresh(a entity.Account) (complete bool, err error) { continue } - if f.Status == entity.FileSyncIgnore && a.SyncRaw && (mediaType == fs.MediaRaw || mediaType == fs.MediaVideo) { + if f.Status == entity.FileSyncIgnore && a.SyncRaw && (content == media.Raw || content == media.Video) { worker.logError(f.Update("Status", entity.FileSyncNew)) } diff --git a/internal/workers/sync_upload.go b/internal/workers/sync_upload.go index 11eac0a6d..ab4b32e8e 100644 --- a/internal/workers/sync_upload.go +++ b/internal/workers/sync_upload.go @@ -11,7 +11,7 @@ import ( "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/remote/webdav" - "github.com/photoprism/photoprism/pkg/sanitize" + "github.com/photoprism/photoprism/pkg/clean" ) // Uploads local files to a remote account @@ -55,7 +55,7 @@ func (worker *Sync) upload(a entity.Account) (complete bool, err error) { continue // try again next time } - log.Infof("sync: uploaded %s to %s (%s)", sanitize.Log(file.FileName), sanitize.Log(remoteName), a.AccName) + log.Infof("sync: uploaded %s to %s (%s)", clean.Log(file.FileName), clean.Log(remoteName), a.AccName) fileSync := entity.NewFileSync(a.ID, remoteName) fileSync.Status = entity.FileSyncUploaded diff --git a/pkg/clean/ascii.go b/pkg/clean/ascii.go new file mode 100644 index 000000000..f0550a9da --- /dev/null +++ b/pkg/clean/ascii.go @@ -0,0 +1,14 @@ +package clean + +// ASCII removes all non-ascii characters from a string and returns it. +func ASCII(s string) string { + result := make([]rune, 0, len(s)) + + for _, r := range s { + if r <= 127 { + result = append(result, r) + } + } + + return string(result) +} diff --git a/pkg/clean/clip.go b/pkg/clean/clip.go new file mode 100644 index 000000000..659a69296 --- /dev/null +++ b/pkg/clean/clip.go @@ -0,0 +1,20 @@ +package clean + +import "strings" + +const ( + ClipType = 64 + ClipShortType = 8 +) + +// Clip shortens a string to the given number of characters, and removes all leading and trailing white space. +func Clip(s string, maxLen int) string { + s = strings.TrimSpace(s) + l := len(s) + + if l <= maxLen { + return s + } else { + return strings.TrimSpace(s[:maxLen]) + } +} diff --git a/pkg/sanitize/const.go b/pkg/clean/const.go similarity index 93% rename from pkg/sanitize/const.go rename to pkg/clean/const.go index 090864cf2..35b3c4b99 100644 --- a/pkg/sanitize/const.go +++ b/pkg/clean/const.go @@ -1,4 +1,4 @@ -package sanitize +package clean const ( EnOr = "or" diff --git a/pkg/sanitize/filename.go b/pkg/clean/filename.go similarity index 97% rename from pkg/sanitize/filename.go rename to pkg/clean/filename.go index e54754c86..e715f5b52 100644 --- a/pkg/sanitize/filename.go +++ b/pkg/clean/filename.go @@ -1,4 +1,4 @@ -package sanitize +package clean import ( "strings" diff --git a/pkg/sanitize/filename_test.go b/pkg/clean/filename_test.go similarity index 98% rename from pkg/sanitize/filename_test.go rename to pkg/clean/filename_test.go index 0e91863ce..881654b3d 100644 --- a/pkg/sanitize/filename_test.go +++ b/pkg/clean/filename_test.go @@ -1,4 +1,4 @@ -package sanitize +package clean import ( "testing" diff --git a/pkg/sanitize/hex.go b/pkg/clean/hex.go similarity index 95% rename from pkg/sanitize/hex.go rename to pkg/clean/hex.go index f1a21d634..995e8192f 100644 --- a/pkg/sanitize/hex.go +++ b/pkg/clean/hex.go @@ -1,4 +1,4 @@ -package sanitize +package clean import ( "strings" diff --git a/pkg/sanitize/hex_test.go b/pkg/clean/hex_test.go similarity index 96% rename from pkg/sanitize/hex_test.go rename to pkg/clean/hex_test.go index 826ae310a..313831860 100644 --- a/pkg/sanitize/hex_test.go +++ b/pkg/clean/hex_test.go @@ -1,4 +1,4 @@ -package sanitize +package clean import ( "testing" diff --git a/pkg/sanitize/id.go b/pkg/clean/id.go similarity index 97% rename from pkg/sanitize/id.go rename to pkg/clean/id.go index a64634c57..5f290d7ef 100644 --- a/pkg/sanitize/id.go +++ b/pkg/clean/id.go @@ -1,4 +1,4 @@ -package sanitize +package clean import ( "strconv" diff --git a/pkg/sanitize/id_test.go b/pkg/clean/id_test.go similarity index 98% rename from pkg/sanitize/id_test.go rename to pkg/clean/id_test.go index 1e46963d0..00d9d1e56 100644 --- a/pkg/sanitize/id_test.go +++ b/pkg/clean/id_test.go @@ -1,4 +1,4 @@ -package sanitize +package clean import ( "testing" diff --git a/pkg/sanitize/log.go b/pkg/clean/log.go similarity index 97% rename from pkg/sanitize/log.go rename to pkg/clean/log.go index 247b9b931..94f3d7373 100644 --- a/pkg/sanitize/log.go +++ b/pkg/clean/log.go @@ -1,4 +1,4 @@ -package sanitize +package clean import ( "fmt" diff --git a/pkg/sanitize/log_test.go b/pkg/clean/log_test.go similarity index 98% rename from pkg/sanitize/log_test.go rename to pkg/clean/log_test.go index 91f38fe72..60f8e98fd 100644 --- a/pkg/sanitize/log_test.go +++ b/pkg/clean/log_test.go @@ -1,4 +1,4 @@ -package sanitize +package clean import ( "testing" diff --git a/pkg/sanitize/name.go b/pkg/clean/name.go similarity index 97% rename from pkg/sanitize/name.go rename to pkg/clean/name.go index ea94a0d13..68964b364 100644 --- a/pkg/sanitize/name.go +++ b/pkg/clean/name.go @@ -1,4 +1,4 @@ -package sanitize +package clean import ( "strings" diff --git a/pkg/sanitize/name_test.go b/pkg/clean/name_test.go similarity index 98% rename from pkg/sanitize/name_test.go rename to pkg/clean/name_test.go index ca1ccfee8..433db8538 100644 --- a/pkg/sanitize/name_test.go +++ b/pkg/clean/name_test.go @@ -1,4 +1,4 @@ -package sanitize +package clean import ( "testing" diff --git a/pkg/sanitize/path.go b/pkg/clean/path.go similarity index 97% rename from pkg/sanitize/path.go rename to pkg/clean/path.go index 054b7b86b..c6f0ad9e8 100644 --- a/pkg/sanitize/path.go +++ b/pkg/clean/path.go @@ -1,4 +1,4 @@ -package sanitize +package clean import ( "strings" diff --git a/pkg/sanitize/path_test.go b/pkg/clean/path_test.go similarity index 98% rename from pkg/sanitize/path_test.go rename to pkg/clean/path_test.go index 73a338694..df74e1578 100644 --- a/pkg/sanitize/path_test.go +++ b/pkg/clean/path_test.go @@ -1,4 +1,4 @@ -package sanitize +package clean import ( "testing" diff --git a/pkg/sanitize/sanitize.go b/pkg/clean/sanitize.go similarity index 98% rename from pkg/sanitize/sanitize.go rename to pkg/clean/sanitize.go index e43894b37..2ac339601 100644 --- a/pkg/sanitize/sanitize.go +++ b/pkg/clean/sanitize.go @@ -24,7 +24,7 @@ Additional information can be found in our Developer Guide: */ -package sanitize +package clean import "strings" diff --git a/pkg/sanitize/search.go b/pkg/clean/search.go similarity index 98% rename from pkg/sanitize/search.go rename to pkg/clean/search.go index 6a5eb9447..174c94f56 100644 --- a/pkg/sanitize/search.go +++ b/pkg/clean/search.go @@ -1,4 +1,4 @@ -package sanitize +package clean import ( "regexp" diff --git a/pkg/sanitize/search_test.go b/pkg/clean/search_test.go similarity index 98% rename from pkg/sanitize/search_test.go rename to pkg/clean/search_test.go index c8e83d9ed..d3ed5a4e7 100644 --- a/pkg/sanitize/search_test.go +++ b/pkg/clean/search_test.go @@ -1,4 +1,4 @@ -package sanitize +package clean import ( "testing" diff --git a/pkg/sanitize/sql.go b/pkg/clean/sql.go similarity index 98% rename from pkg/sanitize/sql.go rename to pkg/clean/sql.go index edf84bfe0..ba41876a6 100644 --- a/pkg/sanitize/sql.go +++ b/pkg/clean/sql.go @@ -1,4 +1,4 @@ -package sanitize +package clean // SqlSpecial checks if the byte must be escaped/omitted in SQL. func SqlSpecial(b byte) (special bool, omit bool) { diff --git a/pkg/sanitize/sql_test.go b/pkg/clean/sql_test.go similarity index 99% rename from pkg/sanitize/sql_test.go rename to pkg/clean/sql_test.go index c85efcd73..5da499d28 100644 --- a/pkg/sanitize/sql_test.go +++ b/pkg/clean/sql_test.go @@ -1,4 +1,4 @@ -package sanitize +package clean import ( "testing" diff --git a/pkg/sanitize/state.go b/pkg/clean/state.go similarity index 98% rename from pkg/sanitize/state.go rename to pkg/clean/state.go index ad8314e60..e89607b6b 100644 --- a/pkg/sanitize/state.go +++ b/pkg/clean/state.go @@ -1,4 +1,4 @@ -package sanitize +package clean import ( "strings" diff --git a/pkg/sanitize/state_test.go b/pkg/clean/state_test.go similarity index 98% rename from pkg/sanitize/state_test.go rename to pkg/clean/state_test.go index 15f63c2ef..a9d19ddbd 100644 --- a/pkg/sanitize/state_test.go +++ b/pkg/clean/state_test.go @@ -1,4 +1,4 @@ -package sanitize +package clean import ( "testing" diff --git a/pkg/sanitize/token.go b/pkg/clean/token.go similarity index 95% rename from pkg/sanitize/token.go rename to pkg/clean/token.go index 457608a09..c4522436c 100644 --- a/pkg/sanitize/token.go +++ b/pkg/clean/token.go @@ -1,4 +1,4 @@ -package sanitize +package clean import ( "strings" diff --git a/pkg/sanitize/token_test.go b/pkg/clean/token_test.go similarity index 96% rename from pkg/sanitize/token_test.go rename to pkg/clean/token_test.go index 12efd0711..387956ae9 100644 --- a/pkg/sanitize/token_test.go +++ b/pkg/clean/token_test.go @@ -1,4 +1,4 @@ -package sanitize +package clean import ( "testing" diff --git a/pkg/clean/type.go b/pkg/clean/type.go new file mode 100644 index 000000000..4d6a30a14 --- /dev/null +++ b/pkg/clean/type.go @@ -0,0 +1,34 @@ +package clean + +import ( + "strings" +) + +// Type omits invalid runes, ensures a maximum length of 32 characters, and returns the result. +func Type(s string) string { + return Clip(ASCII(s), ClipType) +} + +// TypeLower converts a type string to lowercase, omits invalid runes, and shortens it if needed. +func TypeLower(s string) string { + return Type(strings.ToLower(s)) +} + +// ShortType omits invalid runes, ensures a maximum length of 8 characters, and returns the result. +func ShortType(s string) string { + return Clip(ASCII(s), ClipShortType) +} + +// ShortTypeLower converts a short type string to lowercase, omits invalid runes, and shortens it if needed. +func ShortTypeLower(s string) string { + return ShortType(strings.ToLower(s)) +} + +// LogType returns an entity type string for logging. +func LogType(entityType string) string { + if entityType == "" { + return "" + } + + return entityType +} diff --git a/pkg/clean/type_test.go b/pkg/clean/type_test.go new file mode 100644 index 000000000..a38d6a6e7 --- /dev/null +++ b/pkg/clean/type_test.go @@ -0,0 +1,65 @@ +package clean + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestToASCII(t *testing.T) { + result := ASCII("幸福 = Happiness.") + assert.Equal(t, " = Happiness.", result) +} + +func TestClip(t *testing.T) { + t.Run("Foo", func(t *testing.T) { + result := Clip("Foo", 16) + assert.Equal(t, "Foo", result) + assert.Equal(t, 3, len(result)) + }) + t.Run("TrimFoo", func(t *testing.T) { + result := Clip(" Foo ", 16) + assert.Equal(t, "Foo", result) + assert.Equal(t, 3, len(result)) + }) + t.Run("TooLong", func(t *testing.T) { + result := Clip(" 幸福 Hanzi are logograms developed for the writing of Chinese! ", 16) + assert.Equal(t, "幸福 Hanzi are", result) + assert.Equal(t, 16, len(result)) + }) + t.Run("ToASCII", func(t *testing.T) { + result := Clip(ASCII(strings.ToLower(" 幸福 Hanzi are logograms developed for the writing of Chinese! Expressions in an index may not ...!")), ClipType) + assert.Equal(t, "hanzi are logograms developed for the writing of chinese! expres", result) + assert.Equal(t, 64, len(result)) + }) + t.Run("Empty", func(t *testing.T) { + result := Clip("", 999) + assert.Equal(t, "", result) + assert.Equal(t, 0, len(result)) + }) +} + +func TestType(t *testing.T) { + result := Type(" 幸福 Hanzi are logograms developed for the writing of Chinese! Expressions in an index may not ...!") + assert.Equal(t, "Hanzi are logograms developed for the writing of Chinese! Expres", result) + assert.Equal(t, ClipType, len(result)) +} + +func TestTypeLower(t *testing.T) { + result := TypeLower(" 幸福 Hanzi are logograms developed for the writing of Chinese! Expressions in an index may not ...!") + assert.Equal(t, "hanzi are logograms developed for the writing of chinese! expres", result) + assert.Equal(t, ClipType, len(result)) +} + +func TestShortType(t *testing.T) { + result := ShortType(" 幸福 Hanzi are logograms developed for the writing of Chinese! Expressions in an index may not ...!") + assert.Equal(t, "Hanzi ar", result) + assert.Equal(t, ClipShortType, len(result)) +} + +func TestShortTypeLower(t *testing.T) { + result := ShortTypeLower(" 幸福 Hanzi are logograms developed for the writing of Chinese! Expressions in an index may not ...!") + assert.Equal(t, "hanzi ar", result) + assert.Equal(t, ClipShortType, len(result)) +} diff --git a/pkg/sanitize/username.go b/pkg/clean/username.go similarity index 94% rename from pkg/sanitize/username.go rename to pkg/clean/username.go index 4e255da10..ed1fceb73 100644 --- a/pkg/sanitize/username.go +++ b/pkg/clean/username.go @@ -1,4 +1,4 @@ -package sanitize +package clean import ( "strings" diff --git a/pkg/sanitize/username_test.go b/pkg/clean/username_test.go similarity index 95% rename from pkg/sanitize/username_test.go rename to pkg/clean/username_test.go index e02b33b17..e8b7571e0 100644 --- a/pkg/sanitize/username_test.go +++ b/pkg/clean/username_test.go @@ -1,4 +1,4 @@ -package sanitize +package clean import ( "testing" diff --git a/pkg/colors/colorful.go b/pkg/colors/colorful.go index bad91e04d..76082cc06 100644 --- a/pkg/colors/colorful.go +++ b/pkg/colors/colorful.go @@ -2,6 +2,7 @@ package colors import "github.com/lucasb-eyer/go-colorful" +// Colorful finds the Color most similar to the specified colorful.Color. func Colorful(actualColor colorful.Color) (result Color) { var distance = 1.0 diff --git a/pkg/colors/colors.go b/pkg/colors/colors.go index 6c7821a52..be5ce0017 100644 --- a/pkg/colors/colors.go +++ b/pkg/colors/colors.go @@ -29,7 +29,8 @@ package colors import ( "fmt" "image/color" - "strings" + + "github.com/photoprism/photoprism/pkg/txt" ) type Color uint8 @@ -137,7 +138,7 @@ func (c Colors) List() []map[string]string { result := make([]map[string]string, 0, len(c)) for _, c := range c { - result = append(result, map[string]string{"Slug": c.Name(), "Name": strings.Title(c.Name()), "Example": ColorExamples[c]}) + result = append(result, map[string]string{"Slug": c.Name(), "Name": txt.UpperFirst(c.Name()), "Example": ColorExamples[c]}) } return result diff --git a/pkg/env/env.go b/pkg/env/env.go new file mode 100644 index 000000000..382a99edb --- /dev/null +++ b/pkg/env/env.go @@ -0,0 +1,27 @@ +/* + +Package env provides runtime environment information. + +Copyright (c) 2018 - 2022 PhotoPrism UG. All rights reserved. + + This program is free software: you can redistribute it and/or modify + it under Version 3 of the GNU Affero General Public License (the "AGPL"): + + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + The AGPL is supplemented by our Trademark and Brand Guidelines, + which describe how our Brand Assets may be used: + + +Feel free to send an email to hello@photoprism.app if you have questions, +want to support our work, or just want to say hello. + +Additional information can be found in our Developer Guide: + + +*/ +package env diff --git a/pkg/env/info.go b/pkg/env/info.go new file mode 100644 index 000000000..f79b8bbaf --- /dev/null +++ b/pkg/env/info.go @@ -0,0 +1,9 @@ +package env + +// Info returns a new Resources instance. +func Info() (resources Resources) { + resources = Resources{} + resources.Update() + + return resources +} diff --git a/internal/config/runtime_test.go b/pkg/env/info_test.go similarity index 86% rename from internal/config/runtime_test.go rename to pkg/env/info_test.go index 9a7b19553..6a63f2de9 100644 --- a/internal/config/runtime_test.go +++ b/pkg/env/info_test.go @@ -1,4 +1,4 @@ -package config +package env import ( "testing" @@ -6,8 +6,8 @@ import ( "github.com/stretchr/testify/assert" ) -func TestNewRuntimeInfo(t *testing.T) { - info := NewRuntimeInfo() +func TestNew(t *testing.T) { + info := Info() assert.LessOrEqual(t, 1, info.Cores) assert.LessOrEqual(t, 1, info.Routines) @@ -19,8 +19,8 @@ func TestNewRuntimeInfo(t *testing.T) { // t.Logf("Free: %s, Total: %s", humanize.Bytes(info.Memory.Free), humanize.Bytes(info.Memory.Total)) } -func TestRuntimeInfo_Refresh(t *testing.T) { - info := NewRuntimeInfo() +func TestInfo_Update(t *testing.T) { + info := Info() assert.LessOrEqual(t, 1, info.Cores) assert.LessOrEqual(t, 1, info.Routines) @@ -29,7 +29,7 @@ func TestRuntimeInfo_Refresh(t *testing.T) { assert.LessOrEqual(t, uint64(1), info.Memory.Reserved) assert.LessOrEqual(t, uint64(1), info.Memory.Used) - info.Refresh() + info.Update() assert.LessOrEqual(t, 1, info.Cores) assert.LessOrEqual(t, 1, info.Routines) diff --git a/internal/config/runtime.go b/pkg/env/resources.go similarity index 67% rename from internal/config/runtime.go rename to pkg/env/resources.go index 5f799bb52..e30a4de8a 100644 --- a/internal/config/runtime.go +++ b/pkg/env/resources.go @@ -1,4 +1,4 @@ -package config +package env import ( "fmt" @@ -8,8 +8,8 @@ import ( "github.com/pbnjay/memory" ) -// RuntimeInfo represents memory and cpu usage statistics. -type RuntimeInfo struct { +// Resources represents runtime resource information. +type Resources struct { Cores int `json:"cores"` Routines int `json:"routines"` Memory struct { @@ -21,16 +21,8 @@ type RuntimeInfo struct { } `json:"memory"` } -// NewRuntimeInfo returns a new RuntimeInfo instance. -func NewRuntimeInfo() (r RuntimeInfo) { - r = RuntimeInfo{} - r.Refresh() - - return r -} - -// Refresh updates runtime info options like number of goroutines and memory usage. -func (r *RuntimeInfo) Refresh() { +// Update runtime resource information. +func (r *Resources) Update() { var mem runtime.MemStats runtime.ReadMemStats(&mem) diff --git a/pkg/fs/case.go b/pkg/fs/case.go index 00932011c..fad2cefc0 100644 --- a/pkg/fs/case.go +++ b/pkg/fs/case.go @@ -24,5 +24,5 @@ func CaseInsensitive(storagePath string) (result bool, err error) { // IgnoreCase enables the case-insensitive mode. func IgnoreCase() { ignoreCase = true - Formats = Extensions.Formats(true) + FileTypes = Extensions.Types(true) } diff --git a/pkg/fs/ext.go b/pkg/fs/ext.go deleted file mode 100644 index be1be3efa..000000000 --- a/pkg/fs/ext.go +++ /dev/null @@ -1,27 +0,0 @@ -package fs - -import ( - "strings" -) - -const ( - YamlExt = ".yml" - JpegExt = ".jpg" - AvcExt = ".avc" - FujiRawExt = ".raf" - CanonCr3Ext = ".cr3" -) - -// NormalizeExt returns the file extension without dot and in lowercase. -func NormalizeExt(fileName string) string { - if dot := strings.LastIndex(fileName, "."); dot != -1 && len(fileName[dot+1:]) >= 1 { - return strings.ToLower(fileName[dot+1:]) - } - - return "" -} - -// TrimExt removes unwanted characters from file extension strings, and makes it lowercase for comparison. -func TrimExt(ext string) string { - return strings.ToLower(strings.Trim(ext, " .,;:“”'`\"")) -} diff --git a/pkg/fs/ext_test.go b/pkg/fs/ext_test.go deleted file mode 100644 index 61ce211f3..000000000 --- a/pkg/fs/ext_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package fs - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestNormalizeExt(t *testing.T) { - t.Run("none", func(t *testing.T) { - result := NormalizeExt("testdata/test") - assert.Equal(t, "", result) - }) - - t.Run("dot", func(t *testing.T) { - result := NormalizeExt("testdata/test.") - assert.Equal(t, "", result) - }) - - t.Run("test.z", func(t *testing.T) { - result := NormalizeExt("testdata/test.z") - assert.Equal(t, "z", result) - }) - - t.Run("test.jpg", func(t *testing.T) { - result := NormalizeExt("testdata/test.jpg") - assert.Equal(t, "jpg", result) - }) - - t.Run("test.PNG", func(t *testing.T) { - result := NormalizeExt("testdata/test.PNG") - assert.Equal(t, "png", result) - }) - - t.Run("test.MOV", func(t *testing.T) { - result := NormalizeExt("testdata/test.MOV") - assert.Equal(t, "mov", result) - }) - - t.Run("test.xmp", func(t *testing.T) { - result := NormalizeExt("testdata/test.xMp") - assert.Equal(t, "xmp", result) - }) - - t.Run("test.MP", func(t *testing.T) { - result := NormalizeExt("testdata/test.mp") - assert.Equal(t, "mp", result) - }) -} - -func TestTrimExt(t *testing.T) { - t.Run("WithDot", func(t *testing.T) { - assert.Equal(t, "raf", TrimExt(".raf")) - }) - t.Run("Normalized", func(t *testing.T) { - assert.Equal(t, "cr3", TrimExt("cr3")) - }) - t.Run("Uppercase", func(t *testing.T) { - assert.Equal(t, "aaf", TrimExt("AAF")) - }) - t.Run("Empty", func(t *testing.T) { - assert.Equal(t, "", TrimExt("")) - }) - t.Run("MixedCaseWithDot", func(t *testing.T) { - assert.Equal(t, "raw", TrimExt(".Raw")) - }) - t.Run("TypographicQuotes", func(t *testing.T) { - assert.Equal(t, "jpeg", TrimExt(" “JPEG” ")) - }) -} diff --git a/pkg/fs/extension.go b/pkg/fs/extension.go deleted file mode 100644 index c19baf2f7..000000000 --- a/pkg/fs/extension.go +++ /dev/null @@ -1,36 +0,0 @@ -package fs - -import ( - "path/filepath" - "strings" -) - -// Ext returns all extension of a file name including the dots. -func Ext(name string) string { - ext := filepath.Ext(name) - name = StripExt(name) - - if Extensions.Known(name) { - ext = filepath.Ext(name) + ext - } - - return ext -} - -// StripExt removes the file type extension from a file name (if any). -func StripExt(name string) string { - if end := strings.LastIndex(name, "."); end != -1 { - name = name[:end] - } - - return name -} - -// StripKnownExt removes all known file type extension from a file name (if any). -func StripKnownExt(name string) string { - for Extensions.Known(name) { - name = StripExt(name) - } - - return name -} diff --git a/pkg/fs/extensions.go b/pkg/fs/extensions.go deleted file mode 100644 index 311423b14..000000000 --- a/pkg/fs/extensions.go +++ /dev/null @@ -1,152 +0,0 @@ -package fs - -import ( - "path/filepath" - "strings" -) - -// FileExtensions maps file extensions to standard formats -type FileExtensions map[string]Format - -// Extensions contains the filename extensions of file formats known to PhotoPrism. -var Extensions = FileExtensions{ - ".jpg": FormatJpeg, - ".jpeg": FormatJpeg, - ".jpe": FormatJpeg, - ".jif": FormatJpeg, - ".jfif": FormatJpeg, - ".jfi": FormatJpeg, - ".thm": FormatJpeg, - ".3fr": FormatRaw, - ".ari": FormatRaw, - ".arw": FormatRaw, - ".bay": FormatRaw, - ".cap": FormatRaw, - ".crw": FormatRaw, - ".cr2": FormatRaw, - ".cr3": FormatRaw, - ".data": FormatRaw, - ".dcs": FormatRaw, - ".dcr": FormatRaw, - ".dng": FormatRaw, - ".drf": FormatRaw, - ".eip": FormatRaw, - ".erf": FormatRaw, - ".fff": FormatRaw, - ".gpr": FormatRaw, - ".iiq": FormatRaw, - ".k25": FormatRaw, - ".kdc": FormatRaw, - ".mdc": FormatRaw, - ".mef": FormatRaw, - ".mos": FormatRaw, - ".mrw": FormatRaw, - ".nef": FormatRaw, - ".nrw": FormatRaw, - ".obm": FormatRaw, - ".orf": FormatRaw, - ".pef": FormatRaw, - ".ptx": FormatRaw, - ".pxn": FormatRaw, - ".r3d": FormatRaw, - ".raf": FormatRaw, - ".raw": FormatRaw, - ".rwl": FormatRaw, - ".rwz": FormatRaw, - ".rw2": FormatRaw, - ".srf": FormatRaw, - ".srw": FormatRaw, - ".sr2": FormatRaw, - ".x3f": FormatRaw, - ".png": FormatPng, - ".pn": FormatPng, - ".tif": FormatTiff, - ".tiff": FormatTiff, - ".gif": FormatGif, - ".bmp": FormatBitmap, - ".heif": FormatHEIF, - ".heic": FormatHEIF, - ".hevc": FormatHEVC, - ".mov": FormatMov, - ".qt": FormatMov, - ".avi": FormatAvi, - ".av1": FormatAV1, - ".avc": FormatAVC, - ".mpg": FormatMPEG, - ".mpeg": FormatMPEG, - ".mjpg": FormatMJPEG, - ".mjpeg": FormatMJPEG, - ".mp2": FormatMp2, - ".mpv": FormatMp2, - ".mp": FormatMp4, - ".mp4": FormatMp4, - ".m4v": FormatMp4, - ".3gp": Format3gp, - ".3g2": Format3g2, - ".flv": FormatFlv, - ".f4v": FormatFlv, - ".mkv": FormatMkv, - ".mpo": FormatMpo, - ".mts": FormatMts, - ".ogv": FormatOgv, - ".ogg": FormatOgv, - ".ogx": FormatOgv, - ".webp": FormatWebP, - ".webm": FormatWebM, - ".wmv": FormatWMV, - ".aae": FormatAAE, - ".md": FormatMarkdown, - ".markdown": FormatMarkdown, - ".json": FormatJson, - ".toml": FormatToml, - ".txt": FormatText, - ".yml": FormatYaml, - ".yaml": FormatYaml, - ".xmp": FormatXMP, - ".xml": FormatXML, -} - -// Known tests if the file extension is known (supported). -func (m FileExtensions) Known(name string) bool { - if name == "" { - return false - } - - ext := strings.ToLower(filepath.Ext(name)) - - if ext == "" { - return false - } - - if _, ok := m[ext]; ok { - return true - } - - return false -} - -// Formats returns all known file extensions by format. -func (m FileExtensions) Formats(noUppercase bool) FileFormats { - result := make(FileFormats) - - if noUppercase { - for ext, t := range m { - if _, ok := result[t]; ok { - result[t] = append(result[t], ext) - } else { - result[t] = []string{ext} - } - } - } else { - for ext, t := range m { - extUpper := strings.ToUpper(ext) - if _, ok := result[t]; ok { - result[t] = append(result[t], ext, extUpper) - } else { - result[t] = []string{ext, extUpper} - } - } - } - - return result -} diff --git a/pkg/fs/file_ext.go b/pkg/fs/file_ext.go new file mode 100644 index 000000000..0c90a2c95 --- /dev/null +++ b/pkg/fs/file_ext.go @@ -0,0 +1,65 @@ +package fs + +import ( + "path/filepath" + "strings" +) + +const ( + ExtYAML = ".yml" + ExtJPEG = ".jpg" + ExtAVC = ".avc" +) + +// Ext returns all extension of a file name including the dots. +func Ext(name string) string { + ext := filepath.Ext(name) + name = StripExt(name) + + if Extensions.Known(name) { + ext = filepath.Ext(name) + ext + } + + return ext +} + +// NormalizedExt returns the file extension without dot and in lowercase. +func NormalizedExt(fileName string) string { + if dot := strings.LastIndex(fileName, "."); dot != -1 && len(fileName[dot+1:]) >= 1 { + return strings.ToLower(fileName[dot+1:]) + } + + return "" +} + +// LowerExt returns the file name extension with dot in lower case. +func LowerExt(fileName string) string { + if fileName == "" { + return "" + } + + return strings.ToLower(filepath.Ext(fileName)) +} + +// TrimExt removes unwanted characters from file extension strings, and makes it lowercase for comparison. +func TrimExt(ext string) string { + return strings.ToLower(strings.Trim(ext, " .,;:“”'`\"")) +} + +// StripExt removes the file type extension from a file name (if any). +func StripExt(name string) string { + if end := strings.LastIndex(name, "."); end != -1 { + name = name[:end] + } + + return name +} + +// StripKnownExt removes all known file type extension from a file name (if any). +func StripKnownExt(name string) string { + for Extensions.Known(name) { + name = StripExt(name) + } + + return name +} diff --git a/pkg/fs/extension_test.go b/pkg/fs/file_ext_test.go similarity index 53% rename from pkg/fs/extension_test.go rename to pkg/fs/file_ext_test.go index 0401f4e0d..035aae1f9 100644 --- a/pkg/fs/extension_test.go +++ b/pkg/fs/file_ext_test.go @@ -6,6 +6,69 @@ import ( "github.com/stretchr/testify/assert" ) +func TestNormalizeExt(t *testing.T) { + t.Run("none", func(t *testing.T) { + result := NormalizedExt("testdata/test") + assert.Equal(t, "", result) + }) + + t.Run("dot", func(t *testing.T) { + result := NormalizedExt("testdata/test.") + assert.Equal(t, "", result) + }) + + t.Run("test.z", func(t *testing.T) { + result := NormalizedExt("testdata/test.z") + assert.Equal(t, "z", result) + }) + + t.Run("test.jpg", func(t *testing.T) { + result := NormalizedExt("testdata/test.jpg") + assert.Equal(t, "jpg", result) + }) + + t.Run("test.PNG", func(t *testing.T) { + result := NormalizedExt("testdata/test.PNG") + assert.Equal(t, "png", result) + }) + + t.Run("test.MOV", func(t *testing.T) { + result := NormalizedExt("testdata/test.MOV") + assert.Equal(t, "mov", result) + }) + + t.Run("test.xmp", func(t *testing.T) { + result := NormalizedExt("testdata/test.xMp") + assert.Equal(t, "xmp", result) + }) + + t.Run("test.MP", func(t *testing.T) { + result := NormalizedExt("testdata/test.mp") + assert.Equal(t, "mp", result) + }) +} + +func TestTrimExt(t *testing.T) { + t.Run("WithDot", func(t *testing.T) { + assert.Equal(t, "raf", TrimExt(".raf")) + }) + t.Run("Normalized", func(t *testing.T) { + assert.Equal(t, "cr3", TrimExt("cr3")) + }) + t.Run("Uppercase", func(t *testing.T) { + assert.Equal(t, "aaf", TrimExt("AAF")) + }) + t.Run("Empty", func(t *testing.T) { + assert.Equal(t, "", TrimExt("")) + }) + t.Run("MixedCaseWithDot", func(t *testing.T) { + assert.Equal(t, "raw", TrimExt(".Raw")) + }) + t.Run("TypographicQuotes", func(t *testing.T) { + assert.Equal(t, "jpeg", TrimExt(" “JPEG” ")) + }) +} + func TestStripExt(t *testing.T) { t.Run("Test.jpg", func(t *testing.T) { result := StripExt("/testdata/Test.jpg") diff --git a/pkg/fs/file_exts.go b/pkg/fs/file_exts.go new file mode 100644 index 000000000..9dc07e347 --- /dev/null +++ b/pkg/fs/file_exts.go @@ -0,0 +1,160 @@ +package fs + +import ( + "path/filepath" + "strings" +) + +// FileExtensions maps file extensions to standard formats +type FileExtensions map[string]Type + +// Extensions contains the filename extensions of file formats known to PhotoPrism. +var Extensions = FileExtensions{ + ".jpg": ImageJPEG, + ".jpeg": ImageJPEG, + ".jpe": ImageJPEG, + ".jif": ImageJPEG, + ".jfif": ImageJPEG, + ".jfi": ImageJPEG, + ".thm": ImageJPEG, + ".heif": ImageHEIF, + ".heic": ImageHEIF, + ".heifs": ImageHEIF, + ".heics": ImageHEIF, + ".avci": ImageHEIF, + ".avcs": ImageHEIF, + ".avif": ImageHEIF, + ".avifs": ImageHEIF, + ".webp": ImageWebP, + ".tif": ImageTIFF, + ".tiff": ImageTIFF, + ".png": ImagePNG, + ".pn": ImagePNG, + ".mpo": ImageMPO, + ".gif": ImageGIF, + ".bmp": ImageBMP, + ".3fr": RawImage, + ".ari": RawImage, + ".arw": RawImage, + ".bay": RawImage, + ".cap": RawImage, + ".crw": RawImage, + ".cr2": RawImage, + ".cr3": RawImage, + ".data": RawImage, + ".dcs": RawImage, + ".dcr": RawImage, + ".dng": RawImage, + ".drf": RawImage, + ".eip": RawImage, + ".erf": RawImage, + ".fff": RawImage, + ".gpr": RawImage, + ".iiq": RawImage, + ".k25": RawImage, + ".kdc": RawImage, + ".mdc": RawImage, + ".mef": RawImage, + ".mos": RawImage, + ".mrw": RawImage, + ".nef": RawImage, + ".nrw": RawImage, + ".obm": RawImage, + ".orf": RawImage, + ".pef": RawImage, + ".ptx": RawImage, + ".pxn": RawImage, + ".r3d": RawImage, + ".raf": RawImage, + ".raw": RawImage, + ".rwl": RawImage, + ".rwz": RawImage, + ".rw2": RawImage, + ".srf": RawImage, + ".srw": RawImage, + ".sr2": RawImage, + ".x3f": RawImage, + ".hevc": VideoHEVC, + ".mov": VideoMOV, + ".qt": VideoMOV, + ".avi": VideoAVI, + ".av1": VideoAV1, + ".avc": VideoAVC, + ".vvc": VideoVVC, + ".mpg": VideoMPG, + ".mpeg": VideoMPG, + ".mjpg": VideoMJPG, + ".mjpeg": VideoMJPG, + ".mp2": VideoMP2, + ".mpv": VideoMP2, + ".mp": VideoMP4, + ".mp4": VideoMP4, + ".m4v": VideoMP4, + ".3gp": Video3GP, + ".3g2": Video3G2, + ".flv": VideoFlash, + ".f4v": VideoFlash, + ".mkv": VideoMKV, + ".mts": VideoAVCHD, + ".ogv": VideoOGV, + ".ogg": VideoOGV, + ".ogx": VideoOGV, + ".webm": VideoWebM, + ".asf": VideoASF, + ".wmv": VideoWMV, + ".xmp": XmpFile, + ".aae": AaeFile, + ".xml": XmlFile, + ".yml": YamlFile, + ".yaml": YamlFile, + ".json": JsonFile, + ".toml": TomlFile, + ".txt": TextFile, + ".md": MarkdownFile, + ".markdown": MarkdownFile, +} + +// Known tests if the file extension is known (supported). +func (m FileExtensions) Known(name string) bool { + if name == "" { + return false + } + + ext := strings.ToLower(filepath.Ext(name)) + + if ext == "" { + return false + } + + if _, ok := m[ext]; ok { + return true + } + + return false +} + +// TypesExt returns known extensions by file type. +func (m FileExtensions) Types(noUppercase bool) TypesExt { + result := make(TypesExt) + + if noUppercase { + for ext, t := range m { + if _, ok := result[t]; ok { + result[t] = append(result[t], ext) + } else { + result[t] = []string{ext} + } + } + } else { + for ext, t := range m { + extUpper := strings.ToUpper(ext) + if _, ok := result[t]; ok { + result[t] = append(result[t], ext, extUpper) + } else { + result[t] = []string{ext, extUpper} + } + } + } + + return result +} diff --git a/pkg/fs/extensions_test.go b/pkg/fs/file_exts_test.go similarity index 94% rename from pkg/fs/extensions_test.go rename to pkg/fs/file_exts_test.go index 87ffcd0fc..1b5d916d4 100644 --- a/pkg/fs/extensions_test.go +++ b/pkg/fs/file_exts_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestFileExt_Known(t *testing.T) { +func TestFileExtensions_Known(t *testing.T) { t.Run("Empty", func(t *testing.T) { assert.False(t, Extensions.Known("")) }) diff --git a/pkg/fs/file_formats.go b/pkg/fs/file_formats.go deleted file mode 100644 index c75b45af8..000000000 --- a/pkg/fs/file_formats.go +++ /dev/null @@ -1,166 +0,0 @@ -package fs - -import ( - _ "image/gif" - _ "image/jpeg" - _ "image/png" - "sort" - "strings" - "unicode" - - _ "golang.org/x/image/bmp" - _ "golang.org/x/image/tiff" - _ "golang.org/x/image/webp" -) - -// FileFormats maps standard formats to file extensions. -type FileFormats map[Format][]string - -// Formats contains the default file type extensions. -var Formats = Extensions.Formats(ignoreCase) - -// Supported file formats. -const ( - FormatJpeg Format = "jpg" // JPEG image file. - FormatPng Format = "png" // PNG image file. - FormatGif Format = "gif" // GIF image file. - FormatTiff Format = "tiff" // TIFF image file. - FormatBitmap Format = "bmp" // BMP image file. - FormatRaw Format = "raw" // RAW image file. - FormatMpo Format = "mpo" // Stereoscopic Image that consists of two JPG images that are combined into one 3D image - FormatHEIF Format = "heif" // High Efficiency Image File Format - FormatWebP Format = "webp" // Google WebP Image - FormatWebM Format = "webm" // Google WebM Video - FormatHEVC Format = "hevc" // H.265, High Efficiency Video Coding (HEVC) - FormatAVC Format = "avc" // H.264, Advanced Video Coding (AVC), MPEG-4 Part 10, used internally - FormatAV1 Format = "av1" // Alliance for Open Media Video - FormatMPEG Format = "mpg" // Moving Picture Experts Group (MPEG) - FormatMJPEG Format = "mjpg" // Motion JPEG (M-JPEG) - FormatMov Format = "mov" // QuickTime File Format, can contain AVC, HEVC,... - FormatMp2 Format = "mp2" // MPEG-2, H.222/H.262 - FormatMp4 Format = "mp4" // MPEG-4 Container based on QuickTime, can contain AVC, HEVC,... - FormatAvi Format = "avi" // Microsoft Audio Video Interleave (AVI) - Format3gp Format = "3gp" // Mobile Multimedia Container, MPEG-4 Part 12 - Format3g2 Format = "3g2" // Similar to 3GP, consumes less space & bandwidth - FormatFlv Format = "flv" // Flash Video - FormatMkv Format = "mkv" // Matroska Multimedia Container, free and open - FormatMts Format = "mts" // AVCHD (Advanced Video Coding High Definition) - FormatOgv Format = "ogv" // Ogg container format maintained by the Xiph.Org, free and open - FormatWMV Format = "wmv" // Windows Media Video - FormatXMP Format = "xmp" // Adobe XMP sidecar file (XML). - FormatAAE Format = "aae" // Apple sidecar file (XML). - FormatXML Format = "xml" // XML metadata / config / sidecar file. - FormatYaml Format = "yml" // YAML metadata / config / sidecar file. - FormatToml Format = "toml" // Tom's Obvious, Minimal Language sidecar file. - FormatJson Format = "json" // JSON metadata / config / sidecar file. - FormatText Format = "txt" // Text config / sidecar file. - FormatMarkdown Format = "md" // Markdown text sidecar file. - FormatOther Format = "" // Unknown file format. -) - -// FormatDesc contains human-readable descriptions for supported file formats -var FormatDesc = map[Format]string{ - FormatJpeg: "Joint Photographic Experts Group (JPEG)", - FormatPng: "Portable Network Graphics", - FormatGif: "Graphics Interchange Format", - FormatTiff: "Tag Image File Format", - FormatBitmap: "Bitmap", - FormatRaw: "Unprocessed Sensor Data", - FormatMpo: "Stereoscopic (3D JPEG)", - FormatHEIF: "High Efficiency Image File Format (HEIF)", - FormatWebP: "Google WebP", - FormatWebM: "Google WebM", - FormatHEVC: "High Efficiency Video Coding (HEVC, HVC1, H.265)", - FormatAVC: "Advanced Video Coding (AVC, AVC1, H.264, MPEG-4 Part 10)", - FormatAV1: "AOMedia Video 1 (AV1, AV01)", - FormatMov: "Apple QuickTime (QT)", - FormatMp2: "MPEG 2 (H.262, H.222)", - FormatMp4: "Multimedia Container (MPEG-4 Part 14)", - FormatAvi: "Microsoft Audio Video Interleave", - FormatWMV: "Microsoft Windows Media", - Format3gp: "Mobile Multimedia Container (MPEG-4 Part 12)", - Format3g2: "Mobile Multimedia Container for CDMA2000 (based on 3GP)", - FormatFlv: "Flash Video", - FormatMkv: "Matroska Multimedia Container (MKV, MCF, EBML)", - FormatMPEG: "Moving Picture Experts Group (MPEG)", - FormatMJPEG: "Motion JPEG (M-JPEG)", - FormatMts: "Advanced Video Coding High Definition (AVCHD)", - FormatOgv: "Ogg Media by Xiph.Org", - FormatXMP: "Adobe Extensible Metadata Platform", - FormatAAE: "Apple Image Edits", - FormatXML: "Extensible Markup Language", - FormatJson: "Serialized JSON Data (Exiftool, Google Photos)", - FormatYaml: "Serialized YAML Data (Config, Metadata)", - FormatToml: "Serialized TOML Data (Tom's Obvious, Minimal Language)", - FormatText: "Plain Text", - FormatMarkdown: "Markdown Formatted Text", - FormatOther: "Other", -} - -// Report returns a file format documentation table. -func (m FileFormats) Report(withDesc, withType, withExt bool) (rows [][]string, cols []string) { - cols = make([]string, 0, 4) - cols = append(cols, "Format") - - t := 0 - - if withDesc { - cols = append(cols, "Description") - } - - if withType { - if withDesc { - t = 2 - } else { - t = 1 - } - - cols = append(cols, "Type") - } - - if withExt { - cols = append(cols, "Extensions") - } - - rows = make([][]string, 0, len(m)) - - ucFirst := func(str string) string { - for i, v := range str { - return string(unicode.ToUpper(v)) + str[i+1:] - } - return "" - } - - for f, ext := range m { - sort.Slice(ext, func(i, j int) bool { - return ext[i] < ext[j] - }) - - v := make([]string, 0, 4) - v = append(v, strings.ToUpper(f.String())) - - if withDesc { - v = append(v, FormatDesc[f]) - } - - if withType { - v = append(v, ucFirst(string(MediaTypes[f]))) - } - - if withExt { - v = append(v, strings.Join(ext, ", ")) - } - - rows = append(rows, v) - } - - sort.Slice(rows, func(i, j int) bool { - if t > 0 && rows[i][t] == rows[j][t] { - return rows[i][0] < rows[j][0] - } else { - return rows[i][t] < rows[j][t] - } - }) - - return rows, cols -} diff --git a/pkg/fs/file_formats_test.go b/pkg/fs/file_formats_test.go deleted file mode 100644 index f8eb26871..000000000 --- a/pkg/fs/file_formats_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package fs - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestFileFormats_Markdown(t *testing.T) { - t.Run("All", func(t *testing.T) { - f := Extensions.Formats(true) - rows, cols := f.Report(true, true, true) - assert.NotEmpty(t, rows) - assert.NotEmpty(t, cols) - assert.Len(t, cols, 4) - assert.GreaterOrEqual(t, len(rows), 30) - }) - t.Run("Compact", func(t *testing.T) { - f := Extensions.Formats(true) - rows, cols := f.Report(false, false, false) - assert.NotEmpty(t, rows) - assert.NotEmpty(t, cols) - assert.Len(t, cols, 1) - assert.GreaterOrEqual(t, len(rows), 30) - }) -} diff --git a/pkg/fs/file_mediatype.go b/pkg/fs/file_mediatype.go deleted file mode 100644 index aa4d25a9f..000000000 --- a/pkg/fs/file_mediatype.go +++ /dev/null @@ -1,81 +0,0 @@ -package fs - -// MediaType represents a general media type. -type MediaType string - -// General categories of media file types. -const ( - MediaImage MediaType = "image" - MediaSidecar MediaType = "sidecar" - MediaRaw MediaType = "raw" - MediaVideo MediaType = "video" - MediaVector MediaType = "vector" - MediaOther MediaType = "other" -) - -// String returns the media name as string. -func (m MediaType) String() string { - return string(m) -} - -// MediaTypes maps file formats to general media types. -var MediaTypes = map[Format]MediaType{ - FormatRaw: MediaRaw, - FormatJpeg: MediaImage, - FormatPng: MediaImage, - FormatGif: MediaImage, - FormatTiff: MediaImage, - FormatBitmap: MediaImage, - FormatMpo: MediaImage, - FormatHEIF: MediaImage, - FormatHEVC: MediaVideo, - FormatWebP: MediaImage, - FormatWebM: MediaVideo, - FormatAvi: MediaVideo, - FormatAVC: MediaVideo, - FormatAV1: MediaVideo, - FormatMPEG: MediaVideo, - FormatMJPEG: MediaVideo, - FormatMp2: MediaVideo, - FormatMp4: MediaVideo, - FormatMkv: MediaVideo, - FormatMov: MediaVideo, - Format3gp: MediaVideo, - Format3g2: MediaVideo, - FormatFlv: MediaVideo, - FormatMts: MediaVideo, - FormatOgv: MediaVideo, - FormatWMV: MediaVideo, - FormatXMP: MediaSidecar, - FormatXML: MediaSidecar, - FormatAAE: MediaSidecar, - FormatYaml: MediaSidecar, - FormatText: MediaSidecar, - FormatJson: MediaSidecar, - FormatToml: MediaSidecar, - FormatMarkdown: MediaSidecar, - FormatOther: MediaOther, -} - -func GetMediaType(fileName string) MediaType { - if fileName == "" { - return MediaOther - } - - result, ok := MediaTypes[FileFormat(fileName)] - - if !ok { - result = MediaOther - } - - return result -} - -func IsMedia(fileName string) bool { - switch GetMediaType(fileName) { - case MediaRaw, MediaImage, MediaVideo: - return true - default: - return false - } -} diff --git a/pkg/fs/file_mediatype_test.go b/pkg/fs/file_mediatype_test.go deleted file mode 100644 index 2f3576991..000000000 --- a/pkg/fs/file_mediatype_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package fs - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestGetMediaType(t *testing.T) { - t.Run("jpeg", func(t *testing.T) { - result := GetMediaType("testdata/test.jpg") - assert.Equal(t, MediaImage, result) - }) - - t.Run("raw", func(t *testing.T) { - result := GetMediaType("testdata/test (jpg).CR2") - assert.Equal(t, MediaRaw, result) - }) - - t.Run("video", func(t *testing.T) { - result := GetMediaType("testdata/gopher.mp4") - assert.Equal(t, MediaVideo, result) - }) - - t.Run("sidecar", func(t *testing.T) { - result := GetMediaType("/IMG_4120.AAE") - assert.Equal(t, MediaSidecar, result) - }) - - t.Run("empty", func(t *testing.T) { - result := GetMediaType("") - assert.Equal(t, MediaOther, result) - }) - - t.Run("invalid type", func(t *testing.T) { - result := GetMediaType("/IMG_4120.XXX") - assert.Equal(t, MediaOther, result) - }) -} - -func TestIsMedia(t *testing.T) { - t.Run("true", func(t *testing.T) { - assert.True(t, IsMedia("testdata/test.jpg")) - }) - - t.Run("false", func(t *testing.T) { - assert.False(t, IsMedia("/IMG_4120.XXX")) - }) -} diff --git a/pkg/fs/file_format.go b/pkg/fs/file_type.go similarity index 75% rename from pkg/fs/file_format.go rename to pkg/fs/file_type.go index 547daaee2..5cae771ef 100644 --- a/pkg/fs/file_format.go +++ b/pkg/fs/file_type.go @@ -7,30 +7,46 @@ import ( "strings" ) -// Format represents a file format type. -type Format string - -// String returns the file format as string. -func (f Format) String() string { - return string(f) -} - -// Is checks if the format strings match. -func (f Format) Is(s string) bool { - if s == "" { - return false +// FileType returns the type associated with the specified filename, +// and UnknownType if it could not be matched. +func FileType(fileName string) Type { + if t, found := Extensions[LowerExt(fileName)]; found { + return t } - return f.String() == strings.ToLower(s) + return UnknownType } -// Ext returns the standard file format extension. -func (f Format) Ext() string { - return fmt.Sprintf(".%s", f) +// NewType creates a new file type from a filename extension. +func NewType(ext string) Type { + return Type(TrimExt(ext)) +} + +// Type represents a file format type. +type Type string + +// String returns the file format as string. +func (t Type) String() string { + return string(t) +} + +// Equal checks if the type matches. +func (t Type) Equal(s string) bool { + return strings.EqualFold(s, t.String()) +} + +// NotEqual checks if the type is different. +func (t Type) NotEqual(s string) bool { + return !t.Equal(s) +} + +// DefaultExt returns the default file format extension with dot. +func (t Type) DefaultExt() string { + return fmt.Sprintf(".%s", t) } // Find returns the first filename with the same base name and a given type. -func (f Format) Find(fileName string, stripSequence bool) string { +func (t Type) Find(fileName string, stripSequence bool) string { base := BasePrefix(fileName, stripSequence) dir := filepath.Dir(fileName) @@ -38,7 +54,7 @@ func (f Format) Find(fileName string, stripSequence bool) string { prefixLower := filepath.Join(dir, strings.ToLower(base)) prefixUpper := filepath.Join(dir, strings.ToUpper(base)) - for _, ext := range Formats[f] { + for _, ext := range FileTypes[t] { if info, err := os.Stat(prefix + ext); err == nil && info.Mode().IsRegular() { return filepath.Join(dir, info.Name()) } @@ -60,7 +76,7 @@ func (f Format) Find(fileName string, stripSequence bool) string { } // FindFirst searches a list of directories for the first file with the same base name and a given type. -func (f Format) FindFirst(fileName string, dirs []string, baseDir string, stripSequence bool) string { +func (t Type) FindFirst(fileName string, dirs []string, baseDir string, stripSequence bool) string { fileBase := filepath.Base(fileName) fileBasePrefix := BasePrefix(fileName, stripSequence) fileBaseLower := strings.ToLower(fileBasePrefix) @@ -69,7 +85,7 @@ func (f Format) FindFirst(fileName string, dirs []string, baseDir string, stripS fileDir := filepath.Dir(fileName) search := append([]string{fileDir}, dirs...) - for _, ext := range Formats[f] { + for _, ext := range FileTypes[t] { lastDir := "" for _, dir := range search { @@ -109,7 +125,7 @@ func (f Format) FindFirst(fileName string, dirs []string, baseDir string, stripS } // FindAll searches a list of directories for files with the same base name and a given type. -func (f Format) FindAll(fileName string, dirs []string, baseDir string, stripSequence bool) (results []string) { +func (t Type) FindAll(fileName string, dirs []string, baseDir string, stripSequence bool) (results []string) { fileBase := filepath.Base(fileName) fileBasePrefix := BasePrefix(fileName, stripSequence) fileBaseLower := strings.ToLower(fileBasePrefix) @@ -118,7 +134,7 @@ func (f Format) FindAll(fileName string, dirs []string, baseDir string, stripSeq fileDir := filepath.Dir(fileName) search := append([]string{fileDir}, dirs...) - for _, ext := range Formats[f] { + for _, ext := range FileTypes[t] { lastDir := "" for _, dir := range search { @@ -160,15 +176,3 @@ func (f Format) FindAll(fileName string, dirs []string, baseDir string, stripSeq return results } - -// FileFormat returns the (expected) type for a given file name. -func FileFormat(fileName string) Format { - fileExt := strings.ToLower(filepath.Ext(fileName)) - result, ok := Extensions[fileExt] - - if !ok { - result = FormatOther - } - - return result -} diff --git a/pkg/fs/file_format_test.go b/pkg/fs/file_type_test.go similarity index 50% rename from pkg/fs/file_format_test.go rename to pkg/fs/file_type_test.go index 3722e3461..40895bfc4 100644 --- a/pkg/fs/file_format_test.go +++ b/pkg/fs/file_type_test.go @@ -6,148 +6,166 @@ import ( "github.com/stretchr/testify/assert" ) -func TestFileFormat_String(t *testing.T) { +func TestType_String(t *testing.T) { t.Run("jpg", func(t *testing.T) { - assert.Equal(t, "jpg", FormatJpeg.String()) + assert.Equal(t, "jpg", ImageJPEG.String()) }) } -func TestFileFormat_Is(t *testing.T) { +func TestType_Equal(t *testing.T) { + t.Run("jpg", func(t *testing.T) { + assert.True(t, ImageJPEG.Equal("jpg")) + }) +} + +func TestToType(t *testing.T) { + t.Run("jpg", func(t *testing.T) { + assert.Equal(t, "jpg", NewType("JPG").String()) + }) + t.Run("JPEG", func(t *testing.T) { + assert.Equal(t, Type("jpeg"), NewType("JPEG")) + }) + t.Run(".jpg", func(t *testing.T) { + assert.Equal(t, "jpg", NewType(".jpg").String()) + }) +} + +func TestType_Is(t *testing.T) { t.Run("Empty", func(t *testing.T) { - assert.False(t, FormatJpeg.Is("")) + assert.False(t, ImageJPEG.Equal("")) }) t.Run("Upper", func(t *testing.T) { - assert.True(t, FormatJpeg.Is("JPG")) + assert.True(t, ImageJPEG.Equal("JPG")) }) t.Run("Lower", func(t *testing.T) { - assert.True(t, FormatJpeg.Is("jpg")) + assert.True(t, ImageJPEG.Equal("jpg")) }) t.Run("False", func(t *testing.T) { - assert.False(t, FormatJpeg.Is("raw")) + assert.False(t, ImageJPEG.Equal("raw")) }) } -func TestFileFormat_Find(t *testing.T) { +func TestType_Find(t *testing.T) { t.Run("find jpg", func(t *testing.T) { - result := FormatJpeg.Find("testdata/test.xmp", false) + result := ImageJPEG.Find("testdata/test.xmp", false) assert.Equal(t, "testdata/test.jpg", result) }) t.Run("upper ext", func(t *testing.T) { - result := FormatJpeg.Find("testdata/test.XMP", false) + result := ImageJPEG.Find("testdata/test.XMP", false) assert.Equal(t, "testdata/test.jpg", result) }) t.Run("with sequence", func(t *testing.T) { - result := FormatJpeg.Find("testdata/test (2).xmp", false) + result := ImageJPEG.Find("testdata/test (2).xmp", false) assert.Equal(t, "", result) }) t.Run("strip sequence", func(t *testing.T) { - result := FormatJpeg.Find("testdata/test (2).xmp", true) + result := ImageJPEG.Find("testdata/test (2).xmp", true) assert.Equal(t, "testdata/test.jpg", result) }) t.Run("name upper", func(t *testing.T) { - result := FormatJpeg.Find("testdata/CATYELLOW.xmp", true) + result := ImageJPEG.Find("testdata/CATYELLOW.xmp", true) assert.Equal(t, "testdata/CATYELLOW.jpg", result) }) t.Run("name lower", func(t *testing.T) { - result := FormatJpeg.Find("testdata/chameleon_lime.xmp", true) + result := ImageJPEG.Find("testdata/chameleon_lime.xmp", true) assert.Equal(t, "testdata/chameleon_lime.jpg", result) }) } -func TestFileFormat_FindFirst(t *testing.T) { +func TestType_FindFirst(t *testing.T) { dirs := []string{HiddenPath} t.Run("find xmp", func(t *testing.T) { - result := FormatXMP.FindFirst("testdata/test.jpg", dirs, "", false) + result := XmpFile.FindFirst("testdata/test.jpg", dirs, "", false) assert.Equal(t, "testdata/.photoprism/test.xmp", result) }) t.Run("find xmp upper ext", func(t *testing.T) { - result := FormatXMP.FindFirst("testdata/test.PNG", dirs, "", false) + result := XmpFile.FindFirst("testdata/test.PNG", dirs, "", false) assert.Equal(t, "testdata/.photoprism/test.xmp", result) }) t.Run("find xmp without sequence", func(t *testing.T) { - result := FormatXMP.FindFirst("testdata/test (2).jpg", dirs, "", false) + result := XmpFile.FindFirst("testdata/test (2).jpg", dirs, "", false) assert.Equal(t, "", result) }) t.Run("find xmp with sequence", func(t *testing.T) { - result := FormatXMP.FindFirst("testdata/test (2).jpg", dirs, "", true) + result := XmpFile.FindFirst("testdata/test (2).jpg", dirs, "", true) assert.Equal(t, "testdata/.photoprism/test.xmp", result) }) t.Run("find jpg", func(t *testing.T) { - result := FormatJpeg.FindFirst("testdata/test.xmp", dirs, "", false) + result := ImageJPEG.FindFirst("testdata/test.xmp", dirs, "", false) assert.Equal(t, "testdata/test.jpg", result) }) t.Run("find jpg abs", func(t *testing.T) { - result := FormatJpeg.FindFirst(Abs("testdata/test.xmp"), dirs, "", false) + result := ImageJPEG.FindFirst(Abs("testdata/test.xmp"), dirs, "", false) assert.Equal(t, Abs("testdata/test.jpg"), result) }) t.Run("upper ext", func(t *testing.T) { - result := FormatJpeg.FindFirst("testdata/test.XMP", dirs, "", false) + result := ImageJPEG.FindFirst("testdata/test.XMP", dirs, "", false) assert.Equal(t, "testdata/test.jpg", result) }) t.Run("with sequence", func(t *testing.T) { - result := FormatJpeg.FindFirst("testdata/test (2).xmp", dirs, "", false) + result := ImageJPEG.FindFirst("testdata/test (2).xmp", dirs, "", false) assert.Equal(t, "", result) }) t.Run("strip sequence", func(t *testing.T) { - result := FormatJpeg.FindFirst("testdata/test (2).xmp", dirs, "", true) + result := ImageJPEG.FindFirst("testdata/test (2).xmp", dirs, "", true) assert.Equal(t, "testdata/test.jpg", result) }) t.Run("name upper", func(t *testing.T) { - result := FormatJpeg.FindFirst("testdata/CATYELLOW.xmp", dirs, "", true) + result := ImageJPEG.FindFirst("testdata/CATYELLOW.xmp", dirs, "", true) assert.Equal(t, "testdata/CATYELLOW.jpg", result) }) t.Run("name lower", func(t *testing.T) { - result := FormatJpeg.FindFirst("testdata/chameleon_lime.xmp", dirs, "", true) + result := ImageJPEG.FindFirst("testdata/chameleon_lime.xmp", dirs, "", true) assert.Equal(t, "testdata/chameleon_lime.jpg", result) }) t.Run("example_bmp_notfound", func(t *testing.T) { - result := FormatBitmap.FindFirst("testdata/example.00001.jpg", dirs, "", true) + result := ImageBMP.FindFirst("testdata/example.00001.jpg", dirs, "", true) assert.Equal(t, "", result) }) t.Run("example_bmp_found", func(t *testing.T) { - result := FormatBitmap.FindFirst("testdata/example.00001.jpg", []string{"directory"}, "", true) + result := ImageBMP.FindFirst("testdata/example.00001.jpg", []string{"directory"}, "", true) assert.Equal(t, "testdata/directory/example.bmp", result) }) t.Run("example_png_found", func(t *testing.T) { - result := FormatPng.FindFirst("testdata/example.00001.jpg", []string{"directory", "directory/subdirectory"}, "", true) + result := ImagePNG.FindFirst("testdata/example.00001.jpg", []string{"directory", "directory/subdirectory"}, "", true) assert.Equal(t, "testdata/directory/subdirectory/example.png", result) }) t.Run("example_bmp_found", func(t *testing.T) { - result := FormatBitmap.FindFirst(Abs("testdata/example.00001.jpg"), []string{"directory"}, Abs("testdata"), true) + result := ImageBMP.FindFirst(Abs("testdata/example.00001.jpg"), []string{"directory"}, Abs("testdata"), true) assert.Equal(t, Abs("testdata/directory/example.bmp"), result) }) } -func TestFileFormat_FindAll(t *testing.T) { +func TestType_FindAll(t *testing.T) { dirs := []string{HiddenPath} t.Run("CATYELLOW.jpg", func(t *testing.T) { - result := FormatJpeg.FindAll("testdata/CATYELLOW.JSON", dirs, "", false) + result := ImageJPEG.FindAll("testdata/CATYELLOW.JSON", dirs, "", false) assert.Contains(t, result, "testdata/CATYELLOW.jpg") }) } -func TestFileFormat(t *testing.T) { +func TestType(t *testing.T) { t.Run("Empty", func(t *testing.T) { - result := FileFormat("") - assert.Equal(t, FormatOther, result) + result := FileType("") + assert.Equal(t, UnknownType, result) }) t.Run("JPEG", func(t *testing.T) { - result := FileFormat("testdata/test.jpg") - assert.Equal(t, FormatJpeg, result) + result := FileType("testdata/test.jpg") + assert.Equal(t, ImageJPEG, result) }) t.Run("RawCRw", func(t *testing.T) { - result := FileFormat("testdata/test (jpg).crw") - assert.Equal(t, FormatRaw, result) + result := FileType("testdata/test (jpg).crw") + assert.Equal(t, RawImage, result) }) t.Run("RawCR2", func(t *testing.T) { - result := FileFormat("testdata/test (jpg).CR2") - assert.Equal(t, FormatRaw, result) + result := FileType("testdata/test (jpg).CR2") + assert.Equal(t, RawImage, result) }) t.Run("MP4", func(t *testing.T) { - assert.Equal(t, Format("mp4"), FileFormat("file.mp")) + assert.Equal(t, Type("mp4"), FileType("file.mp")) }) } diff --git a/pkg/fs/file_types.go b/pkg/fs/file_types.go new file mode 100644 index 000000000..34dd3aff9 --- /dev/null +++ b/pkg/fs/file_types.go @@ -0,0 +1,93 @@ +package fs + +import ( + _ "image/gif" + _ "image/jpeg" + _ "image/png" + + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/tiff" + _ "golang.org/x/image/webp" +) + +// File types. +const ( + RawImage Type = "raw" // RAW image file. + ImageJPEG Type = "jpg" // JPEG image file. + ImageHEIF Type = "heif" // High Efficiency Image File Format + ImageTIFF Type = "tiff" // TIFF image file. + ImagePNG Type = "png" // PNG image file. + ImageGIF Type = "gif" // GIF image file. + ImageBMP Type = "bmp" // BMP image file. + ImageMPO Type = "mpo" // Stereoscopic Image that consists of two JPG images that are combined into one 3D image + ImageWebP Type = "webp" // Google WebP Image + VideoWebM Type = "webm" // Google WebM Video + VideoAVC Type = "avc" // H.264, Advanced Video Coding (AVC, MPEG-4 Part 10) + VideoHEVC Type = "hevc" // H.265, High Efficiency Video Coding (HEVC) + VideoVVC Type = "vvc" // H.266, Versatile Video Coding (VVC) + VideoAV1 Type = "av1" // Alliance for Open Media Video + VideoMPG Type = "mpg" // Moving Picture Experts Group (MPEG) + VideoMJPG Type = "mjpg" // Motion JPEG (M-JPEG) + VideoMOV Type = "mov" // QuickTime File Format, can contain AVC, HEVC,... + VideoMP2 Type = "mp2" // MPEG-2, H.222/H.262 + VideoMP4 Type = "mp4" // MPEG-4 Container based on QuickTime, can contain AVC, HEVC,... + VideoAVI Type = "avi" // Microsoft Audio Video Interleave (AVI) + Video3GP Type = "3gp" // Mobile Multimedia Container, MPEG-4 Part 12 + Video3G2 Type = "3g2" // Similar to 3GP, consumes less space & bandwidth + VideoFlash Type = "flv" // Flash Video + VideoMKV Type = "mkv" // Matroska Multimedia Container, free and open + VideoAVCHD Type = "mts" // AVCHD (Advanced Video Coding High Definition) + VideoOGV Type = "ogv" // Ogg container format maintained by the Xiph.Org, free and open + VideoASF Type = "asf" // Advanced Systems/Streaming Format (ASF) + VideoWMV Type = "wmv" // Windows Media Video (based on ASF) + XmpFile Type = "xmp" // Adobe XMP sidecar file (XML). + AaeFile Type = "aae" // Apple image edits sidecar file (based on XML). + XmlFile Type = "xml" // XML metadata / config / sidecar file. + YamlFile Type = "yml" // YAML metadata / config / sidecar file. + TomlFile Type = "toml" // Tom's Obvious, Minimal Language sidecar file. + JsonFile Type = "json" // JSON metadata / config / sidecar file. + TextFile Type = "txt" // Text config / sidecar file. + MarkdownFile Type = "md" // Markdown text sidecar file. + UnknownType Type = "" // Unknown file type. +) + +// TypeInfo contains human-readable descriptions for supported file formats +var TypeInfo = map[Type]string{ + RawImage: "Unprocessed Sensor Data", + ImageJPEG: "Joint Photographic Experts Group (JPEG)", + ImagePNG: "Portable Network Graphics", + ImageGIF: "Graphics Interchange Format", + ImageTIFF: "Tag Image File Format", + ImageBMP: "Bitmap", + ImageMPO: "Stereoscopic JPEG (3D)", + ImageHEIF: "High Efficiency Image File Format (HEIF)", + ImageWebP: "Google WebP", + VideoWebM: "Google WebM", + VideoMP2: "MPEG 2 / H.262", + VideoAVC: "Advanced Video Coding (AVC, MPEG-4 Part 10) / H.264", + VideoHEVC: "High Efficiency Video Coding (HEVC) / H.265", + VideoVVC: "Versatile Video Coding (VVC) / H.266", + VideoAV1: "AOMedia Video 1 (AV1)", + VideoMOV: "Apple QuickTime (MOV)", + VideoMP4: "Multimedia Container (MPEG-4 Part 14)", + VideoAVI: "Microsoft Audio Video Interleave (AVI)", + VideoASF: "Advanced Systems Format (ASF)", + VideoWMV: "Windows Media", + Video3GP: "Mobile Multimedia Container (3G)", + Video3G2: "Mobile Multimedia Container (CDMA2000)", + VideoFlash: "Adobe Flash", + VideoMKV: "Matroska Multimedia Container (MKV)", + VideoMPG: "Moving Picture Experts Group (MPEG)", + VideoMJPG: "Motion JPEG (M-JPEG)", + VideoAVCHD: "Advanced Video Coding High Definition (AVCHD)", + VideoOGV: "Ogg Media (OGG)", + XmpFile: "Adobe Extensible Metadata Platform (XMP)", + AaeFile: "Apple Image Edits XML", + XmlFile: "Extensible Markup Language (XML)", + JsonFile: "Serialized JSON Data (Exiftool, Google Photos)", + YamlFile: "Serialized YAML Data (Config, Metadata)", + TomlFile: "Serialized TOML Data (Tom's Obvious, Minimal Language)", + TextFile: "Plain Text", + MarkdownFile: "Markdown Formatted Text", + UnknownType: "Other", +} diff --git a/pkg/fs/file_types_ext.go b/pkg/fs/file_types_ext.go new file mode 100644 index 000000000..2ee8fe4e5 --- /dev/null +++ b/pkg/fs/file_types_ext.go @@ -0,0 +1,17 @@ +package fs + +import ( + _ "image/gif" + _ "image/jpeg" + _ "image/png" + + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/tiff" + _ "golang.org/x/image/webp" +) + +// TypesExt maps standard formats to file extensions. +type TypesExt map[Type][]string + +// FileTypes contains the default file type extensions. +var FileTypes = Extensions.Types(ignoreCase) diff --git a/pkg/fs/fs.go b/pkg/fs/fs.go index 392f9ae0c..60b028dbc 100644 --- a/pkg/fs/fs.go +++ b/pkg/fs/fs.go @@ -81,7 +81,7 @@ func PathWritable(path string) bool { return false } - tmpName := filepath.Join(path, "."+rnd.Token(8)) + tmpName := filepath.Join(path, "."+rnd.GenerateToken(8)) if f, err := os.Create(tmpName); err != nil { return false diff --git a/pkg/fs/id.go b/pkg/fs/id.go index 65f0246ea..6583f920c 100644 --- a/pkg/fs/id.go +++ b/pkg/fs/id.go @@ -86,7 +86,7 @@ func IsGenerated(fileName string) bool { return true } else if IsUniqueName(base) { return true - } else if rnd.IsUID(base, 0) { + } else if rnd.ValidID(base, 0) { return true } else if IsCanonical(base) { return true diff --git a/pkg/fs/mime.go b/pkg/fs/mime.go index 122789b28..96c757790 100644 --- a/pkg/fs/mime.go +++ b/pkg/fs/mime.go @@ -33,7 +33,7 @@ func MimeType(filename string) string { return "" } else if t, err := filetype.Get(buffer); err == nil && t != filetype.Unknown { return t.MIME.Value - } else if t := filetype.GetType(NormalizeExt(filename)); t != filetype.Unknown { + } else if t := filetype.GetType(NormalizedExt(filename)); t != filetype.Unknown { return t.MIME.Value } else { return "" diff --git a/pkg/fs/walk_test.go b/pkg/fs/walk_test.go index 63ad29cf7..98cc035b0 100644 --- a/pkg/fs/walk_test.go +++ b/pkg/fs/walk_test.go @@ -77,7 +77,7 @@ func TestSkipWalk(t *testing.T) { done[fileName] = Found - if textName := FormatText.Find(fileName, false); textName != "" { + if textName := TextFile.Find(fileName, false); textName != "" { done[textName] = Found } diff --git a/pkg/media/filename.go b/pkg/media/filename.go new file mode 100644 index 000000000..705281347 --- /dev/null +++ b/pkg/media/filename.go @@ -0,0 +1,25 @@ +package media + +import ( + "github.com/photoprism/photoprism/pkg/fs" +) + +// FromName returns the content type matching the file extension. +func FromName(fileName string) Type { + if fileName == "" { + return Unknown + } + + // Find media type based on the file type. + if result, found := Formats[fs.FileType(fileName)]; found { + return result + } + + // Default. + return Other +} + +// MainFile checks if the filename belongs to a main content type. +func MainFile(fileName string) bool { + return FromName(fileName).Main() +} diff --git a/pkg/media/filename_test.go b/pkg/media/filename_test.go new file mode 100644 index 000000000..9d53bce96 --- /dev/null +++ b/pkg/media/filename_test.go @@ -0,0 +1,43 @@ +package media + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFromName(t *testing.T) { + t.Run("jpeg", func(t *testing.T) { + result := FromName("testdata/test.jpg") + assert.Equal(t, Image, result) + }) + t.Run("raw", func(t *testing.T) { + result := FromName("testdata/test (jpg).CR2") + assert.Equal(t, Raw, result) + }) + t.Run("video", func(t *testing.T) { + result := FromName("testdata/gopher.mp4") + assert.Equal(t, Video, result) + }) + t.Run("sidecar", func(t *testing.T) { + result := FromName("/IMG_4120.AAE") + assert.Equal(t, Sidecar, result) + }) + t.Run("empty", func(t *testing.T) { + result := FromName("") + assert.Equal(t, Unknown, result) + }) + t.Run("invalid type", func(t *testing.T) { + result := FromName("/IMG_4120.XXX") + assert.Equal(t, Other, result) + }) +} + +func TestMainFile(t *testing.T) { + t.Run("true", func(t *testing.T) { + assert.True(t, MainFile("testdata/test.jpg")) + }) + t.Run("false", func(t *testing.T) { + assert.False(t, MainFile("/IMG_4120.XXX")) + }) +} diff --git a/pkg/media/formats.go b/pkg/media/formats.go new file mode 100644 index 000000000..821fe4af9 --- /dev/null +++ b/pkg/media/formats.go @@ -0,0 +1,44 @@ +package media + +import "github.com/photoprism/photoprism/pkg/fs" + +// Formats maps file formats to general media types. +var Formats = map[fs.Type]Type{ + fs.RawImage: Raw, + fs.ImageJPEG: Image, + fs.ImagePNG: Image, + fs.ImageGIF: Image, + fs.ImageTIFF: Image, + fs.ImageBMP: Image, + fs.ImageMPO: Image, + fs.ImageHEIF: Image, + fs.VideoHEVC: Video, + fs.ImageWebP: Image, + fs.VideoWebM: Video, + fs.VideoAVI: Video, + fs.VideoAVC: Video, + fs.VideoVVC: Video, + fs.VideoAV1: Video, + fs.VideoMPG: Video, + fs.VideoMJPG: Video, + fs.VideoMP2: Video, + fs.VideoMP4: Video, + fs.VideoMKV: Video, + fs.VideoMOV: Video, + fs.Video3GP: Video, + fs.Video3G2: Video, + fs.VideoFlash: Video, + fs.VideoAVCHD: Video, + fs.VideoOGV: Video, + fs.VideoASF: Video, + fs.VideoWMV: Video, + fs.XmpFile: Sidecar, + fs.XmlFile: Sidecar, + fs.AaeFile: Sidecar, + fs.YamlFile: Sidecar, + fs.TextFile: Sidecar, + fs.JsonFile: Sidecar, + fs.TomlFile: Sidecar, + fs.MarkdownFile: Sidecar, + fs.UnknownType: Other, +} diff --git a/pkg/media/media.go b/pkg/media/media.go new file mode 100644 index 000000000..7ec88d740 --- /dev/null +++ b/pkg/media/media.go @@ -0,0 +1,27 @@ +/* + +Package media provides general content types and maps them to file formats. + +Copyright (c) 2018 - 2022 PhotoPrism UG. All rights reserved. + + This program is free software: you can redistribute it and/or modify + it under Version 3 of the GNU Affero General Public License (the "AGPL"): + + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + The AGPL is supplemented by our Trademark and Brand Guidelines, + which describe how our Brand Assets may be used: + + +Feel free to send an email to hello@photoprism.app if you have questions, +want to support our work, or just want to say hello. + +Additional information can be found in our Developer Guide: + + +*/ +package media diff --git a/pkg/media/new.go b/pkg/media/new.go new file mode 100644 index 000000000..ac56b2822 --- /dev/null +++ b/pkg/media/new.go @@ -0,0 +1,8 @@ +package media + +import "github.com/photoprism/photoprism/pkg/clean" + +// New casts a string to a type. +func New(s string) Type { + return Type(clean.ShortTypeLower(s)) +} diff --git a/pkg/media/new_test.go b/pkg/media/new_test.go new file mode 100644 index 000000000..982a95a55 --- /dev/null +++ b/pkg/media/new_test.go @@ -0,0 +1,13 @@ +package media + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNew(t *testing.T) { + mediaType := New(" FOO-bar! FOO-bar! 0123 XXXXXXXXXXXXXXXXXXXXXXXXXXXX YYYYY YYYYYYYYY YZ") + + assert.Equal(t, "foo-bar!", mediaType.String()) +} diff --git a/pkg/media/report.go b/pkg/media/report.go new file mode 100644 index 000000000..27f6ab375 --- /dev/null +++ b/pkg/media/report.go @@ -0,0 +1,77 @@ +package media + +import ( + "sort" + "strings" + "unicode" + + "github.com/photoprism/photoprism/pkg/fs" +) + +// Report returns a file format documentation table. +func Report(m fs.TypesExt, withDesc, withType, withExt bool) (rows [][]string, cols []string) { + cols = make([]string, 0, 4) + cols = append(cols, "Format") + + t := 0 + + if withDesc { + cols = append(cols, "Description") + } + + if withType { + if withDesc { + t = 2 + } else { + t = 1 + } + + cols = append(cols, "Type") + } + + if withExt { + cols = append(cols, "Extensions") + } + + rows = make([][]string, 0, len(m)) + + ucFirst := func(str string) string { + for i, v := range str { + return string(unicode.ToUpper(v)) + str[i+1:] + } + return "" + } + + for f, ext := range m { + sort.Slice(ext, func(i, j int) bool { + return ext[i] < ext[j] + }) + + v := make([]string, 0, 4) + v = append(v, strings.ToUpper(f.String())) + + if withDesc { + v = append(v, fs.TypeInfo[f]) + } + + if withType { + v = append(v, ucFirst(string(Formats[f]))) + } + + if withExt { + v = append(v, strings.Join(ext, ", ")) + } + + rows = append(rows, v) + } + + sort.Slice(rows, func(i, j int) bool { + if t > 0 && rows[i][t] == rows[j][t] { + return rows[i][0] < rows[j][0] + } else { + return rows[i][t] < rows[j][t] + } + }) + + return rows, cols +} diff --git a/pkg/media/type.go b/pkg/media/type.go new file mode 100644 index 000000000..c6a04edaa --- /dev/null +++ b/pkg/media/type.go @@ -0,0 +1,38 @@ +package media + +import ( + "strings" +) + +// Type represents a general media content type. +type Type string + +// String returns the type as string. +func (t Type) String() string { + return string(t) +} + +// Equal checks if the type matches. +func (t Type) Equal(s string) bool { + return strings.EqualFold(s, t.String()) +} + +// NotEqual checks if the type is different. +func (t Type) NotEqual(s string) bool { + return !t.Equal(s) +} + +// Main checks if this is a known main media content format. +func (t Type) Main() bool { + switch t { + case Raw, Image, Video, Live, Animated, Vector: + return true + default: + return false + } +} + +// Unknown checks if the type is unknown. +func (t Type) Unknown() bool { + return t == Unknown +} diff --git a/pkg/media/type_test.go b/pkg/media/type_test.go new file mode 100644 index 000000000..b46d85ffc --- /dev/null +++ b/pkg/media/type_test.go @@ -0,0 +1,67 @@ +package media + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestType_Main(t *testing.T) { + t.Run("Unknown", func(t *testing.T) { + assert.False(t, Unknown.Main()) + }) + t.Run("Image", func(t *testing.T) { + assert.True(t, Image.Main()) + }) + t.Run("Video", func(t *testing.T) { + assert.True(t, Video.Main()) + }) + t.Run("Sidecar", func(t *testing.T) { + assert.False(t, Sidecar.Main()) + }) +} + +func TestType_Unknown(t *testing.T) { + t.Run("Unknown", func(t *testing.T) { + assert.True(t, Unknown.Unknown()) + }) + t.Run("Image", func(t *testing.T) { + assert.False(t, Image.Unknown()) + }) + t.Run("Video", func(t *testing.T) { + assert.False(t, Video.Unknown()) + }) + t.Run("Sidecar", func(t *testing.T) { + assert.False(t, Sidecar.Unknown()) + }) +} + +func TestType_Equal(t *testing.T) { + t.Run("UnknownUnknown", func(t *testing.T) { + assert.True(t, Unknown.Equal("")) + }) + t.Run("ImageImage", func(t *testing.T) { + assert.True(t, Image.Equal(Image.String())) + }) + t.Run("VideoImage", func(t *testing.T) { + assert.False(t, Video.Equal(Image.String())) + }) + t.Run("SidecarUnknown", func(t *testing.T) { + assert.False(t, Sidecar.Equal(Unknown.String())) + }) +} + +func TestType_NotEqual(t *testing.T) { + t.Run("UnknownUnknown", func(t *testing.T) { + assert.False(t, Unknown.NotEqual("")) + }) + t.Run("ImageImage", func(t *testing.T) { + assert.False(t, Image.NotEqual(Image.String())) + }) + t.Run("VideoImage", func(t *testing.T) { + assert.True(t, Video.NotEqual(Image.String())) + }) + t.Run("SidecarUnknown", func(t *testing.T) { + assert.True(t, Sidecar.NotEqual(Unknown.String())) + }) +} diff --git a/pkg/media/types.go b/pkg/media/types.go new file mode 100644 index 000000000..f8a0d5e5e --- /dev/null +++ b/pkg/media/types.go @@ -0,0 +1,14 @@ +package media + +const ( + Unknown Type = "" + Image Type = "image" + Raw Type = "raw" + Vector Type = "vector" + Animated Type = "animated" + Live Type = "live" + Video Type = "video" + Sidecar Type = "sidecar" + Text Type = "text" + Other Type = "other" +) diff --git a/pkg/projection/find.go b/pkg/projection/find.go new file mode 100644 index 000000000..f173fe60c --- /dev/null +++ b/pkg/projection/find.go @@ -0,0 +1,20 @@ +package projection + +import ( + "github.com/photoprism/photoprism/pkg/clean" +) + +// Find returns the project matching the name. +func Find(name string) Type { + if name == "" { + return Unknown + } + + // Find known type based on the normalized name. + if result, found := Types[clean.TypeLower(name)]; found { + return result + } + + // Default. + return Other +} diff --git a/pkg/projection/find_test.go b/pkg/projection/find_test.go new file mode 100644 index 000000000..eacd9f595 --- /dev/null +++ b/pkg/projection/find_test.go @@ -0,0 +1,22 @@ +package projection + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFromName(t *testing.T) { + t.Run("Unknown", func(t *testing.T) { + result := Find("") + assert.Equal(t, Unknown, result) + }) + t.Run("Other", func(t *testing.T) { + result := Find("zdfbhmdflkbhelkthn") + assert.Equal(t, Other, result) + }) + t.Run(Equirectangular.String(), func(t *testing.T) { + result := Find("Equirectangular ") + assert.Equal(t, Equirectangular, result) + }) +} diff --git a/pkg/projection/new.go b/pkg/projection/new.go new file mode 100644 index 000000000..b87598470 --- /dev/null +++ b/pkg/projection/new.go @@ -0,0 +1,8 @@ +package projection + +import "github.com/photoprism/photoprism/pkg/clean" + +// New creates a projection type. +func New(s string) Type { + return Type(clean.TypeLower(s)) +} diff --git a/pkg/projection/new_test.go b/pkg/projection/new_test.go new file mode 100644 index 000000000..5cc4b0f2c --- /dev/null +++ b/pkg/projection/new_test.go @@ -0,0 +1,13 @@ +package projection + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTNew(t *testing.T) { + proj := New(" FOO-bar! FOO-bar! 0123 XXXXXXXXXXXXXXXXXXXXXXXXXXXX YYYYY YYYYYYYYY YZ") + + assert.Equal(t, "foo-bar! foo-bar! 0123 xxxxxxxxxxxxxxxxxxxxxxxxxxxx yyyyy", proj.String()) +} diff --git a/pkg/projection/projection.go b/pkg/projection/projection.go new file mode 100644 index 000000000..f7af11cf5 --- /dev/null +++ b/pkg/projection/projection.go @@ -0,0 +1,27 @@ +/* + +Package projection provides visual projection types and methods. + +Copyright (c) 2018 - 2022 PhotoPrism UG. All rights reserved. + + This program is free software: you can redistribute it and/or modify + it under Version 3 of the GNU Affero General Public License (the "AGPL"): + + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + The AGPL is supplemented by our Trademark and Brand Guidelines, + which describe how our Brand Assets may be used: + + +Feel free to send an email to hello@photoprism.app if you have questions, +want to support our work, or just want to say hello. + +Additional information can be found in our Developer Guide: + + +*/ +package projection diff --git a/pkg/projection/type.go b/pkg/projection/type.go new file mode 100644 index 000000000..a9bcae23c --- /dev/null +++ b/pkg/projection/type.go @@ -0,0 +1,28 @@ +package projection + +import ( + "strings" +) + +// Type represents a visual projection type. +type Type string + +// String returns the type as string. +func (t Type) String() string { + return string(t) +} + +// Unknown checks if the type is unknown. +func (t Type) Unknown() bool { + return t == Unknown +} + +// Equal checks if the type matches. +func (t Type) Equal(s string) bool { + return strings.EqualFold(s, t.String()) +} + +// NotEqual checks if the type is different. +func (t Type) NotEqual(s string) bool { + return !t.Equal(s) +} diff --git a/pkg/projection/type_test.go b/pkg/projection/type_test.go new file mode 100644 index 000000000..154dadc6d --- /dev/null +++ b/pkg/projection/type_test.go @@ -0,0 +1,37 @@ +package projection + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestType_Equal(t *testing.T) { + t.Run("UnknownUnknown", func(t *testing.T) { + assert.True(t, Unknown.Equal("")) + }) + t.Run("CubestripCubestrip", func(t *testing.T) { + assert.True(t, Cubestrip.Equal(Cubestrip.String())) + }) + t.Run("CubestripCylindrical", func(t *testing.T) { + assert.False(t, Cubestrip.Equal(Cylindrical.String())) + }) + t.Run("CylindricalUnknown", func(t *testing.T) { + assert.False(t, Cylindrical.Equal(Unknown.String())) + }) +} + +func TestType_NotEqual(t *testing.T) { + t.Run("UnknownUnknown", func(t *testing.T) { + assert.False(t, Unknown.NotEqual("")) + }) + t.Run("CubestripCubestrip", func(t *testing.T) { + assert.False(t, Cubestrip.NotEqual(Cubestrip.String())) + }) + t.Run("CubestripCylindrical", func(t *testing.T) { + assert.True(t, Cubestrip.NotEqual(Cylindrical.String())) + }) + t.Run("CylindricalUnknown", func(t *testing.T) { + assert.True(t, Cylindrical.NotEqual(Unknown.String())) + }) +} diff --git a/pkg/projection/types.go b/pkg/projection/types.go new file mode 100644 index 000000000..c628ac206 --- /dev/null +++ b/pkg/projection/types.go @@ -0,0 +1,24 @@ +package projection + +const ( + Unknown Type = "" + Equirectangular Type = "equirectangular" + Cubestrip Type = "cubestrip" + Cylindrical Type = "cylindrical" + TransverseCylindrical Type = "transverse-cylindrical" + PseudocylindricalCompromise Type = "pseudocylindrical-compromise" + Other Type = "other" +) + +// Types maps identifiers to known types. +var Types = Known{ + string(Unknown): Unknown, + string(Equirectangular): Equirectangular, + string(Cubestrip): Cubestrip, + string(Cylindrical): Cylindrical, + string(TransverseCylindrical): TransverseCylindrical, + string(PseudocylindricalCompromise): PseudocylindricalCompromise, +} + +// Known maps names to standard projection types. +type Known map[string]Type diff --git a/pkg/report/table.go b/pkg/report/table.go index e0858b918..be341253f 100644 --- a/pkg/report/table.go +++ b/pkg/report/table.go @@ -2,6 +2,7 @@ package report import ( "bytes" + "strings" "github.com/olekukonko/tablewriter" ) @@ -15,6 +16,17 @@ func Table(rows [][]string, cols []string, markDown bool) string { // TableWithCaption returns a text-formatted table with caption, optionally as valid Markdown, // so the output can be pasted into the docs. func TableWithCaption(rows [][]string, cols []string, caption string, markDown bool) string { + // Escape Markdown. + if markDown { + for i := range rows { + for j := range rows[i] { + if strings.ContainsRune(rows[i][j], '|') { + rows[i][j] = strings.ReplaceAll(rows[i][j], "|", "\\|") + } + } + } + } + buf := &bytes.Buffer{} // Set Borders. diff --git a/pkg/report/table_test.go b/pkg/report/table_test.go new file mode 100644 index 000000000..27edd3a04 --- /dev/null +++ b/pkg/report/table_test.go @@ -0,0 +1,28 @@ +package report + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTable(t *testing.T) { + t.Run("Standard", func(t *testing.T) { + cols := []string{"Col1", "Col2"} + rows := [][]string{ + {"foo", "bar" + strings.Repeat(", abc", 30)}, + {"bar", "b & a | z"}} + result := Table(rows, cols, false) + assert.Contains(t, result, "| bar | b & a | z |") + }) + t.Run("Markdown", func(t *testing.T) { + cols := []string{"Col1", "Col2"} + rows := [][]string{ + {"foo", "bar" + strings.Repeat(", abc", 30)}, + {"bar", "b & a | z"}} + result := Table(rows, cols, true) + // fmt.Println(result) + assert.Contains(t, result, "| bar | b & a \\| z") + }) +} diff --git a/pkg/rnd/generate_passwd.go b/pkg/rnd/generate_passwd.go new file mode 100644 index 000000000..230d00cb0 --- /dev/null +++ b/pkg/rnd/generate_passwd.go @@ -0,0 +1,6 @@ +package rnd + +// GeneratePasswd returns a random password with 8 characters as string. +func GeneratePasswd() string { + return GenerateToken(8) +} diff --git a/pkg/rnd/token.go b/pkg/rnd/generate_token.go similarity index 81% rename from pkg/rnd/token.go rename to pkg/rnd/generate_token.go index ed274cf7b..ee58a9dd7 100644 --- a/pkg/rnd/token.go +++ b/pkg/rnd/generate_token.go @@ -7,8 +7,8 @@ import ( "strconv" ) -// Token returns a random token with length of up to 10 characters. -func Token(size uint) string { +// GenerateToken returns a random token with length of up to 10 characters. +func GenerateToken(size uint) string { if size > 10 || size < 1 { panic(fmt.Sprintf("size out of range: %d", size)) } diff --git a/pkg/rnd/token_test.go b/pkg/rnd/generate_token_test.go similarity index 81% rename from pkg/rnd/token_test.go rename to pkg/rnd/generate_token_test.go index c9854cbd0..2ef921b49 100644 --- a/pkg/rnd/token_test.go +++ b/pkg/rnd/generate_token_test.go @@ -8,35 +8,35 @@ import ( func TestRandomToken(t *testing.T) { t.Run("size 4", func(t *testing.T) { - token := Token(4) + token := GenerateToken(4) assert.NotEmpty(t, token) }) t.Run("size 8", func(t *testing.T) { - token := Token(9) + token := GenerateToken(9) assert.NotEmpty(t, token) }) } func TestRandomPassword(t *testing.T) { - pw := Password() + pw := GeneratePasswd() t.Logf("password: %s", pw) assert.Equal(t, 8, len(pw)) } func BenchmarkRandomPassword(b *testing.B) { for n := 0; n < b.N; n++ { - Password() + GeneratePasswd() } } func BenchmarkRandomToken4(b *testing.B) { for n := 0; n < b.N; n++ { - Token(4) + GenerateToken(4) } } func BenchmarkRandomToken3(b *testing.B) { for n := 0; n < b.N; n++ { - Token(3) + GenerateToken(3) } } diff --git a/pkg/rnd/generate_uid.go b/pkg/rnd/generate_uid.go new file mode 100644 index 000000000..1f9e133c6 --- /dev/null +++ b/pkg/rnd/generate_uid.go @@ -0,0 +1,16 @@ +package rnd + +import ( + "strconv" + "time" +) + +// GenerateUID returns a unique id with prefix as string. +func GenerateUID(prefix byte) string { + result := make([]byte, 0, 16) + result = append(result, prefix) + result = append(result, strconv.FormatInt(time.Now().UTC().Unix(), 36)[0:6]...) + result = append(result, GenerateToken(9)...) + + return string(result) +} diff --git a/pkg/rnd/generate_uid_test.go b/pkg/rnd/generate_uid_test.go new file mode 100644 index 000000000..66ad16eb0 --- /dev/null +++ b/pkg/rnd/generate_uid_test.go @@ -0,0 +1,71 @@ +package rnd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPPID(t *testing.T) { + for n := 0; n < 5; n++ { + uid := GenerateUID('x') + t.Logf("id: %s", uid) + assert.Equal(t, len(uid), 16) + } +} + +func BenchmarkPPID(b *testing.B) { + for n := 0; n < b.N; n++ { + GenerateUID('x') + } +} + +func TestIsPPID(t *testing.T) { + prefix := byte('x') + + for n := 0; n < 10; n++ { + id := GenerateUID(prefix) + assert.True(t, EntityUID(id, prefix)) + } + + assert.True(t, EntityUID("lt9k3pw1wowuy3c2", 'l')) + assert.False(t, EntityUID("lt9k3pw1wowuy3c2123", 'l')) + assert.False(t, EntityUID("lt9k3pw1wowuy3c2123", 'l')) + assert.False(t, EntityUID("lt9k3pw1AAA-owuy3c2123", 'l')) + assert.False(t, EntityUID("", 'l')) + assert.False(t, EntityUID("lt9k3pw1w ?owuy 3c2123", 'l')) +} + +func TestIsHex(t *testing.T) { + assert.True(t, IsHex("dafbfeb8-a129-4e7c-9cf0-e7996a701cdb")) + assert.True(t, IsHex("6ba7b810-9dad-11d1-80b4")) + assert.False(t, IsHex("55785BAC-9A4B-4747-B090-GE123FFEE437")) + assert.False(t, IsHex("550e8400-e29b-11d4-a716_446655440000")) + assert.True(t, IsHex("4B1FEF2D1CF4A5BE38B263E0637EDEAD")) + assert.False(t, IsHex("")) +} + +func TestUniqueID(t *testing.T) { + assert.True(t, ValidID("lt9k3pw1wowuy3c2", 'l')) + assert.True(t, ValidID("dafbfeb8-a129-4e7c-9cf0-e7996a701cdb", 'l')) + assert.True(t, ValidID("6ba7b810-9dad-11d1-80b4-00c04fd430c8", 'l')) + assert.True(t, ValidID("55785BAC-9A4B-4747-B090-EE123FFEE437", 'l')) + assert.True(t, ValidID("550e8400-e29b-11d4-a716-446655440000", 'l')) + assert.False(t, ValidID("4B1FEF2D1CF4A5BE38B263E0637EDEAD", 'l')) + assert.False(t, ValidID("123", '1')) + assert.False(t, ValidID("_", '_')) + assert.False(t, ValidID("", '_')) +} + +func TestUniqueIDs(t *testing.T) { + assert.True(t, ValidIDs([]string{"lt9k3pw1wowuy3c2", "ltxk3pwawowuy0c0"}, 'l')) + assert.True(t, ValidIDs([]string{"dafbfeb8-a129-4e7c-9cf0-e7996a701cdb"}, 'l')) + assert.False(t, ValidIDs([]string{"_"}, '_')) + assert.False(t, ValidIDs([]string{""}, '_')) +} + +func TestIsLowerAlnum(t *testing.T) { + assert.False(t, IsAlnum("dafbfeb8-a129-4e7c-9cf0-e7996a701cdb")) + assert.True(t, IsAlnum("dafbe7996a701cdb")) + assert.False(t, IsAlnum("")) +} diff --git a/pkg/rnd/generate_uuid.go b/pkg/rnd/generate_uuid.go new file mode 100644 index 000000000..b03707502 --- /dev/null +++ b/pkg/rnd/generate_uuid.go @@ -0,0 +1,10 @@ +package rnd + +import ( + uuid "github.com/satori/go.uuid" +) + +// UUID returns a standard, random UUID as string. +func UUID() string { + return uuid.NewV4().String() +} diff --git a/pkg/rnd/generate_uuid_test.go b/pkg/rnd/generate_uuid_test.go new file mode 100644 index 000000000..cb7af43fb --- /dev/null +++ b/pkg/rnd/generate_uuid_test.go @@ -0,0 +1,21 @@ +package rnd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUUID(t *testing.T) { + for n := 0; n < 5; n++ { + uuid := UUID() + t.Logf("token: %s", uuid) + assert.Equal(t, 36, len(uuid)) + } +} + +func BenchmarkUUID(b *testing.B) { + for n := 0; n < b.N; n++ { + UUID() + } +} diff --git a/pkg/rnd/password.go b/pkg/rnd/password.go deleted file mode 100644 index 4965bfc6f..000000000 --- a/pkg/rnd/password.go +++ /dev/null @@ -1,6 +0,0 @@ -package rnd - -// Password returns a random password with 8 characters as string. -func Password() string { - return Token(8) -} diff --git a/pkg/rnd/uid.go b/pkg/rnd/uid.go deleted file mode 100644 index ad881d48a..000000000 --- a/pkg/rnd/uid.go +++ /dev/null @@ -1,89 +0,0 @@ -package rnd - -import ( - "strconv" - "time" -) - -// PPID returns a unique id with prefix as string. -func PPID(prefix byte) string { - result := make([]byte, 0, 16) - result = append(result, prefix) - result = append(result, strconv.FormatInt(time.Now().UTC().Unix(), 36)[0:6]...) - result = append(result, Token(9)...) - - return string(result) -} - -// IsPPID returns true if string is a unique id as generated by PhotoPrism. -func IsPPID(s string, prefix byte) bool { - if len(s) != 16 { - return false - } - - if !IsLowerAlnum(s) { - return false - } - - return prefix == 0 || s[0] == prefix -} - -// IsHex returns true if the string only contains hex numbers, dashes and letters without whitespace. -func IsHex(s string) bool { - if s == "" { - return false - } - - for _, r := range s { - if (r < 48 || r > 57) && (r < 97 || r > 102) && (r < 65 || r > 70) && r != 45 { - return false - } - } - - return true -} - -// IsLowerAlnum returns true if the string only contains alphanumeric ascii chars without whitespace. -func IsLowerAlnum(s string) bool { - if s == "" { - return false - } - - for _, r := range s { - if (r < 48 || r > 57) && (r < 97 || r > 122) { - return false - } - } - - return true -} - -// IsUID returns true if string is a seemingly unique id. -func IsUID(s string, prefix byte) bool { - // Regular UUID. - if IsUUID(s) { - return true - } - - // Not a known UID format. - if len(s) != 16 { - return false - } - - return IsPPID(s, prefix) -} - -// ContainsUIDs tests if a slice of strings contains UIDs only. -func ContainsUIDs(s []string, prefix byte) bool { - if len(s) < 1 { - return false - } - - for _, id := range s { - if !IsUID(id, prefix) { - return false - } - } - - return true -} diff --git a/pkg/rnd/uid_test.go b/pkg/rnd/uid_test.go deleted file mode 100644 index 40784fd9c..000000000 --- a/pkg/rnd/uid_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package rnd - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestPPID(t *testing.T) { - for n := 0; n < 5; n++ { - uid := PPID('x') - t.Logf("id: %s", uid) - assert.Equal(t, len(uid), 16) - } -} - -func BenchmarkPPID(b *testing.B) { - for n := 0; n < b.N; n++ { - PPID('x') - } -} - -func TestIsPPID(t *testing.T) { - prefix := byte('x') - - for n := 0; n < 10; n++ { - id := PPID(prefix) - assert.True(t, IsPPID(id, prefix)) - } - - assert.True(t, IsPPID("lt9k3pw1wowuy3c2", 'l')) - assert.False(t, IsPPID("lt9k3pw1wowuy3c2123", 'l')) - assert.False(t, IsPPID("lt9k3pw1wowuy3c2123", 'l')) - assert.False(t, IsPPID("lt9k3pw1AAA-owuy3c2123", 'l')) - assert.False(t, IsPPID("", 'l')) - assert.False(t, IsPPID("lt9k3pw1w ?owuy 3c2123", 'l')) -} - -func TestIsHex(t *testing.T) { - assert.True(t, IsHex("dafbfeb8-a129-4e7c-9cf0-e7996a701cdb")) - assert.True(t, IsHex("6ba7b810-9dad-11d1-80b4")) - assert.False(t, IsHex("55785BAC-9A4B-4747-B090-GE123FFEE437")) - assert.False(t, IsHex("550e8400-e29b-11d4-a716_446655440000")) - assert.True(t, IsHex("4B1FEF2D1CF4A5BE38B263E0637EDEAD")) - assert.False(t, IsHex("")) -} - -func TestIsUID(t *testing.T) { - assert.True(t, IsUID("lt9k3pw1wowuy3c2", 'l')) - assert.True(t, IsUID("dafbfeb8-a129-4e7c-9cf0-e7996a701cdb", 'l')) - assert.True(t, IsUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8", 'l')) - assert.True(t, IsUID("55785BAC-9A4B-4747-B090-EE123FFEE437", 'l')) - assert.True(t, IsUID("550e8400-e29b-11d4-a716-446655440000", 'l')) - assert.False(t, IsUID("4B1FEF2D1CF4A5BE38B263E0637EDEAD", 'l')) - assert.False(t, IsUID("123", '1')) - assert.False(t, IsUID("_", '_')) - assert.False(t, IsUID("", '_')) -} - -func TestContainsUIDs(t *testing.T) { - assert.True(t, ContainsUIDs([]string{"lt9k3pw1wowuy3c2", "ltxk3pwawowuy0c0"}, 'l')) - assert.True(t, ContainsUIDs([]string{"dafbfeb8-a129-4e7c-9cf0-e7996a701cdb"}, 'l')) - assert.False(t, ContainsUIDs([]string{"_"}, '_')) - assert.False(t, ContainsUIDs([]string{""}, '_')) -} - -func TestIsLowerAlnum(t *testing.T) { - assert.False(t, IsLowerAlnum("dafbfeb8-a129-4e7c-9cf0-e7996a701cdb")) - assert.True(t, IsLowerAlnum("dafbe7996a701cdb")) - assert.False(t, IsLowerAlnum("")) -} diff --git a/pkg/rnd/uuid.go b/pkg/rnd/uuid.go deleted file mode 100644 index febb573dc..000000000 --- a/pkg/rnd/uuid.go +++ /dev/null @@ -1,36 +0,0 @@ -package rnd - -import ( - "strings" - - uuid "github.com/satori/go.uuid" -) - -// UUID returns a standard, random UUID as string. -func UUID() string { - return uuid.NewV4().String() -} - -// IsUUID tests if the string looks like a standard UUID. -func IsUUID(s string) bool { - return len(s) == 36 && IsHex(s) -} - -// SanitizeUUID normalizes UUIDs found in XMP or Exif metadata. -func SanitizeUUID(s string) string { - if s == "" { - return "" - } - - s = strings.Replace(strings.TrimSpace(s), "\"", "", -1) - - if start := strings.LastIndex(s, ":"); start != -1 { - s = s[start+1:] - } - - if !IsUUID(s) { - return "" - } - - return strings.ToLower(s) -} diff --git a/pkg/rnd/validation.go b/pkg/rnd/validation.go new file mode 100644 index 000000000..b49705591 --- /dev/null +++ b/pkg/rnd/validation.go @@ -0,0 +1,100 @@ +package rnd + +import "strings" + +// ValidID checks if the string is a valid unique ID. +func ValidID(s string, prefix byte) bool { + // Regular UUID. + if ValidUUID(s) { + return true + } + + // Not a known GenerateUID format. + if len(s) != 16 { + return false + } + + return EntityUID(s, prefix) +} + +// ValidIDs checks if a slice of strings contains ValidIDs only. +func ValidIDs(s []string, prefix byte) bool { + if len(s) < 1 { + return false + } + + for _, id := range s { + if !ValidID(id, prefix) { + return false + } + } + + return true +} + +// EntityUID returns true if string is a unique id as generated by PhotoPrism. +func EntityUID(s string, prefix byte) bool { + if len(s) != 16 { + return false + } + + if !IsAlnum(s) { + return false + } + + return prefix == 0 || s[0] == prefix +} + +// ValidUUID tests if the string looks like a standard UUID. +func ValidUUID(s string) bool { + return len(s) == 36 && IsHex(s) +} + +// SanitizeUUID normalizes UUIDs found in XMP or Exif metadata. +func SanitizeUUID(s string) string { + if s == "" { + return "" + } + + s = strings.Replace(strings.TrimSpace(s), "\"", "", -1) + + if start := strings.LastIndex(s, ":"); start != -1 { + s = s[start+1:] + } + + if !ValidUUID(s) { + return "" + } + + return strings.ToLower(s) +} + +// IsAlnum returns true if the string only contains alphanumeric ascii chars without whitespace. +func IsAlnum(s string) bool { + if s == "" { + return false + } + + for _, r := range s { + if (r < 48 || r > 57) && (r < 97 || r > 122) { + return false + } + } + + return true +} + +// IsHex returns true if the string only contains hex numbers, dashes and letters without whitespace. +func IsHex(s string) bool { + if s == "" { + return false + } + + for _, r := range s { + if (r < 48 || r > 57) && (r < 97 || r > 102) && (r < 65 || r > 70) && r != 45 { + return false + } + } + + return true +} diff --git a/pkg/rnd/uuid_test.go b/pkg/rnd/validation_test.go similarity index 62% rename from pkg/rnd/uuid_test.go rename to pkg/rnd/validation_test.go index 106f9ac04..6423cebd1 100644 --- a/pkg/rnd/uuid_test.go +++ b/pkg/rnd/validation_test.go @@ -6,26 +6,12 @@ import ( "github.com/stretchr/testify/assert" ) -func TestUUID(t *testing.T) { - for n := 0; n < 5; n++ { - uuid := UUID() - t.Logf("token: %s", uuid) - assert.Equal(t, 36, len(uuid)) - } -} - -func BenchmarkUUID(b *testing.B) { - for n := 0; n < b.N; n++ { - UUID() - } -} - func TestIsUUID(t *testing.T) { - assert.True(t, IsUUID("dafbfeb8-a129-4e7c-9cf0-e7996a701cdb")) - assert.True(t, IsUUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8")) - assert.False(t, IsUUID("55785BAC-9H4B-4747-B090-EE123FFEE437")) - assert.True(t, IsUUID("550e8400-e29b-11d4-a716-446655440000")) - assert.False(t, IsUUID("4B1FEF2D1CF4A5BE38B263E0637EDEAD")) + assert.True(t, ValidUUID("dafbfeb8-a129-4e7c-9cf0-e7996a701cdb")) + assert.True(t, ValidUUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8")) + assert.False(t, ValidUUID("55785BAC-9H4B-4747-B090-EE123FFEE437")) + assert.True(t, ValidUUID("550e8400-e29b-11d4-a716-446655440000")) + assert.False(t, ValidUUID("4B1FEF2D1CF4A5BE38B263E0637EDEAD")) } func TestSanitizeUUID(t *testing.T) { diff --git a/pkg/txt/strings.go b/pkg/txt/compare.go similarity index 100% rename from pkg/txt/strings.go rename to pkg/txt/compare.go diff --git a/pkg/txt/strings_test.go b/pkg/txt/compare_test.go similarity index 100% rename from pkg/txt/strings_test.go rename to pkg/txt/compare_test.go diff --git a/pkg/txt/separator.go b/pkg/txt/separator.go new file mode 100644 index 000000000..bf2961fb4 --- /dev/null +++ b/pkg/txt/separator.go @@ -0,0 +1,29 @@ +package txt + +import ( + "unicode" +) + +// isSeparator reports whether the rune could mark a word boundary. +func isSeparator(r rune) bool { + // ASCII alphanumerics and underscore are not separators + if r <= 0x7F { + switch { + case '0' <= r && r <= '9': + return false + case 'a' <= r && r <= 'z': + return false + case 'A' <= r && r <= 'Z': + return false + case r == '_', r == '\'': + return false + } + return true + } + // Letters and digits are not separators + if unicode.IsLetter(r) || unicode.IsDigit(r) { + return false + } + // Otherwise, all we can do for now is treat spaces as separators. + return unicode.IsSpace(r) +} diff --git a/pkg/txt/capitalization.go b/pkg/txt/title.go similarity index 56% rename from pkg/txt/capitalization.go rename to pkg/txt/title.go index d73df6e79..f77430e40 100644 --- a/pkg/txt/capitalization.go +++ b/pkg/txt/title.go @@ -5,38 +5,6 @@ import ( "unicode" ) -// isSeparator reports whether the rune could mark a word boundary. -func isSeparator(r rune) bool { - // ASCII alphanumerics and underscore are not separators - if r <= 0x7F { - switch { - case '0' <= r && r <= '9': - return false - case 'a' <= r && r <= 'z': - return false - case 'A' <= r && r <= 'Z': - return false - case r == '_', r == '\'': - return false - } - return true - } - // Letters and digits are not separators - if unicode.IsLetter(r) || unicode.IsDigit(r) { - return false - } - // Otherwise, all we can do for now is treat spaces as separators. - return unicode.IsSpace(r) -} - -// UcFirst returns the string with the first character converted to uppercase. -func UcFirst(str string) string { - for i, v := range str { - return string(unicode.ToUpper(v)) + str[i+1:] - } - return "" -} - // Title returns the string with the first characters of each word converted to uppercase. func Title(s string) string { s = strings.ReplaceAll(s, "_", " ") diff --git a/pkg/txt/uppercase.go b/pkg/txt/uppercase.go new file mode 100644 index 000000000..ff39a8975 --- /dev/null +++ b/pkg/txt/uppercase.go @@ -0,0 +1,13 @@ +package txt + +import ( + "unicode" +) + +// UpperFirst returns the string with the first character converted to uppercase. +func UpperFirst(str string) string { + for i, v := range str { + return string(unicode.ToUpper(v)) + str[i+1:] + } + return "" +} diff --git a/pkg/txt/capitalization_test.go b/pkg/txt/uppercase_test.go similarity index 96% rename from pkg/txt/capitalization_test.go rename to pkg/txt/uppercase_test.go index 56a34db6a..6a738d39a 100644 --- a/pkg/txt/capitalization_test.go +++ b/pkg/txt/uppercase_test.go @@ -35,16 +35,16 @@ func TestIsSeparator(t *testing.T) { func TestUcFirst(t *testing.T) { t.Run("photo-lover", func(t *testing.T) { - assert.Equal(t, "Photo-lover", UcFirst("photo-lover")) + assert.Equal(t, "Photo-lover", UpperFirst("photo-lover")) }) t.Run("cat", func(t *testing.T) { - assert.Equal(t, "Cat", UcFirst("Cat")) + assert.Equal(t, "Cat", UpperFirst("Cat")) }) t.Run("KwaZulu natal", func(t *testing.T) { assert.Equal(t, "KwaZulu Natal", Title("KwaZulu natal")) }) t.Run("empty string", func(t *testing.T) { - assert.Equal(t, "", UcFirst("")) + assert.Equal(t, "", UpperFirst("")) }) } diff --git a/pkg/video/codecs.go b/pkg/video/codecs.go new file mode 100644 index 000000000..8aa818636 --- /dev/null +++ b/pkg/video/codecs.go @@ -0,0 +1,27 @@ +package video + +type Codec string + +const ( + UnknownCodec Codec = "" + CodecAVC Codec = "avc1" + CodecHEVC Codec = "hvc1" + CodecVVC Codec = "vvc" + CodecAV1 Codec = "av01" +) + +// Codecs maps identifiers to codecs. +var Codecs = StandardCodecs{ + "": UnknownCodec, + "avc": CodecAVC, + "avc1": CodecAVC, + "hvc1": CodecHEVC, + "hvc": CodecHEVC, + "hevc": CodecHEVC, + "vvc": CodecVVC, + "av1": CodecAV1, + "av01": CodecAV1, +} + +// StandardCodecs maps names to known codecs. +type StandardCodecs map[string]Codec diff --git a/pkg/video/codecs_test.go b/pkg/video/codecs_test.go new file mode 100644 index 000000000..99807e929 --- /dev/null +++ b/pkg/video/codecs_test.go @@ -0,0 +1,17 @@ +package video + +import "testing" + +func TestCodecs(t *testing.T) { + if val := Codecs[""]; val != UnknownCodec { + t.Fatal("default codec should be UnknownCodec") + } + + if val := Codecs["avc"]; val != CodecAVC { + t.Fatal("codec should be CodecAVC") + } + + if val := Codecs["av1"]; val != CodecAV1 { + t.Fatal("codec should be CodecAV1") + } +} diff --git a/pkg/video/standards.go b/pkg/video/standards.go new file mode 100644 index 000000000..00b718322 --- /dev/null +++ b/pkg/video/standards.go @@ -0,0 +1,20 @@ +package video + +// Types maps identifiers to standards. +var Types = Standards{ + "": AVC, + "mp4": MP4, + "mpeg4": MP4, + "avc": AVC, + "avc1": AVC, + "hvc": HEVC, + "hvc1": HEVC, + "hevc": HEVC, + "vvc": VVC, + "vvc1": VVC, + "av1": AV1, + "av01": AV1, +} + +// Standards maps names to standardized formats. +type Standards map[string]Type diff --git a/pkg/video/type.go b/pkg/video/type.go new file mode 100644 index 000000000..cb3f359cc --- /dev/null +++ b/pkg/video/type.go @@ -0,0 +1,14 @@ +package video + +import ( + "github.com/photoprism/photoprism/pkg/fs" +) + +// Type represents a video format type. +type Type struct { + File fs.Type + Codec Codec + Width int + Height int + Public bool +} diff --git a/pkg/video/types.go b/pkg/video/types.go new file mode 100644 index 000000000..5edbc762d --- /dev/null +++ b/pkg/video/types.go @@ -0,0 +1,50 @@ +package video + +import ( + "github.com/photoprism/photoprism/pkg/fs" +) + +// MP4 is a Multimedia Container (MPEG-4 Part 14). +var MP4 = Type{ + File: fs.VideoMP4, + Codec: CodecAVC, + Width: 0, + Height: 0, + Public: true, +} + +// AVC aka Advanced Video Coding (H.264). +var AVC = Type{ + File: fs.VideoAVC, + Codec: CodecAVC, + Width: 0, + Height: 0, + Public: true, +} + +// AV1 aka AOMedia Video 1. +var AV1 = Type{ + File: fs.VideoAV1, + Codec: CodecAV1, + Width: 0, + Height: 0, + Public: false, +} + +// HEVC aka High Efficiency Video Coding (H.265). +var HEVC = Type{ + File: fs.VideoHEVC, + Codec: CodecHEVC, + Width: 0, + Height: 0, + Public: false, +} + +// VVC aka Versatile Video Coding (H.266). +var VVC = Type{ + File: fs.VideoVVC, + Codec: CodecVVC, + Width: 0, + Height: 0, + Public: false, +} diff --git a/internal/video/formats_test.go b/pkg/video/types_test.go similarity index 51% rename from internal/video/formats_test.go rename to pkg/video/types_test.go index 42a22b40f..d5cbec2b6 100644 --- a/internal/video/formats_test.go +++ b/pkg/video/types_test.go @@ -2,16 +2,16 @@ package video import "testing" -func TestFormats(t *testing.T) { - if val := Formats[""]; val != AVC { +func TestTypes(t *testing.T) { + if val := Types[""]; val != AVC { t.Fatal("default type should be avc") } - if val := Formats["mp4"]; val != MP4 { + if val := Types["mp4"]; val != MP4 { t.Fatal("mp4 type should be mp4") } - if val := Formats["avc"]; val != AVC { + if val := Types["avc"]; val != AVC { t.Fatal("mp4 type should be avc") } } diff --git a/internal/video/video.go b/pkg/video/video.go similarity index 100% rename from internal/video/video.go rename to pkg/video/video.go