Merge remote-tracking branch 'origin/develop' into feature/oidc-v2

# Conflicts:
#	docker-compose.yml
#	go.sum
#	internal/config/flags.go
#	internal/config/options.go
This commit is contained in:
Timo Volkmann 2021-10-06 13:45:32 +02:00
commit ca0abb1a95
250 changed files with 6550 additions and 4286 deletions

View file

@ -1,4 +1,8 @@
FROM photoprism/development:20210922
FROM photoprism/development:20210929
# Copy latest entrypoint script
COPY --chown=root:root /docker/development/entrypoint.sh /entrypoint.sh
COPY --chown=root:root /docker/scripts/Makefile /root/Makefile
# Set up project directory
WORKDIR "/go/src/github.com/photoprism/photoprism"

View file

@ -40,16 +40,17 @@ To get a first impression, you're welcome to play with our public demo at [demo.
Step-by-step installation instructions for our self-hosted [community edition](https://photoprism.app/get) can be found
on [docs.photoprism.org](https://docs.photoprism.org/getting-started/) -
all you need is a Web browser and Docker to run the server. It is available for Mac, Linux, and Windows.
all you need is a Web browser and [Docker](https://docs.docker.com/get-docker/) to run the server.
It is available for Mac, Linux, and Windows.
We recommend hosting PhotoPrism on a server with **at least 2 cores** and **4 GB of memory**.
Also make sure it has at least 4 GB of swap configured, so that indexing doesn't cause
restarts when there are memory usage spikes.
Beyond these minimum requirements, the amount of RAM should match the number of cores.
## New Release 🌈 ##
Indexing large photo and video collections significantly benefits from fast, local SSD storage,
and lots of memory for caching. Especially the conversion of RAW images and the transcoding of
videos are very demanding.
The [latest release](https://docs.photoprism.org/release-notes/) not only includes
**facial recognition**, it also comes as a
**single [multi-arch image](https://hub.docker.com/r/photoprism/photoprism) for AMD64, ARM64, and ARMv7**.
That means you don't need to pull from different Docker repositories anymore.
We recommend updating your existing `docker-compose.yml` config based on
[our examples](https://dl.photoprism.org/docker/).
## Roadmap ##
@ -79,13 +80,13 @@ In addition, you can find us on [Patreon](https://www.patreon.com/photoprism) an
Your continuous support helps...
* pay for operating expenses and external services like satellite maps
* developing new features and keeping them free for everyone 🌈
* pay for operating expenses and external services like satellite maps 🛰 🌏
* **developing new features and keeping them free for everyone**
Also, please [leave a star](https://github.com/photoprism/photoprism/stargazers) on GitHub if you like this project.
It provides additional motivation to keep going.
Thank you very much! <3
Thank you very much! ❤️
<sup>1</sup> Ideas backed by one or more [eligible sponsors](SPONSORS.md) are marked with a [golden label](https://github.com/photoprism/photoprism/issues?q=is%3Aissue+is%3Aopen+label%3Asponsor).
Let us know if we mistakenly [label an idea as unfunded](https://github.com/photoprism/photoprism/issues?q=is%3Aissue+is%3Aopen+label%3Aunfunded).

Binary file not shown.

View file

@ -7,280 +7,323 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-01-03 19:34+0000\n"
"PO-Revision-Date: 2021-01-07 15:13+0100\n"
"POT-Creation-Date: 2021-10-01 12:14+0000\n"
"PO-Revision-Date: 2021-10-03 09:05+0200\n"
"Last-Translator: Michael Mayer <michael@photoprism.org>\n"
"Language-Team: \n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.4.2\n"
"X-Generator: Poedit 3.0\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: messages.go:73
#: messages.go:82
msgid "Unexpected error, please try again"
msgstr "Erreur imprévue, veuillez recommencer"
#: messages.go:74
#: messages.go:83
msgid "Invalid request"
msgstr "Requête incorrecte"
#: messages.go:75
#: messages.go:84
msgid "Changes could not be saved"
msgstr "Les modifications n'ont pas pu être sauvegardées"
#: messages.go:76
#: messages.go:85
msgid "Could not be deleted"
msgstr "N'a pu être supprimé"
#: messages.go:77
#: messages.go:86
#, c-format
msgid "%s already exists"
msgstr "%s existe déjà"
#: messages.go:78 messages.go:81
msgid "Not found on server, deleted?"
msgstr "Non trouvé sur le serveur. Supprimé?"
#: messages.go:87
msgid "Not found"
msgstr "Non trouvé"
#: messages.go:79
#: messages.go:88
msgid "File not found"
msgstr "Fichier non trouvé"
#: messages.go:80
#: messages.go:89
msgid "Selection not found"
msgstr "Sélection non trouvée"
#: messages.go:82
#: messages.go:90
msgid "Entity not found"
msgstr "Entité non trouvée"
#: messages.go:91
msgid "Account not found"
msgstr "Compte non trouvé"
#: messages.go:83
#: messages.go:92
msgid "User not found"
msgstr "Utilisateur non trouvé"
#: messages.go:84
#: messages.go:93
msgid "Label not found"
msgstr "Étiquette non trouvée"
#: messages.go:85
#: messages.go:94
msgid "Album not found"
msgstr "Album non trouvé"
#: messages.go:86
#: messages.go:95
msgid "Subject not found"
msgstr "Sujet non trouvé"
#: messages.go:96
msgid "Person not found"
msgstr "Personne non trouvée"
#: messages.go:97
msgid "Face not found"
msgstr "Visage non trouvé"
#: messages.go:98
msgid "Not available in public mode"
msgstr "Non disponible en mode public"
#: messages.go:87
#: messages.go:99
msgid "not available in read-only mode"
msgstr "non disponible en mode lecture seule"
#: messages.go:88
#: messages.go:100
msgid "Please log in and try again"
msgstr "Veuillez vous identifier et recommencer"
#: messages.go:89
#: messages.go:101
msgid "Upload might be offensive"
msgstr "Le chargement peut être choquant"
#: messages.go:90
#: messages.go:102
msgid "No items selected"
msgstr "Aucun élément sélectionné"
#: messages.go:91
#: messages.go:103
msgid "Failed creating file, please check permissions"
msgstr "Échec lors de la création du fichier, veuillez vérifier les autorisations"
#: messages.go:92
#: messages.go:104
msgid "Failed creating folder, please check permissions"
msgstr "Échec lors de la création du dossier, veuillez vérifier les autorisations"
#: messages.go:93
#: messages.go:105
msgid "Could not connect, please try again"
msgstr "Échec lors de la connexion, veuillez réessayer"
#: messages.go:94
#: messages.go:106
msgid "Invalid password, please try again"
msgstr "Mot de passe incorrect, veuillez réessayer"
#: messages.go:95
#: messages.go:107
msgid "Feature disabled"
msgstr "Fonctionnalité désactivée"
#: messages.go:96
#: messages.go:108
msgid "No labels selected"
msgstr "Aucune étiquette sélectionnée"
#: messages.go:97
#: messages.go:109
msgid "No albums selected"
msgstr "Aucun album sélectionné"
#: messages.go:98
#: messages.go:110
msgid "No files available for download"
msgstr "Aucun fichier disponible au téléchargement"
#: messages.go:99
#: messages.go:111
msgid "Failed to create zip file"
msgstr "Échec de la création de l'archive zip"
#: messages.go:100
#: messages.go:112
msgid "Invalid credentials"
msgstr "Les informations d'identification sont invalides"
#: messages.go:101
#: messages.go:113
msgid "Invalid link"
msgstr "Lien invalide"
#: messages.go:104
#: messages.go:114
msgid "Invalid name"
msgstr "Nom incorrect"
#: messages.go:117
msgid "Changes successfully saved"
msgstr "Les modifications ont bien été enregistrées"
#: messages.go:105
#: messages.go:118
msgid "Album created"
msgstr "Album créé"
#: messages.go:106
#: messages.go:119
msgid "Album saved"
msgstr "Album sauvegardé"
#: messages.go:107
#: messages.go:120
#, c-format
msgid "Album %s deleted"
msgstr "Album %s supprimé"
#: messages.go:108
#: messages.go:121
msgid "Album contents cloned"
msgstr "Le contenu de l'album a été cloné"
#: messages.go:109
#: messages.go:122
msgid "File removed from stack"
msgstr "Fichier retiré du groupe"
#: messages.go:110
#: messages.go:123
msgid "File deleted"
msgstr "Fichier supprimé"
#: messages.go:111
#: messages.go:124
#, c-format
msgid "Selection added to %s"
msgstr "Sélection ajoutée à %s"
#: messages.go:112
#: messages.go:125
#, c-format
msgid "One entry added to %s"
msgstr "Une entrée a été ajoutée à %s"
#: messages.go:113
#: messages.go:126
#, c-format
msgid "%d entries added to %s"
msgstr "%d entrées ont été ajoutées à %s"
#: messages.go:114
#: messages.go:127
#, c-format
msgid "One entry removed from %s"
msgstr "Une entrée a été supprimée de %s"
#: messages.go:115
#: messages.go:128
#, c-format
msgid "%d entries removed from %s"
msgstr "%d entrées ont été supprimées de %s"
#: messages.go:116
#: messages.go:129
msgid "Account created"
msgstr "Compte créé"
#: messages.go:117
#: messages.go:130
msgid "Account saved"
msgstr "Compte sauvegardé"
#: messages.go:118
#: messages.go:131
msgid "Account deleted"
msgstr "Compte supprimé"
#: messages.go:119
#: messages.go:132
msgid "Settings saved"
msgstr "Paramètres sauvegardés"
#: messages.go:120
#: messages.go:133
msgid "Password changed"
msgstr "Mode de passe changé"
#: messages.go:121
#: messages.go:134
#, c-format
msgid "Import completed in %d s"
msgstr "Importation terminée en %d s"
#: messages.go:122
#: messages.go:135
msgid "Import canceled"
msgstr "Importation annulée"
#: messages.go:123
#: messages.go:136
#, c-format
msgid "Indexing completed in %d s"
msgstr "Indexation terminée en %d s"
#: messages.go:124
#: messages.go:137
msgid "Indexing originals..."
msgstr "Indexage des originaux…"
#: messages.go:125
#: messages.go:138
#, c-format
msgid "Indexing files in %s"
msgstr "Indexation des fichiers de %s"
#: messages.go:126
#: messages.go:139
msgid "Indexing canceled"
msgstr "Indexation annulée"
#: messages.go:127
#: messages.go:140
#, c-format
msgid "Removed %d files and %d photos"
msgstr "Suppression de %d fichiers et %d photos"
#: messages.go:128
#: messages.go:141
#, c-format
msgid "Moving files from %s"
msgstr "Déplacement de fichiers depuis %s"
#: messages.go:129
#: messages.go:142
#, c-format
msgid "Copying files from %s"
msgstr "Copie de fichiers depuis %s"
#: messages.go:130
#: messages.go:143
msgid "Labels deleted"
msgstr "Étiquettes supprimées"
#: messages.go:131
#: messages.go:144
msgid "Label saved"
msgstr "Étiquettes sauvegardées"
#: messages.go:132
#: messages.go:145
msgid "Subject saved"
msgstr "Sujet sauvegardé"
#: messages.go:146
msgid "Subject deleted"
msgstr "Sujet supprimé"
#: messages.go:147
msgid "Person saved"
msgstr "Personne sauvegardée"
#: messages.go:148
msgid "Person deleted"
msgstr "Personne supprimée"
#: messages.go:149
#, c-format
msgid "%d files uploaded in %d s"
msgstr "%d fichiers chargés en %d s"
#: messages.go:133
#: messages.go:150
msgid "Selection approved"
msgstr "Sélection approuvée"
#: messages.go:134
#: messages.go:151
msgid "Selection archived"
msgstr "Sélection archivée"
#: messages.go:135
#: messages.go:152
msgid "Selection restored"
msgstr "Sélection restaurée"
#: messages.go:136
#: messages.go:153
msgid "Selection marked as private"
msgstr "Sélection marquée comme privée"
#: messages.go:137
#: messages.go:154
msgid "Albums deleted"
msgstr "Albums supprimés"
#: messages.go:138
#: messages.go:155
#, c-format
msgid "Zip created in %d s"
msgstr "Archive zip créée en %d s"
#: messages.go:156
msgid "Permanently deleted"
msgstr "Supprimé définitivement"
#~ msgid "Not found on server, deleted?"
#~ msgstr "Non trouvé sur le serveur. Supprimé?"

View file

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-09-18 17:16+0000\n"
"POT-Creation-Date: 2021-10-01 12:14+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -17,305 +17,309 @@ msgstr ""
"Content-Type: text/plain; charset=CHARSET\n"
"Content-Transfer-Encoding: 8bit\n"
#: messages.go:81
#: messages.go:82
msgid "Unexpected error, please try again"
msgstr ""
#: messages.go:82
#: messages.go:83
msgid "Invalid request"
msgstr ""
#: messages.go:83
#: messages.go:84
msgid "Changes could not be saved"
msgstr ""
#: messages.go:84
#: messages.go:85
msgid "Could not be deleted"
msgstr ""
#: messages.go:85
#: messages.go:86
#, c-format
msgid "%s already exists"
msgstr ""
#: messages.go:86
#: messages.go:87
msgid "Not found"
msgstr ""
#: messages.go:87
#: messages.go:88
msgid "File not found"
msgstr ""
#: messages.go:88
#: messages.go:89
msgid "Selection not found"
msgstr ""
#: messages.go:89
#: messages.go:90
msgid "Entity not found"
msgstr ""
#: messages.go:90
#: messages.go:91
msgid "Account not found"
msgstr ""
#: messages.go:91
#: messages.go:92
msgid "User not found"
msgstr ""
#: messages.go:92
#: messages.go:93
msgid "Label not found"
msgstr ""
#: messages.go:93
#: messages.go:94
msgid "Album not found"
msgstr ""
#: messages.go:94
#: messages.go:95
msgid "Subject not found"
msgstr ""
#: messages.go:95
#: messages.go:96
msgid "Person not found"
msgstr ""
#: messages.go:96
#: messages.go:97
msgid "Face not found"
msgstr ""
#: messages.go:97
#: messages.go:98
msgid "Not available in public mode"
msgstr ""
#: messages.go:98
#: messages.go:99
msgid "not available in read-only mode"
msgstr ""
#: messages.go:99
#: messages.go:100
msgid "Please log in and try again"
msgstr ""
#: messages.go:100
#: messages.go:101
msgid "Upload might be offensive"
msgstr ""
#: messages.go:101
#: messages.go:102
msgid "No items selected"
msgstr ""
#: messages.go:102
#: messages.go:103
msgid "Failed creating file, please check permissions"
msgstr ""
#: messages.go:103
#: messages.go:104
msgid "Failed creating folder, please check permissions"
msgstr ""
#: messages.go:104
#: messages.go:105
msgid "Could not connect, please try again"
msgstr ""
#: messages.go:105
#: messages.go:106
msgid "Invalid password, please try again"
msgstr ""
#: messages.go:106
#: messages.go:107
msgid "Feature disabled"
msgstr ""
#: messages.go:107
#: messages.go:108
msgid "No labels selected"
msgstr ""
#: messages.go:108
#: messages.go:109
msgid "No albums selected"
msgstr ""
#: messages.go:109
#: messages.go:110
msgid "No files available for download"
msgstr ""
#: messages.go:110
#: messages.go:111
msgid "Failed to create zip file"
msgstr ""
#: messages.go:111
#: messages.go:112
msgid "Invalid credentials"
msgstr ""
#: messages.go:112
#: messages.go:113
msgid "Invalid link"
msgstr ""
#: messages.go:115
msgid "Changes successfully saved"
msgstr ""
#: messages.go:116
msgid "Album created"
#: messages.go:114
msgid "Invalid name"
msgstr ""
#: messages.go:117
msgid "Album saved"
msgid "Changes successfully saved"
msgstr ""
#: messages.go:118
msgid "Album created"
msgstr ""
#: messages.go:119
msgid "Album saved"
msgstr ""
#: messages.go:120
#, c-format
msgid "Album %s deleted"
msgstr ""
#: messages.go:119
#: messages.go:121
msgid "Album contents cloned"
msgstr ""
#: messages.go:120
#: messages.go:122
msgid "File removed from stack"
msgstr ""
#: messages.go:121
msgid "File deleted"
msgstr ""
#: messages.go:122
#, c-format
msgid "Selection added to %s"
msgstr ""
#: messages.go:123
#, c-format
msgid "One entry added to %s"
msgid "File deleted"
msgstr ""
#: messages.go:124
#, c-format
msgid "%d entries added to %s"
msgid "Selection added to %s"
msgstr ""
#: messages.go:125
#, c-format
msgid "One entry removed from %s"
msgid "One entry added to %s"
msgstr ""
#: messages.go:126
#, c-format
msgid "%d entries removed from %s"
msgid "%d entries added to %s"
msgstr ""
#: messages.go:127
msgid "Account created"
#, c-format
msgid "One entry removed from %s"
msgstr ""
#: messages.go:128
msgid "Account saved"
#, c-format
msgid "%d entries removed from %s"
msgstr ""
#: messages.go:129
msgid "Account deleted"
msgid "Account created"
msgstr ""
#: messages.go:130
msgid "Settings saved"
msgid "Account saved"
msgstr ""
#: messages.go:131
msgid "Password changed"
msgid "Account deleted"
msgstr ""
#: messages.go:132
#, c-format
msgid "Import completed in %d s"
msgid "Settings saved"
msgstr ""
#: messages.go:133
msgid "Import canceled"
msgid "Password changed"
msgstr ""
#: messages.go:134
#, c-format
msgid "Indexing completed in %d s"
msgid "Import completed in %d s"
msgstr ""
#: messages.go:135
msgid "Indexing originals..."
msgid "Import canceled"
msgstr ""
#: messages.go:136
#, c-format
msgid "Indexing files in %s"
msgid "Indexing completed in %d s"
msgstr ""
#: messages.go:137
msgid "Indexing canceled"
msgid "Indexing originals..."
msgstr ""
#: messages.go:138
#, c-format
msgid "Removed %d files and %d photos"
msgid "Indexing files in %s"
msgstr ""
#: messages.go:139
#, c-format
msgid "Moving files from %s"
msgid "Indexing canceled"
msgstr ""
#: messages.go:140
#, c-format
msgid "Copying files from %s"
msgid "Removed %d files and %d photos"
msgstr ""
#: messages.go:141
msgid "Labels deleted"
#, c-format
msgid "Moving files from %s"
msgstr ""
#: messages.go:142
msgid "Label saved"
#, c-format
msgid "Copying files from %s"
msgstr ""
#: messages.go:143
msgid "Subject saved"
msgid "Labels deleted"
msgstr ""
#: messages.go:144
msgid "Subject deleted"
msgid "Label saved"
msgstr ""
#: messages.go:145
msgid "Person saved"
msgid "Subject saved"
msgstr ""
#: messages.go:146
msgid "Person deleted"
msgid "Subject deleted"
msgstr ""
#: messages.go:147
msgid "Person saved"
msgstr ""
#: messages.go:148
msgid "Person deleted"
msgstr ""
#: messages.go:149
#, c-format
msgid "%d files uploaded in %d s"
msgstr ""
#: messages.go:148
#: messages.go:150
msgid "Selection approved"
msgstr ""
#: messages.go:149
#: messages.go:151
msgid "Selection archived"
msgstr ""
#: messages.go:150
#: messages.go:152
msgid "Selection restored"
msgstr ""
#: messages.go:151
#: messages.go:153
msgid "Selection marked as private"
msgstr ""
#: messages.go:152
#: messages.go:154
msgid "Albums deleted"
msgstr ""
#: messages.go:153
#: messages.go:155
#, c-format
msgid "Zip created in %d s"
msgstr ""
#: messages.go:154
#: messages.go:156
msgid "Permanently deleted"
msgstr ""

View file

@ -55,25 +55,25 @@ func main() {
app.Commands = []cli.Command{
commands.StartCommand,
commands.StopCommand,
commands.StatusCommand,
commands.IndexCommand,
commands.ImportCommand,
commands.CopyCommand,
commands.FacesCommand,
commands.MomentsCommand,
commands.OptimizeCommand,
commands.PurgeCommand,
commands.CleanUpCommand,
commands.CopyCommand,
commands.OptimizeCommand,
commands.MomentsCommand,
commands.ConvertCommand,
commands.ResampleCommand,
commands.ThumbsCommand,
commands.MigrateCommand,
commands.BackupCommand,
commands.RestoreCommand,
commands.ResetCommand,
commands.ConfigCommand,
commands.UsersCommand,
commands.PasswdCommand,
commands.UsersCommand,
commands.ConfigCommand,
commands.VersionCommand,
commands.StatusCommand,
}
if err := app.Run(os.Args); err != nil {

View file

@ -40,6 +40,7 @@ services:
PHOTOPRISM_STORAGE_PATH: "/go/src/github.com/photoprism/photoprism/storage"
PHOTOPRISM_ORIGINALS_PATH: "/go/src/github.com/photoprism/photoprism/storage/originals"
PHOTOPRISM_IMPORT_PATH: "/go/src/github.com/photoprism/photoprism/storage/import"
PHOTOPRISM_DISABLE_CHOWN: "true" # Disables storage permission updates on startup
PHOTOPRISM_DISABLE_BACKUPS: "false" # Don't backup photo and album metadata to YAML files
PHOTOPRISM_DISABLE_WEBDAV: "false" # Disables built-in WebDAV server
PHOTOPRISM_DISABLE_SETTINGS: "false" # Disables Settings in Web UI

View file

@ -10,7 +10,8 @@ services:
ports:
- "2344:2342" # [server]:[container]
environment:
UID: ${UID:-1000}
PHOTOPRISM_UID: ${UID:-1000}
PHOTOPRISM_GID: ${GID:-1000}
PHOTOPRISM_SITE_URL: "http://localhost:2344/"
PHOTOPRISM_SITE_TITLE: "PhotoPrism"
PHOTOPRISM_SITE_CAPTION: "Browse Your Life"
@ -30,6 +31,7 @@ services:
PHOTOPRISM_DATABASE_USER: "root"
PHOTOPRISM_DATABASE_PASSWORD: "photoprism"
PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # The initial admin password (min 4 characters)
PHOTOPRISM_DISABLE_CHOWN: "false" # Disables storage permission updates on startup
PHOTOPRISM_DISABLE_BACKUPS: "false" # Don't backup photo and album metadata to YAML files
PHOTOPRISM_DISABLE_WEBDAV: "false" # Disables built-in WebDAV server
PHOTOPRISM_DISABLE_SETTINGS: "false" # Disables Settings in Web UI

View file

@ -14,13 +14,10 @@ services:
- "2342:2342" # Web Server (PhotoPrism)
- "2343:2343" # Acceptance Tests
- "40000:40000" # Go Debugging
working_dir: "/go/src/github.com/photoprism/photoprism"
volumes:
- ".:/go/src/github.com/photoprism/photoprism"
- "go-mod:/go/pkg/mod"
shm_size: "2gb"
environment:
UID: ${UID:-1000}
PHOTOPRISM_UID: ${UID:-1000}
PHOTOPRISM_GID: ${GID:-1000}
PHOTOPRISM_SITE_URL: "http://localhost:2342/"
PHOTOPRISM_SITE_TITLE: "PhotoPrism"
PHOTOPRISM_SITE_CAPTION: "Browse Your Life"
@ -28,12 +25,12 @@ services:
PHOTOPRISM_SITE_AUTHOR: "@photoprism_app"
PHOTOPRISM_DEBUG: "true"
PHOTOPRISM_READONLY: "false"
PHOTOPRISM_PUBLIC: "false"
PHOTOPRISM_PUBLIC: "true"
PHOTOPRISM_EXPERIMENTAL: "true"
PHOTOPRISM_SERVER_MODE: "debug"
PHOTOPRISM_HTTP_HOST: "0.0.0.0"
PHOTOPRISM_HTTP_PORT: 2342
PHOTOPRISM_HTTP_COMPRESSION: "gzip" # Improves transfer speed and bandwidth utilization (none or gzip)
PHOTOPRISM_HTTP_COMPRESSION: "gzip" # Improves transfer speed and bandwidth utilization (none or gzip)
PHOTOPRISM_DATABASE_DRIVER: "mysql"
PHOTOPRISM_DATABASE_SERVER: "mariadb:4001"
PHOTOPRISM_DATABASE_NAME: "photoprism"
@ -41,35 +38,52 @@ services:
PHOTOPRISM_DATABASE_PASSWORD: "photoprism"
PHOTOPRISM_TEST_DRIVER: "sqlite"
PHOTOPRISM_TEST_DSN: ".test.db"
PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # The initial admin password (min 4 characters)
PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # The initial admin password (min 4 characters)
PHOTOPRISM_ASSETS_PATH: "/go/src/github.com/photoprism/photoprism/assets"
PHOTOPRISM_STORAGE_PATH: "/go/src/github.com/photoprism/photoprism/storage"
PHOTOPRISM_ORIGINALS_PATH: "/go/src/github.com/photoprism/photoprism/storage/originals"
PHOTOPRISM_IMPORT_PATH: "/go/src/github.com/photoprism/photoprism/storage/import"
PHOTOPRISM_DISABLE_BACKUPS: "false" # Don't backup photo and album metadata to YAML files
PHOTOPRISM_DISABLE_WEBDAV: "false" # Disables built-in WebDAV server
PHOTOPRISM_DISABLE_SETTINGS: "false" # Disables Settings in Web UI
PHOTOPRISM_DISABLE_PLACES: "false" # Disables reverse geocoding and maps
PHOTOPRISM_DISABLE_EXIFTOOL: "false" # Don't create ExifTool JSON files for improved metadata extraction
PHOTOPRISM_DISABLE_TENSORFLOW: "false" # Don't use TensorFlow for image classification
PHOTOPRISM_DETECT_NSFW: "false" # Flag photos as private that MAY be offensive (requires TensorFlow)
PHOTOPRISM_UPLOAD_NSFW: "false" # Allows uploads that may be offensive
PHOTOPRISM_DARKTABLE_PRESETS: "false" # Enables Darktable presets and disables concurrent RAW conversion
PHOTOPRISM_THUMB_FILTER: "lanczos" # Resample filter, best to worst: blackman, lanczos, cubic, linear
PHOTOPRISM_THUMB_UNCACHED: "true" # Enables on-demand thumbnail rendering (high memory and cpu usage)
PHOTOPRISM_THUMB_SIZE: 2048 # Pre-rendered thumbnail size limit (default 2048, min 720, max 7680)
# PHOTOPRISM_THUMB_SIZE: 4096 # Retina 4K, DCI 4K (requires more storage); 7680 for 8K Ultra HD
PHOTOPRISM_THUMB_SIZE_UNCACHED: 7680 # On-demand rendering size limit (default 7680, min 720, max 7680)
PHOTOPRISM_JPEG_SIZE: 7680 # Size limit for converted image files in pixels (720-30000)
PHOTOPRISM_JPEG_QUALITY: 92 # Set to 95 for high-quality thumbnails (25-100)
TF_CPP_MIN_LOG_LEVEL: 0 # Show TensorFlow log messages for development
HOME: "/photoprism"
PHOTOPRISM_DISABLE_CHOWN: "false" # Disables storage permission updates on startup
PHOTOPRISM_DISABLE_BACKUPS: "false" # Don't backup photo and album metadata to YAML files
PHOTOPRISM_DISABLE_WEBDAV: "false" # Disables built-in WebDAV server
PHOTOPRISM_DISABLE_SETTINGS: "false" # Disables Settings in Web UI
PHOTOPRISM_DISABLE_PLACES: "false" # Disables reverse geocoding and maps
PHOTOPRISM_DISABLE_EXIFTOOL: "false" # Don't create ExifTool JSON files for improved metadata extraction
PHOTOPRISM_DISABLE_TENSORFLOW: "false" # Don't use TensorFlow for image classification
PHOTOPRISM_DETECT_NSFW: "false" # Flag photos as private that MAY be offensive (requires TensorFlow)
PHOTOPRISM_UPLOAD_NSFW: "false" # Allows uploads that may be offensive
PHOTOPRISM_DARKTABLE_PRESETS: "false" # Enables Darktable presets and disables concurrent RAW conversion
PHOTOPRISM_THUMB_FILTER: "lanczos" # Resample filter, best to worst: blackman, lanczos, cubic, linear
PHOTOPRISM_THUMB_UNCACHED: "true" # Enables on-demand thumbnail rendering (high memory and cpu usage)
PHOTOPRISM_THUMB_SIZE: 2048 # Pre-rendered thumbnail size limit (default 2048, min 720, max 7680)
# PHOTOPRISM_THUMB_SIZE: 4096 # Retina 4K, DCI 4K (requires more storage); 7680 for 8K Ultra HD
PHOTOPRISM_THUMB_SIZE_UNCACHED: 7680 # On-demand rendering size limit (default 7680, min 720, max 7680)
PHOTOPRISM_JPEG_SIZE: 7680 # Size limit for converted image files in pixels (720-30000)
PHOTOPRISM_JPEG_QUALITY: 92 # Set to 95 for high-quality thumbnails (25-100)
PHOTOPRISM_OIDC_ISSUER: "https://keycloak.timovolkmann.de/auth/realms/master"
PHOTOPRISM_OIDC_CLIENT_ID: "photoprism-dev"
PHOTOPRISM_OIDC_CLIENT_SECRET: "9d8351a0-ca01-4556-9c37-85eb634869b9"
# PHOTOPRISM_OIDC_ISSUER: "https://accounts.google.com"
# PHOTOPRISM_OIDC_CLIENT_ID: "86720117204-mb09c300nas5r9rid1ad0omv67nlvhck.apps.googleusercontent.com"
# PHOTOPRISM_OIDC_CLIENT_SECRET: "WQ2-LdfhYhHd-BdpfZCYGE12"
# PHOTOPRISM_OIDC_ISSUER: "https://accounts.google.com"
# PHOTOPRISM_OIDC_CLIENT_ID: "86720117204-mb09c300nas5r9rid1ad0omv67nlvhck.apps.googleusercontent.com"
# PHOTOPRISM_OIDC_CLIENT_SECRET: "WQ2-LdfhYhHd-BdpfZCYGE12"
TF_CPP_MIN_LOG_LEVEL: 0 # Show TensorFlow log messages for development
# Enable TensorFlow AVX2 support for modern Intel CPUs (requires starting the container as root):
# PHOTOPRISM_INIT: "tensorflow-amd64-avx2"
# Hardware video transcoding options:
# PHOTOPRISM_FFMPEG_BUFFERS: "64" # FFmpeg capture buffers (default: 32)
# PHOTOPRISM_FFMPEG_BITRATE: "32" # FFmpeg encoding bitrate limit in Mbit/s (default: 50)
# PHOTOPRISM_FFMPEG_ENCODER: "h264_v4l2m2m" # Use Video4Linux for AVC transcoding (default: libx264)
# PHOTOPRISM_FFMPEG_ENCODER: "h264_qsv" # Use Intel Quick Sync Video for AVC transcoding (default: libx264)
# PHOTOPRISM_INIT: "intel-graphics tensorflow-amd64-avx2" # Enable TensorFlow AVX2 & Intel Graphics support
# Optional hardware devices for video transcoding and machine learning:
# devices:
# - "/dev/video11:/dev/video11" # Video4Linux (h264_v4l2m2m)
# - "/dev/dri/renderD128:/dev/dri/renderD128" # Intel GPU
# - "/dev/dri/card0:/dev/dri/card0"
working_dir: "/go/src/github.com/photoprism/photoprism"
volumes:
- ".:/go/src/github.com/photoprism/photoprism"
- "go-mod:/go/pkg/mod"
mariadb:
image: mariadb:10.5
@ -92,8 +106,8 @@ services:
oidc-test-op:
build: docker/oidc/.
image: test-op:latest
ports:
- "9998:9998"
# ports:
# - "9998:9998"
volumes:
go-mod:

View file

@ -21,7 +21,7 @@ RUN echo 'Acquire::Retries "10";' > /etc/apt/apt.conf.d/80retry && \
COPY --chown=root:root --chmod=755 /docker/scripts/*.sh /root/.local/bin/
# Install dev / build dependencies
RUN apt-get update && apt dist-upgrade 2>/dev/null && apt-get -qq install -y --no-install-recommends \
RUN apt-get update && apt-get -qq dist-upgrade && apt-get -qq install --no-install-recommends \
build-essential \
ca-certificates \
wget \
@ -116,8 +116,11 @@ RUN rm -rf /tmp/* && mkdir -p /tmp/photoprism && \
wget "https://dl.photoprism.org/tensorflow/facenet.zip?${BUILD_TAG}" -O /tmp/photoprism/facenet.zip && \
wget "https://dl.photoprism.org/qa/testdata.zip?${BUILD_TAG}" -O /tmp/photoprism/testdata.zip
# Install additional tools
COPY --chown=root:root --chmod=755 /docker/scripts/heif-convert.sh /usr/local/bin/heif-convert
# Copy additional files to image
COPY --chown=root:root /docker/scripts/heif-convert.sh /usr/local/bin/heif-convert
COPY --chown=root:root /docker/scripts/Makefile /root/Makefile
COPY --chown=root:root /docker/development/entrypoint.sh /entrypoint.sh
RUN env GO111MODULE=off /usr/local/go/bin/go get -u github.com/tianon/gosu \
golang.org/x/tools/cmd/goimports github.com/kyoh86/richgo && \
[ "$TARGETARCH" = "arm" ] || \
@ -127,12 +130,13 @@ RUN env GO111MODULE=off /usr/local/go/bin/go get -u github.com/tianon/gosu \
cp /go/bin/gosu /bin/gosu
# Create photoprism user and directory for deployment
RUN useradd photoprism -m -d /photoprism && chmod a+rwx /photoprism && \
RUN useradd -m -U -u 1000 -d /photoprism photoprism && chmod a+rwx /photoprism && \
mkdir -m 777 -p /var/lib/photoprism /tmp/photoprism && \
echo "alias go=richgo" > /photoprism/.bash_aliases && \
echo "photoprism ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \
chown -Rf photoprism:photoprism /photoprism /var/lib/photoprism /tmp/photoprism && \
chmod -Rf a+rw /photoprism /var/lib/photoprism /tmp/photoprism /go && \
chmod 755 /usr/local/bin/heif-convert /entrypoint.sh && \
find /go -type d -print0 | xargs -0 chmod 777
# Copy mysql client config for development
@ -148,7 +152,6 @@ EXPOSE 2342 2343 9515
VOLUME /var/lib/photoprism
# Configure entrypoint
COPY --chown=root:root --chmod=755 /docker/development/entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
# Run server

View file

@ -1,39 +1,89 @@
#!/usr/bin/env bash
if [[ ${UMASK} ]]; then
echo "umask ${UMASK}"
umask "${UMASK}"
if [[ $(id -u) == "0" ]]; then
echo "development base image started as root"
if [ -e /root/.init ]; then
echo "initialized"
elif [[ ${PHOTOPRISM_INIT} ]]; then
for target in $PHOTOPRISM_INIT; do
echo "init ${target}..."
make -f /root/Makefile "${target}"
done
echo 1 >/root/.init
fi
else
echo "development base image started as uid $(id -u)"
fi
find /go -type d -print0 | xargs -0 chmod 777
chmod -Rf a+rw /var/lib/photoprism /tmp/photoprism /go
re='^[0-9]+$'
if [[ ${UID} ]] && [[ ${GID} ]] && [[ ${UID} != "0" ]] && [[ $(id -u) = "0" ]]; then
groupadd -f -g "${GID}" "${GID}"
usermod -o -u "${UID}" -g "${GID}" photoprism
# Legacy umask env variable in use?
if [[ -z ${PHOTOPRISM_UMASK} ]] && [[ ${UMASK} =~ $re ]]; then
PHOTOPRISM_UMASK=${UMASK}
echo "WARNING: UMASK without PHOTOPRISM_ prefix is deprecated, use PHOTOPRISM_UMASK: \"${PHOTOPRISM_UMASK}\" instead"
fi
if [[ -z ${PHOTOPRISM_DISABLE_CHOWN} ]] ; then
echo "updating storage permissions..."
chown -Rf photoprism /photoprism /var/lib/photoprism /tmp/photoprism /go
# Set file permission mask
if [[ ${PHOTOPRISM_UMASK} =~ $re ]]; then
echo "umask ${PHOTOPRISM_UMASK}"
umask "${PHOTOPRISM_UMASK}"
fi
# Script runs as root?
if [[ $(id -u) == "0" ]]; then
# Legacy user ID env variable in use?
if [[ -z ${PHOTOPRISM_UID} ]] && [[ ${UID} =~ $re ]] && [[ ${UID} != "0" ]]; then
PHOTOPRISM_UID=${UID}
echo "WARNING: UID without PHOTOPRISM_ prefix is deprecated, use PHOTOPRISM_UID: \"${PHOTOPRISM_UID}\" instead"
fi
echo "running as uid ${UID}:${GID}"
echo "${@}"
gosu "${UID}:${GID}" "$@" &
elif [[ ${UID} ]] && [[ ${UID} != "0" ]] && [[ $(id -u) = "0" ]]; then
usermod -o -u "${UID}" photoprism
if [[ -z ${PHOTOPRISM_DISABLE_CHOWN} ]] ; then
echo "updating storage permissions..."
chown -Rf photoprism /photoprism /var/lib/photoprism /tmp/photoprism /go
# Legacy group ID env variable in use?
if [[ -z ${PHOTOPRISM_GID} ]] && [[ ${GID} =~ $re ]] && [[ ${GID} != "0" ]]; then
PHOTOPRISM_GID=${GID}
echo "WARNING: GID without PHOTOPRISM_ prefix is deprecated, use PHOTOPRISM_GID: \"${PHOTOPRISM_GID}\" instead"
fi
echo "running as uid ${UID}"
echo "${@}"
# User and group ID set?
if [[ ${PHOTOPRISM_UID} =~ $re ]] && [[ ${PHOTOPRISM_UID} != "0" ]] && [[ ${PHOTOPRISM_GID} =~ $re ]] && [[ ${PHOTOPRISM_GID} != "0" ]]; then
groupadd -g "${PHOTOPRISM_GID}" "group_${PHOTOPRISM_GID}" 2>/dev/null
useradd -o -u "${PHOTOPRISM_UID}" -g "${PHOTOPRISM_GID}" -d /photoprism "user_${PHOTOPRISM_UID}" 2>/dev/null
usermod -g "${PHOTOPRISM_GID}" "user_${PHOTOPRISM_UID}" 2>/dev/null
gosu "${UID}" "$@" &
if [[ -z ${PHOTOPRISM_DISABLE_CHOWN} ]]; then
echo "set PHOTOPRISM_DISABLE_CHOWN: \"true\" to disable storage permission updates"
echo "updating storage permissions..."
chown -Rf "${PHOTOPRISM_UID}:${PHOTOPRISM_GID}" /photoprism /var/lib/photoprism /tmp/photoprism /go
fi
echo "running as uid ${PHOTOPRISM_UID}:${PHOTOPRISM_GID}"
echo "${@}"
gosu "${PHOTOPRISM_UID}:${PHOTOPRISM_GID}" "$@" &
elif [[ ${PHOTOPRISM_UID} =~ $re ]] && [[ ${PHOTOPRISM_UID} != "0" ]]; then
# User ID only
useradd -o -u "${PHOTOPRISM_UID}" -g 1000 -d /photoprism "user_${PHOTOPRISM_UID}" 2>/dev/null
usermod -g 1000 "user_${PHOTOPRISM_UID}" 2>/dev/null
if [[ -z ${PHOTOPRISM_DISABLE_CHOWN} ]]; then
echo "set PHOTOPRISM_DISABLE_CHOWN: \"true\" to disable storage permission updates"
echo "updating storage permissions..."
chown -Rf "${PHOTOPRISM_UID}" /photoprism /var/lib/photoprism /tmp/photoprism /go
fi
echo "running as uid ${PHOTOPRISM_UID}"
echo "${@}"
gosu "${PHOTOPRISM_UID}" "$@" &
else
# Run as root
echo "running as root"
echo "${@}"
"$@" &
fi
else
# Running as user
echo "running as uid $(id -u)"
echo "${@}"
@ -43,4 +93,4 @@ fi
PID=$!
trap "kill $PID" INT TERM
wait
wait

View file

@ -45,7 +45,7 @@ services:
- seccomp:unconfined
- apparmor:unconfined
# Run as a specific, non-root user (see https://docs.docker.com/engine/reference/run/#user):
# user: "1000"
# user: "1000:1000"
ports:
- "2342:2342" # [server]:[container]
environment:
@ -57,6 +57,7 @@ services:
PHOTOPRISM_PUBLIC: "false" # No authentication required (disables password protection)
PHOTOPRISM_READONLY: "false" # Don't modify originals directory (reduced functionality)
PHOTOPRISM_EXPERIMENTAL: "false" # Enables experimental features
PHOTOPRISM_DISABLE_CHOWN: "false" # Disables storage permission updates on startup
PHOTOPRISM_DISABLE_WEBDAV: "false" # Disables built-in WebDAV server
PHOTOPRISM_DISABLE_SETTINGS: "false" # Disables Settings in Web UI
PHOTOPRISM_DISABLE_TENSORFLOW: "false" # Disables all features depending on TensorFlow
@ -78,14 +79,14 @@ services:
PHOTOPRISM_SITE_CAPTION: "Browse Your Life"
PHOTOPRISM_SITE_DESCRIPTION: ""
PHOTOPRISM_SITE_AUTHOR: ""
HOME: "/photoprism"
# Set a non-root user, group, or custom umask if your Docker environment doesn't support this natively:
# UID: 1000
# GID: 1000
# UMASK: 0000
# For hardware AVC transcoding using the h264_v4l2m2m encoder:
# PHOTOPRISM_UID: 1000
# PHOTOPRISM_GID: 1000
# PHOTOPRISM_UMASK: 0000
HOME: "/photoprism"
# Optional hardware devices for video transcoding and machine learning:
# devices:
# - "/dev/video11:/dev/video11"
# - "/dev/video11:/dev/video11" # Video4Linux (h264_v4l2m2m)
working_dir: "/photoprism"
volumes:
# Your photo and video files ([local path]:[container path]):

View file

@ -44,11 +44,11 @@
"echo 'APT::Install-Suggests \"false\";' > /etc/apt/apt.conf.d/80suggests",
"echo 'APT::Get::Assume-Yes \"true\";' > /etc/apt/apt.conf.d/80forceyes",
"echo 'APT::Get::Fix-Missing \"true\";' > /etc/apt/apt.conf.d/80fixmissing",
"apt-get -qqy update",
"apt-get -qq update",
"apt dist-upgrade 2>/dev/null",
"apt-get -qqy install {{user `apt_packages`}}",
"apt-get -qqy autoclean",
"apt-get -qqy autoremove",
"apt-get -qq install {{user `apt_packages`}}",
"apt-get -qq autoclean",
"apt-get -qq autoremove",
"chmod 700 /var/lib/cloud/scripts/per-instance/install_photoprism.sh"
]
},

View file

@ -104,7 +104,7 @@ services:
- seccomp:unconfined
- apparmor:unconfined
# Run as a specific, non-root user (see https://docs.docker.com/engine/reference/run/#user):
user: "1000"
user: "1000:1000"
# Don't expose port when running behind Traefik reverse proxy!
# ports:
# - "2342:2342" # [server]:[container]
@ -136,6 +136,7 @@ services:
PHOTOPRISM_PUBLIC: "false" # No authentication required (disables password protection)
PHOTOPRISM_READONLY: "false" # Don't modify originals directory (reduced functionality)
PHOTOPRISM_EXPERIMENTAL: "false" # Enables experimental features
PHOTOPRISM_DISABLE_CHOWN: "false" # Disables storage permission updates on startup
PHOTOPRISM_DISABLE_WEBDAV: "false" # Disables built-in WebDAV server
PHOTOPRISM_DISABLE_SETTINGS: "false" # Disables Settings in Web UI
PHOTOPRISM_DISABLE_TENSORFLOW: "false" # Disables all features depending on TensorFlow
@ -149,6 +150,7 @@ services:
PHOTOPRISM_DATABASE_NAME: "photoprism" # MariaDB database schema name
PHOTOPRISM_DATABASE_USER: "photoprism" # MariaDB database user name
PHOTOPRISM_DATABASE_PASSWORD: "_admin_password_" # MariaDB database user password
PHOTOPRISM_INIT: "tensorflow-amd64-avx2"
HOME: "/photoprism"
working_dir: "/photoprism"
volumes:

View file

@ -57,7 +57,7 @@ if ! command -v docker-compose &> /dev/null; then
fi
# create user
useradd photoprism -u 1000 -G docker -o -m -d /opt/photoprism || echo "User 'photoprism' already exists. Proceeding."
useradd -o -m -U -u 1000 -G docker -d /opt/photoprism photoprism || echo "User 'photoprism' already exists. Proceeding."
mkdir -p /opt/photoprism/originals /opt/photoprism/import /opt/photoprism/storage /opt/photoprism/backup \
/opt/photoprism/database /opt/photoprism/traefik /opt/photoprism/certs

View file

@ -45,7 +45,7 @@ services:
- seccomp:unconfined
- apparmor:unconfined
# Run as a specific, non-root user (see https://docs.docker.com/engine/reference/run/#user):
# user: "1000"
# user: "1000:1000"
ports:
- "2342:2342" # [server]:[container]
environment:
@ -56,6 +56,7 @@ services:
PHOTOPRISM_PUBLIC: "false" # No authentication required (disables password protection)
PHOTOPRISM_READONLY: "false" # Don't modify originals directory (reduced functionality)
PHOTOPRISM_EXPERIMENTAL: "false" # Enables experimental features
PHOTOPRISM_DISABLE_CHOWN: "false" # Disables storage permission updates on startup
PHOTOPRISM_DISABLE_WEBDAV: "false" # Disables built-in WebDAV server
PHOTOPRISM_DISABLE_SETTINGS: "false" # Disables Settings in Web UI
PHOTOPRISM_DISABLE_TENSORFLOW: "false" # Disables all features depending on TensorFlow
@ -75,11 +76,24 @@ services:
PHOTOPRISM_SITE_CAPTION: "Browse Your Life"
PHOTOPRISM_SITE_DESCRIPTION: ""
PHOTOPRISM_SITE_AUTHOR: ""
HOME: "/photoprism"
# Set a non-root user, group, or custom umask if your Docker environment doesn't support this natively:
# UID: 1000
# GID: 1000
# UMASK: 0000
# PHOTOPRISM_UID: 1000
# PHOTOPRISM_GID: 1000
# PHOTOPRISM_UMASK: 0000
# Enable TensorFlow AVX2 support for modern Intel CPUs (requires starting the container as root):
# PHOTOPRISM_INIT: "tensorflow-amd64-avx2"
# Hardware video transcoding options:
# PHOTOPRISM_FFMPEG_BUFFERS: "64" # FFmpeg capture buffers (default: 32)
# PHOTOPRISM_FFMPEG_BITRATE: "32" # FFmpeg encoding bitrate limit in Mbit/s (default: 50)
# PHOTOPRISM_FFMPEG_ENCODER: "h264_v4l2m2m" # Use Video4Linux for AVC transcoding (default: libx264)
# PHOTOPRISM_FFMPEG_ENCODER: "h264_qsv" # Use Intel Quick Sync Video for AVC transcoding (default: libx264)
# PHOTOPRISM_INIT: "intel-graphics tensorflow-amd64-avx2" # Enable TensorFlow AVX2 & Intel Graphics support
HOME: "/photoprism"
# Optional hardware devices for video transcoding and machine learning:
# devices:
# - "/dev/video11:/dev/video11" # Video4Linux (h264_v4l2m2m)
# - "/dev/dri/renderD128:/dev/dri/renderD128" # Intel GPU
# - "/dev/dri/card0:/dev/dri/card0"
working_dir: "/photoprism"
volumes:
# Your photo and video files ([local path]:[container path]):

View file

@ -52,6 +52,7 @@ services:
PHOTOPRISM_PUBLIC: "false" # No authentication required (disables password protection)
PHOTOPRISM_READONLY: "false" # Don't modify originals directory (reduced functionality)
PHOTOPRISM_EXPERIMENTAL: "false" # Enables experimental features
PHOTOPRISM_DISABLE_CHOWN: "false" # Disables storage permission updates on startup
PHOTOPRISM_DISABLE_WEBDAV: "false" # Disables built-in WebDAV server
PHOTOPRISM_DISABLE_SETTINGS: "false" # Disables Settings in Web UI
PHOTOPRISM_DISABLE_TENSORFLOW: "false" # Disables all features depending on TensorFlow
@ -70,6 +71,8 @@ services:
PHOTOPRISM_SITE_CAPTION: "Browse Your Life"
PHOTOPRISM_SITE_DESCRIPTION: ""
PHOTOPRISM_SITE_AUTHOR: ""
# Enable TensorFlow AVX2 support for modern Intel CPUs (requires starting the container as root):
# PHOTOPRISM_INIT: "tensorflow-amd64-avx2"
HOME: "/photoprism"
volumes:
# Your photo and video files ([local path]:[container path]):

View file

@ -48,7 +48,7 @@ services:
- seccomp:unconfined
- apparmor:unconfined
# Run as a specific, non-root user (see https://docs.docker.com/engine/reference/run/#user):
# user: "1000"
# user: "1000:1000"
ports:
- "2342:2342" # [server]:[container]
environment:
@ -59,6 +59,7 @@ services:
PHOTOPRISM_PUBLIC: "false" # No authentication required (disables password protection)
PHOTOPRISM_READONLY: "false" # Don't modify originals directory (reduced functionality)
PHOTOPRISM_EXPERIMENTAL: "false" # Enables experimental features
PHOTOPRISM_DISABLE_CHOWN: "false" # Disables storage permission updates on startup
PHOTOPRISM_DISABLE_WEBDAV: "false" # Disables built-in WebDAV server
PHOTOPRISM_DISABLE_SETTINGS: "false" # Disables Settings in Web UI
PHOTOPRISM_DISABLE_TENSORFLOW: "false" # Disables all features depending on TensorFlow
@ -78,11 +79,13 @@ services:
PHOTOPRISM_SITE_CAPTION: "Browse Your Life"
PHOTOPRISM_SITE_DESCRIPTION: ""
PHOTOPRISM_SITE_AUTHOR: ""
HOME: "/photoprism"
# Set a non-root user, group, or custom umask if your Docker environment doesn't support this natively:
# UID: 1000
# GID: 1000
# UMASK: 0000
# PHOTOPRISM_UID: 1000
# PHOTOPRISM_GID: 1000
# PHOTOPRISM_UMASK: 0000
# Enable TensorFlow AVX2 support for modern Intel CPUs (requires starting the container as root):
# PHOTOPRISM_INIT: "tensorflow-amd64-avx2"
HOME: "/photoprism"
working_dir: "/photoprism"
volumes:
# Your photo and video files ([local path]:[container path]):

View file

@ -43,7 +43,7 @@ services:
- seccomp:unconfined
- apparmor:unconfined
# Run as a specific, non-root user (see https://docs.docker.com/engine/reference/run/#user):
# user: "1000"
# user: "1000:1000"
ports:
- "2342:2342" # [server]:[container]
environment:
@ -54,6 +54,7 @@ services:
PHOTOPRISM_PUBLIC: "false" # No authentication required (disables password protection)
PHOTOPRISM_READONLY: "false" # Don't modify originals directory (reduced functionality)
PHOTOPRISM_EXPERIMENTAL: "false" # Enables experimental features
PHOTOPRISM_DISABLE_CHOWN: "false" # Disables storage permission updates on startup
PHOTOPRISM_DISABLE_WEBDAV: "false" # Disables built-in WebDAV server
PHOTOPRISM_DISABLE_SETTINGS: "false" # Disables Settings in Web UI
PHOTOPRISM_DISABLE_TENSORFLOW: "false" # Disables all features depending on TensorFlow
@ -68,11 +69,13 @@ services:
PHOTOPRISM_SITE_CAPTION: "Browse Your Life"
PHOTOPRISM_SITE_DESCRIPTION: ""
PHOTOPRISM_SITE_AUTHOR: ""
HOME: "/photoprism"
# Set a non-root user, group, or custom umask if your Docker environment doesn't support this natively:
# UID: 1000
# GID: 1000
# UMASK: 0000
# PHOTOPRISM_UID: 1000
# PHOTOPRISM_GID: 1000
# PHOTOPRISM_UMASK: 0000
# Enable TensorFlow AVX2 support for modern Intel CPUs (requires starting the container as root):
# PHOTOPRISM_INIT: "tensorflow-amd64-avx2"
HOME: "/photoprism"
working_dir: "/photoprism"
volumes:
# Your photo and video files ([local path]:[container path]):

View file

@ -55,6 +55,7 @@ services:
PHOTOPRISM_PUBLIC: "false" # No authentication required, disables password protection
PHOTOPRISM_READONLY: "false" # Don't modify originals folder; disables import, upload, and delete
PHOTOPRISM_EXPERIMENTAL: "false" # Enables experimental features
PHOTOPRISM_DISABLE_CHOWN: "false" # Disables storage permission updates on startup
PHOTOPRISM_DISABLE_WEBDAV: "false" # Disables built-in WebDAV server
PHOTOPRISM_DISABLE_SETTINGS: "false" # Disables Settings in Web UI
PHOTOPRISM_DISABLE_TENSORFLOW: "false" # Disables all features depending on TensorFlow

View file

@ -1,4 +1,4 @@
FROM photoprism/development:20210922 as build
FROM photoprism/development:20210929 as build
ARG TARGETARCH
ARG TARGETPLATFORM
@ -31,10 +31,11 @@ RUN echo 'Acquire::Retries "10";' > /etc/apt/apt.conf.d/80retry && \
echo 'APT::Get::Fix-Missing "true";' > /etc/apt/apt.conf.d/80fixmissing
# Install additional distribution packages
RUN apt-get update && apt dist-upgrade 2>/dev/null && apt-get -qq install -y --no-install-recommends \
RUN apt-get update && apt-get -qq dist-upgrade && apt-get -qq install --no-install-recommends \
gpgv \
wget \
curl \
make \
davfs2 \
ca-certificates \
mariadb-client \
@ -83,6 +84,7 @@ ENV TF_CPP_MIN_LOG_LEVEL="2" \
PHOTOPRISM_DATABASE_NAME="photoprism" \
PHOTOPRISM_DATABASE_USER="photoprism" \
PHOTOPRISM_DATABASE_PASSWORD="" \
PHOTOPRISM_DISABLE_CHOWN="false" \
PHOTOPRISM_DISABLE_WEBDAV="false" \
PHOTOPRISM_DISABLE_SETTINGS="false" \
PHOTOPRISM_DISABLE_BACKUPS="false" \
@ -110,13 +112,15 @@ COPY --from=build /usr/lib/libtensorflow_framework.so /usr/lib/libtensorflow_fra
RUN ldconfig
# Set default umask and create photoprism user
RUN umask 0000 && useradd photoprism -m -d /photoprism && chmod a+rwx /photoprism
RUN umask 0000 && useradd -m -U -u 1000 -d /photoprism photoprism && chmod a+rwx /photoprism
WORKDIR /photoprism
# Copy files to /photoprism
# Copy additional files to image
COPY --from=build /root/.local/bin/photoprism /photoprism/bin/photoprism
COPY --from=build /root/.photoprism/assets /photoprism/assets
COPY --chown=root:root --chmod=755 /docker/scripts/heif-convert.sh /usr/local/bin/heif-convert
COPY --chown=root:root /docker/scripts/heif-convert.sh /usr/local/bin/heif-convert
COPY --chown=root:root /docker/scripts/Makefile /root/Makefile
COPY --chown=root:root /docker/photoprism/entrypoint.sh /entrypoint.sh
# Create directories
RUN mkdir -m 777 -p \
@ -127,7 +131,8 @@ RUN mkdir -m 777 -p \
/photoprism/storage/config \
/photoprism/storage/cache && \
chown -Rf photoprism:photoprism /photoprism /var/lib/photoprism /tmp/photoprism && \
chmod -Rf a+rwx /photoprism /var/lib/photoprism /tmp/photoprism
chmod -Rf a+rwx /photoprism /var/lib/photoprism /tmp/photoprism && \
chmod 755 /usr/local/bin/heif-convert /entrypoint.sh
# Show photoprism version
RUN photoprism -v
@ -136,7 +141,6 @@ RUN photoprism -v
EXPOSE 2342
# Configure entrypoint
COPY --chown=root:root --chmod=755 /docker/scripts/entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
VOLUME /var/lib/photoprism

94
docker/photoprism/entrypoint.sh Executable file
View file

@ -0,0 +1,94 @@
#!/usr/bin/env bash
if [[ $(id -u) == "0" ]]; then
echo "started as root"
if [ -e /root/.init ]; then
echo "initialized"
elif [[ ${PHOTOPRISM_INIT} ]]; then
for target in $PHOTOPRISM_INIT; do
echo "init ${target}..."
make -f /root/Makefile "${target}"
done
echo 1 >/root/.init
fi
fi
re='^[0-9]+$'
# Legacy umask env variable in use?
if [[ -z ${PHOTOPRISM_UMASK} ]] && [[ ${UMASK} =~ $re ]]; then
PHOTOPRISM_UMASK=${UMASK}
echo "WARNING: UMASK without PHOTOPRISM_ prefix is deprecated, use PHOTOPRISM_UMASK: \"${PHOTOPRISM_UMASK}\" instead"
fi
# Set file permission mask
if [[ ${PHOTOPRISM_UMASK} =~ $re ]]; then
echo "umask ${PHOTOPRISM_UMASK}"
umask "${PHOTOPRISM_UMASK}"
fi
# Script runs as root?
if [[ $(id -u) == "0" ]]; then
# Legacy user ID env variable in use?
if [[ -z ${PHOTOPRISM_UID} ]] && [[ ${UID} =~ $re ]] && [[ ${UID} != "0" ]]; then
PHOTOPRISM_UID=${UID}
echo "WARNING: UID without PHOTOPRISM_ prefix is deprecated, use PHOTOPRISM_UID: \"${PHOTOPRISM_UID}\" instead"
fi
# Legacy group ID env variable in use?
if [[ -z ${PHOTOPRISM_GID} ]] && [[ ${GID} =~ $re ]] && [[ ${GID} != "0" ]]; then
PHOTOPRISM_GID=${GID}
echo "WARNING: GID without PHOTOPRISM_ prefix is deprecated, use PHOTOPRISM_GID: \"${PHOTOPRISM_GID}\" instead"
fi
# User and group ID set?
if [[ ${PHOTOPRISM_UID} =~ $re ]] && [[ ${PHOTOPRISM_UID} != "0" ]] && [[ ${PHOTOPRISM_GID} =~ $re ]] && [[ ${PHOTOPRISM_GID} != "0" ]]; then
groupadd -g "${PHOTOPRISM_GID}" "group_${PHOTOPRISM_GID}" 2>/dev/null
useradd -o -u "${PHOTOPRISM_UID}" -g "${PHOTOPRISM_GID}" -d /photoprism "user_${PHOTOPRISM_UID}" 2>/dev/null
usermod -g "${PHOTOPRISM_GID}" "user_${PHOTOPRISM_UID}" 2>/dev/null
if [[ -z ${PHOTOPRISM_DISABLE_CHOWN} ]]; then
echo "set PHOTOPRISM_DISABLE_CHOWN: \"true\" to disable storage permission updates"
echo "updating storage permissions..."
chown -Rf "${PHOTOPRISM_UID}:${PHOTOPRISM_GID}" /photoprism/storage /photoprism/import /photoprism/assets /var/lib/photoprism /tmp/photoprism
fi
echo "running as uid ${PHOTOPRISM_UID}:${PHOTOPRISM_GID}"
echo "${@}"
gosu "${PHOTOPRISM_UID}:${PHOTOPRISM_GID}" "$@" &
elif [[ ${PHOTOPRISM_UID} =~ $re ]] && [[ ${PHOTOPRISM_UID} != "0" ]]; then
# User ID only
useradd -o -u "${PHOTOPRISM_UID}" -g 1000 -d /photoprism "user_${PHOTOPRISM_UID}" 2>/dev/null
usermod -g 1000 "user_${PHOTOPRISM_UID}" 2>/dev/null
if [[ -z ${PHOTOPRISM_DISABLE_CHOWN} ]]; then
echo "set PHOTOPRISM_DISABLE_CHOWN: \"true\" to disable storage permission updates"
echo "updating storage permissions..."
chown -Rf "${PHOTOPRISM_UID}" /photoprism/storage /photoprism/import /photoprism/assets /var/lib/photoprism /tmp/photoprism
fi
echo "running as uid ${PHOTOPRISM_UID}"
echo "${@}"
gosu "${PHOTOPRISM_UID}" "$@" &
else
# No user or group ID set via end variable
echo "running as root"
echo "${@}"
"$@" &
fi
else
# Running as root
echo "running as uid $(id -u)"
echo "${@}"
"$@" &
fi
PID=$!
trap "kill $PID" INT TERM
wait

29
docker/scripts/Makefile Normal file
View file

@ -0,0 +1,29 @@
# Optional packages and drivers for PhotoPrism
# Maintainer: Michael Mayer <hello@photoprism.org>
.PHONY: tensorflow-amd64-cpu tensorflow-amd64-cpu-install \
tensorflow-amd64-avx tensorflow-amd64-avx-install \
tensorflow-amd64-avx2 tensorflow-amd64-avx2-install \
intel-graphics;
tensorflow-amd64-cpu: /tmp/libtensorflow-linux-cpu-1.15.2.tar.gz tensorflow-amd64-cpu-install
/tmp/libtensorflow-linux-cpu-1.15.2.tar.gz:
curl -fsSL "https://dl.photoprism.org/tensorflow/linux/libtensorflow-linux-cpu-1.15.2.tar.gz" > /tmp/libtensorflow-linux-cpu-1.15.2.tar.gz
tensorflow-amd64-cpu-install:
tar --overwrite -C "/usr" -xzf /tmp/libtensorflow-linux-cpu-1.15.2.tar.gz
ldconfig
tensorflow-amd64-avx: /tmp/libtensorflow-linux-avx-1.15.2.tar.gz tensorflow-amd64-avx-install
/tmp/libtensorflow-linux-avx-1.15.2.tar.gz:
curl -fsSL "https://dl.photoprism.org/tensorflow/linux/libtensorflow-linux-avx-1.15.2.tar.gz" > /tmp/libtensorflow-linux-avx-1.15.2.tar.gz
tensorflow-amd64-avx-install:
tar --overwrite -C "/usr" -xzf /tmp/libtensorflow-linux-avx-1.15.2.tar.gz
ldconfig
tensorflow-amd64-avx2: /tmp/libtensorflow-linux-avx2-1.15.2.tar.gz tensorflow-amd64-avx2-install
/tmp/libtensorflow-linux-avx2-1.15.2.tar.gz:
curl -fsSL "https://dl.photoprism.org/tensorflow/linux/libtensorflow-linux-avx2-1.15.2.tar.gz" > /tmp/libtensorflow-linux-avx2-1.15.2.tar.gz
tensorflow-amd64-avx2-install:
tar --overwrite -C "/usr" -xzf /tmp/libtensorflow-linux-avx2-1.15.2.tar.gz
ldconfig
intel-graphics:
apt-get update
apt-get dist-upgrade
apt-get install intel-opencl-icd intel-media-va-driver-non-free i965-va-driver-shaders libmfx1
apt-get -y autoremove && apt-get -y autoclean && apt-get clean && rm -rf /var/lib/apt/lists/*

View file

@ -1,43 +0,0 @@
#!/usr/bin/env bash
if [[ ${UMASK} ]]; then
echo "umask ${UMASK}"
umask "${UMASK}"
fi
if [[ ${UID} ]] && [[ ${GID} ]] && [[ ${UID} != "0" ]] && [[ $(id -u) = "0" ]]; then
groupadd -f -g "${GID}" "${GID}"
usermod -o -u "${UID}" -g "${GID}" photoprism
if [[ -z ${PHOTOPRISM_DISABLE_CHOWN} ]] ; then
echo "updating storage permissions..."
chown -Rf photoprism /photoprism/storage /photoprism/import /photoprism/assets /var/lib/photoprism /tmp/photoprism
fi
echo "running as uid ${UID}:${GID}"
echo "${@}"
gosu "${UID}:${GID}" "$@" &
elif [[ ${UID} ]] && [[ ${UID} != "0" ]] && [[ $(id -u) = "0" ]]; then
usermod -o -u "${UID}" photoprism
if [[ -z ${PHOTOPRISM_DISABLE_CHOWN} ]] ; then
echo "updating storage permissions..."
chown -Rf photoprism /photoprism/storage /photoprism/import /photoprism/assets /var/lib/photoprism /tmp/photoprism
fi
echo "running as uid ${UID}"
echo "${@}"
gosu "${UID}" "$@" &
else
echo "running as uid $(id -u)"
echo "${@}"
"$@" &
fi
PID=$!
trap "kill $PID" INT TERM
wait

View file

@ -12,14 +12,14 @@ else
elif [[ $1 == "arm64" ]]; then
URL="https://dl.photoprism.org/tensorflow/$1/libtensorflow-$1-1.15.2.tar.gz"
elif [[ $1 == "arm" ]]; then
URL="https://github.com/Qengineering/TensorFlow-Raspberry-Pi/blob/master/libtensorflow_1_15_2.tar.gz?raw=true"
URL="https://dl.photoprism.org/tensorflow/$1/libtensorflow-$1-1.15.2.tar.gz"
else
echo "cpu architecture not supported by now" 1>&2
exit 1
fi
echo "$URL"
curl -L "$URL" | \
tar -C "/usr" -xz && \
curl -fsSL "$URL" | \
tar --overwrite -C "/usr" -xz && \
ldconfig
echo "Done"
fi

File diff suppressed because it is too large Load diff

View file

@ -188,6 +188,24 @@ export default class Config {
}
}
getPerson(name) {
name = name.toLowerCase();
const result = this.values.people.filter((m) => m.Name.toLowerCase() === name);
const l = result ? result.length : 0;
if (l === 0) {
return null;
} else if (l === 1) {
return result[0];
} else {
if (this.debug) {
console.warn("more than one person matching the same name", result);
}
return result[0];
}
}
onCount(ev, data) {
const type = ev.split(".")[1];

View file

@ -21,7 +21,7 @@
<v-layout row wrap class="search-results photo-results cards-view" :class="{'select-results': selectMode}">
<v-flex
v-for="(photo, index) in photos"
:key="index"
:key="photo.ID"
xs12 sm6 md4 lg3 xlg2 xxxl1 d-flex
>
<v-card tile

View file

@ -21,7 +21,7 @@
<v-layout row wrap class="search-results photo-results mosaic-view" :class="{'select-results': selectMode}">
<v-flex
v-for="(photo, index) in photos"
:key="index"
:key="photo.ID"
xs4 sm3 md2 lg1 d-flex
>
<v-card tile

View file

@ -202,6 +202,8 @@ main {
#photoprism .v-tabs .v-badge__badge {
right: -22px;
font-size: 9px;
width: 1.1rem;
width: auto;
padding: 0 0.2rem;
min-width: 1.1rem;
height: 1.1rem;
}

View file

@ -39,6 +39,7 @@ import PFileDeleteDialog from "./file/delete.vue";
import PAlbumEditDialog from "./album/edit.vue";
import PAlbumDeleteDialog from "./album/delete.vue";
import PLabelDeleteDialog from "./label/delete.vue";
import PPeopleMergeDialog from "./people/merge.vue";
import PUploadDialog from "./upload.vue";
import PVideoViewer from "./video/viewer.vue";
import PShareDialog from "./share.vue";
@ -61,6 +62,7 @@ dialogs.install = (Vue) => {
Vue.component("PAlbumEditDialog", PAlbumEditDialog);
Vue.component("PAlbumDeleteDialog", PAlbumDeleteDialog);
Vue.component("PLabelDeleteDialog", PLabelDeleteDialog);
Vue.component("PPeopleMergeDialog", PPeopleMergeDialog);
Vue.component("PUploadDialog", PUploadDialog);
Vue.component("PVideoViewer", PVideoViewer);
Vue.component("PShareDialog", PShareDialog);

View file

@ -0,0 +1,65 @@
<template>
<v-dialog v-model="show" lazy persistent max-width="350" class="p-people-merge-dialog" @keydown.esc="cancel">
<v-card raised elevation="24">
<v-container fluid class="pb-2 pr-2 pl-2">
<v-layout row wrap>
<v-flex xs3 text-xs-center>
<v-icon size="54" color="secondary-dark lighten-1">people</v-icon>
</v-flex>
<v-flex xs9 text-xs-left align-self-center>
<div class="subheading pr-1">
{{ prompt }}
</div>
</v-flex>
<v-flex xs12 text-xs-right class="pt-3">
<v-btn depressed color="secondary-light" class="action-cancel" @click.stop="cancel">
<translate key="No">No</translate>
</v-btn>
<v-btn color="primary-button" depressed dark class="action-confirm"
@click.stop="confirm">
<translate key="Yes">Yes</translate>
</v-btn>
</v-flex>
</v-layout>
</v-container>
</v-card>
</v-dialog>
</template>
<script>
import Subject from "model/subject";
export default {
name: 'PPeopleMergeDialog',
props: {
show: Boolean,
subj1: {
type: Object,
default: new Subject(),
},
subj2: {
type: Object,
default: new Subject(),
},
},
data() {
return {};
},
computed: {
prompt() {
if (!this.subj1 || !this.subj2) {
return "";
}
return this.$gettextInterpolate(this.$gettext("Merge %{a} with %{b}?"), {a: this.subj1.originalValue("Name"), b: this.subj2.Name});
},
},
methods: {
cancel() {
this.$emit('cancel');
},
confirm() {
this.$emit('confirm');
},
}
};
</script>

View file

@ -1,8 +1,8 @@
<template>
<div class="p-tab p-tab-photo-files">
<v-expansion-panel expand class="pa-0 elevation-0 secondary" :value="state">
<template v-for="(file, index) in model.fileModels()">
<v-expansion-panel-content v-if="!file.Missing" :key="index" class="pa-0 elevation-0 secondary-light"
<template v-for="file in model.fileModels()">
<v-expansion-panel-content v-if="!file.Missing" :key="file.UID" class="pa-0 elevation-0 secondary-light"
style="margin-top: 1px;">
<template #header>
<div class="caption">{{ file.baseName(70) }}</div>
@ -41,13 +41,13 @@
@click.stop.prevent="downloadFile(file)">
<translate>Download</translate>
</v-btn>
<v-btn v-if="features.edit && file.Type === 'jpg' && !file.Primary" small depressed dark
<v-btn v-if="features.edit && file.Type === 'jpg' && file.Error === '' && !file.Primary" small depressed dark
color="primary-button"
class="ma-0 action-primary"
@click.stop.prevent="primaryFile(file)">
<translate>Primary</translate>
</v-btn>
<v-btn v-if="features.edit && !file.Sidecar && !file.Primary && file.Root === '/'" small
<v-btn v-if="features.edit && !file.Sidecar && file.Error === '' && !file.Primary && file.Root === '/'" small
depressed dark color="primary-button"
class="ma-0 action-unstack"
@click.stop.prevent="unstackFile(file)">
@ -66,6 +66,12 @@
</td>
<td>{{ file.UID | uppercase }}</td>
</tr>
<tr v-if="file.Error">
<td>
<translate>Error</translate>
</td>
<td><span class="body-2">{{ file.Error | uppercase }}</span></td>
</tr>
<tr v-if="file.InstanceID" title="XMP">
<td>
<translate>Instance ID</translate>
@ -162,12 +168,6 @@
</td>
<td>{{ file.Chroma }} / 100</td>
</tr>
<tr v-if="file.Error">
<td>
<translate>Error</translate>
</td>
<td>{{ file.Error }}</td>
</tr>
<tr v-if="file.Missing">
<td>
<translate>Missing</translate>

View file

@ -53,6 +53,7 @@
v-model="marker.Name"
:rules="[textRule]"
:disabled="busy"
:readonly="true"
browser-autocomplete="off"
class="input-name pa-0 ma-0"
hide-details
@ -132,7 +133,11 @@ export default {
},
onReject(marker) {
this.busy = true;
marker.reject().finally(() => this.busy = false);
this.$notify.blockUI();
marker.reject().finally(() => {
this.$notify.unblockUI();
this.busy = false;
});
},
onApprove(marker) {
this.busy = true;
@ -140,11 +145,19 @@ export default {
},
onClearSubject(marker) {
this.busy = true;
marker.clearSubject(marker).finally(() => this.busy = false);
this.$notify.blockUI();
marker.clearSubject(marker).finally(() => {
this.$notify.unblockUI();
this.busy = false;
});
},
onRename(marker) {
this.busy = true;
marker.rename().finally(() => this.busy = false);
this.$notify.blockUI();
marker.rename().finally(() => {
this.$notify.unblockUI();
this.busy = false;
});
},
},
};

View file

@ -18,7 +18,7 @@
</v-card-title>
<v-card-text>
<v-expansion-panel class="pa-0 elevation-0">
<v-expansion-panel-content v-for="(link, index) in links" :key="index"
<v-expansion-panel-content v-for="(link, index) in links" :key="link.UID"
class="pa-0 elevation-0 secondary mb-1">
<template #header>
<button :class="`text-xs-${!rtl ? 'left' : 'right'} action-url ml-0 mt-0 mb-0 pa-0 mr-2`" style="user-select: none;"

Binary file not shown.

View file

@ -37,7 +37,7 @@ msgstr "%{n} Ordner gefunden"
msgid "%{n} labels found"
msgstr "%{n} Kategorien gefunden"
#: src/pages/people/faces.vue:357 src/pages/people/subjects.vue:348
#: src/pages/people/faces.vue:364 src/pages/people/subjects.vue:389
msgid "%{n} people found"
msgstr "%{n} Personen gefunden"
@ -184,7 +184,7 @@ msgstr "Alle %{n} Einträge werden angezeigt"
msgid "All %{n} labels loaded"
msgstr "Alle %{n} Kategorien werden angezeigt"
#: src/pages/people/faces.vue:264 src/pages/people/subjects.vue:255
#: src/pages/people/faces.vue:271 src/pages/people/subjects.vue:296
msgid "All %{n} people loaded"
msgstr "Alle %{n} Personen geladen"
@ -330,7 +330,7 @@ msgstr ""
msgid "Automatically creates albums of special moments, trips, and places."
msgstr "Erstellt automatisch Alben mit besonderen Momenten, Reisen und Orten."
#: src/pages/people/subjects.vue:351
#: src/pages/people/subjects.vue:352
msgid "Bio"
msgstr "Biographie"
@ -393,9 +393,9 @@ msgstr "Limit erreicht, bitte Suche eingrenzen"
#: src/common/clipboard.js:105 src/common/clipboard.js:142
#: src/pages/albums.vue:387 src/pages/albums.vue:403 src/pages/labels.vue:182
#: src/pages/labels.vue:198 src/pages/library/files.vue:193
#: src/pages/library/files.vue:209 src/pages/people/faces.vue:200
#: src/pages/people/faces.vue:216 src/pages/people/subjects.vue:191
#: src/pages/people/subjects.vue:207 src/share/albums.vue:345
#: src/pages/library/files.vue:209 src/pages/people/faces.vue:207
#: src/pages/people/faces.vue:223 src/pages/people/subjects.vue:232
#: src/pages/people/subjects.vue:248 src/share/albums.vue:345
#: src/share/albums.vue:361
msgid "Can't select more items"
msgstr "Maximale Anzahl wurde selektiert"
@ -1302,6 +1302,10 @@ msgstr "Bildbereich"
msgid "Medium"
msgstr "Mittel"
#: src/dialog/people/merge.vue:25
msgid "Merge %{a} with %{b}?"
msgstr "%{a} und %{b} zusammenfassen?"
#: src/pages/about/feedback.vue:27
msgid "Message sent"
msgstr "Nachricht versendet"
@ -1335,7 +1339,7 @@ msgstr "Moonlight"
msgid "More than 20 albums found"
msgstr "Mehr als 20 Alben gefunden"
#: src/pages/people/faces.vue:360
#: src/pages/people/faces.vue:367
msgid "More than 20 faces found"
msgstr "Mehr als 20 Gesichter gefunden"
@ -1343,7 +1347,7 @@ msgstr "Mehr als 20 Gesichter gefunden"
msgid "More than 20 labels found"
msgstr "Mehr als 20 Labels gefunden"
#: src/pages/people/subjects.vue:351
#: src/pages/people/subjects.vue:392
msgid "More than 20 people found"
msgstr "Mehr als 20 Personen gefunden"
@ -1373,19 +1377,19 @@ msgstr "Dateien verschieben"
#: src/dialog/album/edit.vue:106 src/dialog/photo/files.vue:70
#: src/dialog/photo/files.vue:67 src/dialog/photo/files.vue:30
#: src/dialog/photo/info.vue:31 src/dialog/photo/labels.vue:48
#: src/dialog/photo/people.vue:18 src/dialog/photo/people.vue:228
#: src/dialog/photo/people.vue:19 src/dialog/photo/people.vue:229
#: src/pages/about/feedback.vue:144 src/pages/labels.vue:337
#: src/pages/login.vue:73 src/pages/people/faces.vue:46
#: src/pages/people/faces.vue:312 src/pages/people/subjects.vue:314
#: src/pages/login.vue:73 src/pages/people/faces.vue:47
#: src/pages/people/faces.vue:310 src/pages/people/subjects.vue:315
#: src/share/photo/cards.vue:30 src/share/photo/list.vue:34
#: src/share/photo/list.vue:216
msgid "Name"
msgstr "Name"
#: src/component/album/toolbar.vue:52 src/dialog/album/edit.vue:24
#: src/dialog/photo/labels.vue:22 src/dialog/photo/people.vue:21
#: src/dialog/photo/labels.vue:22 src/dialog/photo/people.vue:22
#: src/pages/labels.vue:38 src/pages/library/files.vue:40
#: src/pages/people/faces.vue:40 src/pages/people/subjects.vue:39
#: src/pages/people/faces.vue:41 src/pages/people/subjects.vue:40
msgid "Name too long"
msgstr "Name zu lang"
@ -1407,9 +1411,10 @@ msgstr "Neues Passwort"
msgid "Newest first"
msgstr "Neueste zuerst"
#: src/dialog/photo/archive.vue:15 src/dialog/photo/info.vue:284
#: src/dialog/photo/info.vue:305 src/dialog/photo/info.vue:325
#: src/dialog/photo/info.vue:345 src/dialog/photo/info.vue:365
#: src/dialog/people/merge.vue:15 src/dialog/photo/archive.vue:15
#: src/dialog/photo/info.vue:284 src/dialog/photo/info.vue:305
#: src/dialog/photo/info.vue:325 src/dialog/photo/info.vue:345
#: src/dialog/photo/info.vue:365
msgid "No"
msgstr "Nein"
@ -1428,8 +1433,8 @@ msgid "No labels found"
msgstr "Keine Kategorien gefunden"
#: src/dialog/photo/people.vue:5 src/pages/people/faces.vue:23
#: src/pages/people/faces.vue:353 src/pages/people/subjects.vue:26
#: src/pages/people/subjects.vue:344
#: src/pages/people/faces.vue:360 src/pages/people/subjects.vue:26
#: src/pages/people/subjects.vue:385
msgid "No people found"
msgstr "Keine Personen gefunden"
@ -1550,7 +1555,7 @@ msgstr "Ein Ordner gefunden"
msgid "One label found"
msgstr "Eine Kategorie gefunden"
#: src/pages/people/faces.vue:355 src/pages/people/subjects.vue:346
#: src/pages/people/faces.vue:362 src/pages/people/subjects.vue:387
msgid "One person found"
msgstr "Eine Person gefunden"
@ -1826,7 +1831,7 @@ msgstr "Rot"
#: src/component/album/toolbar.vue:54 src/component/photo/toolbar.vue:57
#: src/dialog/reload.vue:15 src/pages/albums.vue:134 src/pages/labels.vue:91
#: src/pages/library/errors.vue:69 src/pages/library/files.vue:100
#: src/pages/people/faces.vue:67 src/pages/people/subjects.vue:97
#: src/pages/people/faces.vue:67 src/pages/people/subjects.vue:98
msgid "Reload"
msgstr "Neu laden"
@ -1909,7 +1914,7 @@ msgstr "Scans"
#: src/component/navigation.vue:4 src/component/navigation.vue:280
#: src/component/photo/toolbar.vue:48 src/pages/albums.vue:109
#: src/pages/labels.vue:81 src/pages/library/errors.vue:60
#: src/pages/people/subjects.vue:80 src/pages/places.vue:30 src/routes.js:107
#: src/pages/people/subjects.vue:81 src/pages/places.vue:30 src/routes.js:107
msgid "Search"
msgstr "Suche"
@ -2166,7 +2171,7 @@ msgstr "Aufgenommen"
msgid "Teal"
msgstr "Blaugrün"
#: src/dialog/photo/details.vue:26 src/pages/people/faces.vue:49
#: src/dialog/photo/details.vue:26 src/pages/people/faces.vue:50
msgid "Text too long"
msgstr "Text ist zu lang"
@ -2446,12 +2451,13 @@ msgstr "Gelb"
msgid "Yellowstone"
msgstr "Yellowstone"
#: src/dialog/photo/archive.vue:18 src/dialog/photo/files.vue:103
#: src/dialog/photo/files.vue:111 src/dialog/photo/files.vue:157
#: src/dialog/photo/files.vue:100 src/dialog/photo/files.vue:108
#: src/dialog/photo/files.vue:154 src/dialog/photo/info.vue:284
#: src/dialog/photo/info.vue:305 src/dialog/photo/info.vue:325
#: src/dialog/photo/info.vue:345 src/dialog/photo/info.vue:365
#: src/dialog/people/merge.vue:18 src/dialog/photo/archive.vue:18
#: src/dialog/photo/files.vue:103 src/dialog/photo/files.vue:111
#: src/dialog/photo/files.vue:157 src/dialog/photo/files.vue:100
#: src/dialog/photo/files.vue:108 src/dialog/photo/files.vue:154
#: src/dialog/photo/info.vue:284 src/dialog/photo/info.vue:305
#: src/dialog/photo/info.vue:325 src/dialog/photo/info.vue:345
#: src/dialog/photo/info.vue:365
msgid "Yes"
msgstr "Ja"

Binary file not shown.

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -9,8 +9,8 @@ msgstr ""
msgid ""
msgstr ""
#: src/dialog/photo/files.vue:131
#: src/dialog/photo/files.vue:128
#: src/dialog/photo/files.vue:137
#: src/dialog/photo/files.vue:134
msgid "{{ file.Orientation }}"
msgstr ""
@ -36,8 +36,9 @@ msgstr ""
msgid "%{n} labels found"
msgstr ""
#: src/pages/people/faces.vue:357
#: src/pages/people/subjects.vue:348
#: src/pages/people/faces.vue:267
#: src/pages/people/faces.vue:353
#: src/pages/people/subjects.vue:392
msgid "%{n} people found"
msgstr ""
@ -161,7 +162,7 @@ msgstr ""
msgid "After two weeks"
msgstr ""
#: src/model/album.js:189
#: src/model/album.js:179
msgid "Album"
msgstr ""
@ -198,8 +199,7 @@ msgstr ""
msgid "All %{n} labels loaded"
msgstr ""
#: src/pages/people/faces.vue:264
#: src/pages/people/subjects.vue:255
#: src/pages/people/subjects.vue:295
msgid "All %{n} people loaded"
msgstr ""
@ -329,8 +329,8 @@ msgstr ""
msgid "Artist"
msgstr ""
#: src/dialog/photo/files.vue:122
#: src/dialog/photo/files.vue:119
#: src/dialog/photo/files.vue:128
#: src/dialog/photo/files.vue:125
msgid "Aspect Ratio"
msgstr ""
@ -346,7 +346,7 @@ msgstr ""
msgid "Automatically creates albums of special moments, trips, and places."
msgstr ""
#: src/pages/people/subjects.vue:351
#: src/pages/people/subjects.vue:352
msgid "Bio"
msgstr ""
@ -420,10 +420,10 @@ msgstr ""
#: src/pages/labels.vue:198
#: src/pages/library/files.vue:193
#: src/pages/library/files.vue:209
#: src/pages/people/faces.vue:200
#: src/pages/people/faces.vue:216
#: src/pages/people/subjects.vue:191
#: src/pages/people/subjects.vue:207
#: src/pages/people/faces.vue:196
#: src/pages/people/faces.vue:212
#: src/pages/people/subjects.vue:229
#: src/pages/people/subjects.vue:245
#: src/share/albums.vue:345
#: src/share/albums.vue:361
msgid "Can't select more items"
@ -482,8 +482,8 @@ msgstr ""
msgid "Chinese Traditional"
msgstr ""
#: src/dialog/photo/files.vue:142
#: src/dialog/photo/files.vue:139
#: src/dialog/photo/files.vue:148
#: src/dialog/photo/files.vue:145
msgid "Chroma"
msgstr ""
@ -494,8 +494,8 @@ msgstr ""
msgid "Close"
msgstr ""
#: src/dialog/photo/files.vue:94
#: src/dialog/photo/files.vue:91
#: src/dialog/photo/files.vue:100
#: src/dialog/photo/files.vue:97
msgid "Codec"
msgstr ""
@ -733,10 +733,10 @@ msgstr ""
msgid "Disables reverse geocoding and maps."
msgstr ""
#: src/routes.js:361
#: src/routes.js:368
#: src/routes.js:375
#: src/routes.js:382
#: src/routes.js:389
#: src/routes.js:396
msgid "Discover"
msgstr ""
@ -894,8 +894,8 @@ msgstr ""
msgid "English"
msgstr ""
#: src/dialog/photo/files.vue:148
#: src/dialog/photo/files.vue:145
#: src/dialog/photo/files.vue:52
#: src/dialog/photo/files.vue:49
msgid "Error"
msgstr ""
@ -944,7 +944,7 @@ msgstr ""
msgid "F Number"
msgstr ""
#: src/model/face.js:115
#: src/model/face.js:153
msgid "Face"
msgstr ""
@ -992,7 +992,7 @@ msgstr ""
msgid "Feedback"
msgstr ""
#: src/model/file.js:245
#: src/model/file.js:244
msgid "File"
msgstr ""
@ -1076,8 +1076,8 @@ msgstr ""
msgid "Group by similarity"
msgstr ""
#: src/dialog/photo/files.vue:58
#: src/dialog/photo/files.vue:55
#: src/dialog/photo/files.vue:64
#: src/dialog/photo/files.vue:61
msgid "Hash"
msgstr ""
@ -1097,7 +1097,7 @@ msgstr ""
msgid "Hidden Files"
msgstr ""
#: src/pages/people/faces.vue:192
#: src/pages/people/faces.vue:186
msgid "Hide"
msgstr ""
@ -1184,8 +1184,8 @@ msgstr ""
msgid "Indexing media and sidecar files…"
msgstr ""
#: src/dialog/photo/files.vue:52
#: src/dialog/photo/files.vue:49
#: src/dialog/photo/files.vue:58
#: src/dialog/photo/files.vue:55
msgid "Instance ID"
msgstr ""
@ -1287,9 +1287,9 @@ msgstr ""
#: src/component/navigation.vue:1131
#: src/pages/settings.vue:41
#: src/pages/settings/general.vue:404
#: src/routes.js:279
#: src/routes.js:286
#: src/routes.js:293
#: src/routes.js:300
#: src/routes.js:307
msgid "Library"
msgstr ""
@ -1381,8 +1381,8 @@ msgstr ""
msgid "Magenta"
msgstr ""
#: src/dialog/photo/files.vue:136
#: src/dialog/photo/files.vue:133
#: src/dialog/photo/files.vue:142
#: src/dialog/photo/files.vue:139
msgid "Main Color"
msgstr ""
@ -1402,6 +1402,10 @@ msgstr ""
msgid "Medium"
msgstr ""
#: src/dialog/people/merge.vue:25
msgid "Merge %{a} with %{b}?"
msgstr ""
#: src/pages/about/feedback.vue:27
msgid "Message sent"
msgstr ""
@ -1441,15 +1445,11 @@ msgstr ""
msgid "More than 20 albums found"
msgstr ""
#: src/pages/people/faces.vue:360
msgid "More than 20 faces found"
msgstr ""
#: src/pages/labels.vue:342
msgid "More than 20 labels found"
msgstr ""
#: src/pages/people/subjects.vue:351
#: src/pages/people/subjects.vue:395
msgid "More than 20 people found"
msgstr ""
@ -1482,19 +1482,19 @@ msgstr ""
#: src/component/photo/list.vue:237
#: src/dialog/account/edit.vue:397
#: src/dialog/album/edit.vue:106
#: src/dialog/photo/files.vue:70
#: src/dialog/photo/files.vue:67
#: src/dialog/photo/files.vue:76
#: src/dialog/photo/files.vue:73
#: src/dialog/photo/files.vue:30
#: src/dialog/photo/info.vue:31
#: src/dialog/photo/labels.vue:48
#: src/dialog/photo/people.vue:18
#: src/dialog/photo/people.vue:228
#: src/dialog/photo/people.vue:229
#: src/pages/about/feedback.vue:144
#: src/pages/labels.vue:337
#: src/pages/login.vue:73
#: src/pages/people/faces.vue:46
#: src/pages/people/faces.vue:312
#: src/pages/people/subjects.vue:314
#: src/pages/people/faces.vue:48
#: src/pages/people/faces.vue:304
#: src/pages/people/subjects.vue:315
#: src/share/photo/cards.vue:30
#: src/share/photo/list.vue:34
#: src/share/photo/list.vue:216
@ -1507,8 +1507,8 @@ msgstr ""
#: src/dialog/photo/people.vue:21
#: src/pages/labels.vue:38
#: src/pages/library/files.vue:40
#: src/pages/people/faces.vue:40
#: src/pages/people/subjects.vue:39
#: src/pages/people/faces.vue:42
#: src/pages/people/subjects.vue:40
msgid "Name too long"
msgstr ""
@ -1532,6 +1532,7 @@ msgstr ""
msgid "Newest first"
msgstr ""
#: src/dialog/people/merge.vue:15
#: src/dialog/photo/archive.vue:15
#: src/dialog/photo/info.vue:284
#: src/dialog/photo/info.vue:305
@ -1561,9 +1562,10 @@ msgstr ""
#: src/dialog/photo/people.vue:5
#: src/pages/people/faces.vue:23
#: src/pages/people/faces.vue:353
#: src/pages/people/faces.vue:263
#: src/pages/people/faces.vue:349
#: src/pages/people/subjects.vue:26
#: src/pages/people/subjects.vue:344
#: src/pages/people/subjects.vue:388
msgid "No people found"
msgstr ""
@ -1675,8 +1677,9 @@ msgstr ""
msgid "One label found"
msgstr ""
#: src/pages/people/faces.vue:355
#: src/pages/people/subjects.vue:346
#: src/pages/people/faces.vue:265
#: src/pages/people/faces.vue:351
#: src/pages/people/subjects.vue:390
msgid "One person found"
msgstr ""
@ -1696,8 +1699,8 @@ msgstr ""
msgid "Orange"
msgstr ""
#: src/dialog/photo/files.vue:128
#: src/dialog/photo/files.vue:125
#: src/dialog/photo/files.vue:134
#: src/dialog/photo/files.vue:131
msgid "Orientation"
msgstr ""
@ -1709,8 +1712,8 @@ msgstr ""
msgid "Original file names will be stored and indexed."
msgstr ""
#: src/dialog/photo/files.vue:76
#: src/dialog/photo/files.vue:73
#: src/dialog/photo/files.vue:82
#: src/dialog/photo/files.vue:79
#: src/dialog/photo/info.vue:37
msgid "Original Name"
msgstr ""
@ -1763,7 +1766,7 @@ msgstr ""
#: src/dialog/photo/edit.vue:267
#: src/pages/settings/general.vue:338
#: src/routes.js:265
#: src/routes.js:272
#: src/routes.js:286
msgid "People"
msgstr ""
@ -1843,8 +1846,8 @@ msgstr ""
msgid "Polish"
msgstr ""
#: src/dialog/photo/files.vue:108
#: src/dialog/photo/files.vue:105
#: src/dialog/photo/files.vue:114
#: src/dialog/photo/files.vue:111
msgid "Portrait"
msgstr ""
@ -1875,9 +1878,9 @@ msgid "Preview"
msgstr ""
#: src/dialog/photo/files.vue:34
#: src/dialog/photo/files.vue:100
#: src/dialog/photo/files.vue:106
#: src/dialog/photo/files.vue:31
#: src/dialog/photo/files.vue:97
#: src/dialog/photo/files.vue:103
#: src/dialog/photo/files.vue:24
msgid "Primary"
msgstr ""
@ -1894,8 +1897,8 @@ msgstr ""
msgid "Product Feedback"
msgstr ""
#: src/dialog/photo/files.vue:116
#: src/dialog/photo/files.vue:113
#: src/dialog/photo/files.vue:122
#: src/dialog/photo/files.vue:119
msgid "Projection"
msgstr ""
@ -1979,8 +1982,8 @@ msgstr ""
#: src/pages/labels.vue:91
#: src/pages/library/errors.vue:69
#: src/pages/library/files.vue:100
#: src/pages/people/faces.vue:67
#: src/pages/people/subjects.vue:97
#: src/pages/people/faces.vue:63
#: src/pages/people/subjects.vue:98
msgid "Reload"
msgstr ""
@ -2067,7 +2070,7 @@ msgstr ""
#: src/pages/albums.vue:109
#: src/pages/labels.vue:81
#: src/pages/library/errors.vue:60
#: src/pages/people/subjects.vue:80
#: src/pages/people/subjects.vue:81
#: src/pages/places.vue:30
#: src/routes.js:107
msgid "Search"
@ -2133,11 +2136,11 @@ msgstr ""
#: src/component/navigation.vue:18
#: src/component/navigation.vue:4
#: src/component/navigation.vue:1305
#: src/routes.js:301
#: src/routes.js:313
#: src/routes.js:325
#: src/routes.js:337
#: src/routes.js:349
#: src/routes.js:315
#: src/routes.js:327
#: src/routes.js:339
#: src/routes.js:351
#: src/routes.js:363
msgid "Settings"
msgstr ""
@ -2198,7 +2201,7 @@ msgstr ""
msgid "Shows more detailed log messages. Requires a restart."
msgstr ""
#: src/model/file.js:186
#: src/model/file.js:185
msgid "Sidecar"
msgstr ""
@ -2215,8 +2218,8 @@ msgid "Similar"
msgstr ""
#: src/dialog/account/edit.vue:220
#: src/dialog/photo/files.vue:82
#: src/dialog/photo/files.vue:79
#: src/dialog/photo/files.vue:88
#: src/dialog/photo/files.vue:85
#: src/dialog/photo/files.vue:32
msgid "Size"
msgstr ""
@ -2296,8 +2299,8 @@ msgstr ""
msgid "Status"
msgstr ""
#: src/dialog/photo/files.vue:64
#: src/dialog/photo/files.vue:61
#: src/dialog/photo/files.vue:70
#: src/dialog/photo/files.vue:67
msgid "Storage Folder"
msgstr ""
@ -2310,7 +2313,7 @@ msgid "Style"
msgstr ""
#: src/dialog/photo/details.vue:482
#: src/model/subject.js:135
#: src/model/subject.js:136
msgid "Subject"
msgstr ""
@ -2340,7 +2343,7 @@ msgid "Teal"
msgstr ""
#: src/dialog/photo/details.vue:26
#: src/pages/people/faces.vue:49
#: src/pages/people/faces.vue:51
msgid "Text too long"
msgstr ""
@ -2431,15 +2434,15 @@ msgid "Try again using other filters or keywords."
msgstr ""
#: src/dialog/account/edit.vue:490
#: src/dialog/photo/files.vue:88
#: src/dialog/photo/files.vue:85
#: src/dialog/photo/files.vue:94
#: src/dialog/photo/files.vue:91
#: src/dialog/photo/files.vue:33
#: src/dialog/photo/info.vue:15
msgid "Type"
msgstr ""
#: src/dialog/photo/people.vue:151
#: src/pages/people/faces.vue:235
#: src/pages/people/faces.vue:229
msgid "Undo"
msgstr ""
@ -2449,7 +2452,7 @@ msgstr ""
#: src/dialog/photo/details.vue:16
#: src/dialog/photo/info.vue:21
#: src/model/album.js:146
#: src/model/album.js:136
#: src/model/photo.js:527
#: src/model/photo.js:544
#: src/model/photo.js:567
@ -2576,7 +2579,7 @@ msgstr ""
#: src/component/photo/cards.vue:225
#: src/component/photo/list.vue:198
#: src/component/photo/mosaic.vue:200
#: src/model/file.js:184
#: src/model/file.js:183
#: src/model/photo.js:618
#: src/model/photo.js:632
#: src/options/options.js:300
@ -2643,12 +2646,13 @@ msgstr ""
msgid "Yellowstone"
msgstr ""
#: src/dialog/people/merge.vue:18
#: src/dialog/photo/archive.vue:18
#: src/dialog/photo/files.vue:103
#: src/dialog/photo/files.vue:111
#: src/dialog/photo/files.vue:109
#: src/dialog/photo/files.vue:117
#: src/dialog/photo/files.vue:157
#: src/dialog/photo/files.vue:100
#: src/dialog/photo/files.vue:108
#: src/dialog/photo/files.vue:106
#: src/dialog/photo/files.vue:114
#: src/dialog/photo/files.vue:154
#: src/dialog/photo/info.vue:284
#: src/dialog/photo/info.vue:305

View file

@ -131,26 +131,16 @@ export class Album extends RestModel {
return DateTime.fromISO(`${date}T12:00:00Z`).toUTC();
}
localDate(time) {
if (!this.TakenAtLocal) {
return this.utcDate();
}
let zone = this.getTimeZone();
return DateTime.fromISO(this.localDateString(time), { zone });
}
getDateString() {
if (!this.Year || this.Year <= 1000) {
return $gettext("Unknown");
} else if (!this.Month || this.Month <= 0) {
return this.localYearString();
return this.Year.toString();
} else if (!this.Day || this.Day <= 0) {
return this.getDate().toLocaleString({ month: "long", year: "numeric" });
}
return this.localDate().toLocaleString(DateTime.DATE_HUGE);
return this.getDate().toLocaleString(DateTime.DATE_HUGE);
}
getCreatedString() {

View file

@ -33,13 +33,11 @@ import RestModel from "model/rest";
import { DateTime } from "luxon";
import { config } from "../session";
import { $gettext } from "common/vm";
import * as src from "../common/src";
import Api from "../common/api";
export class Face extends RestModel {
constructor(values) {
if (values && values.Marker) {
values.Marker = new Marker(values.Marker);
}
super(values);
}
@ -48,15 +46,24 @@ export class Face extends RestModel {
ID: "",
Src: "",
SubjUID: "",
SubjSrc: "",
FileUID: "",
MarkerUID: "",
Samples: 0,
SampleRadius: 0.0,
Collisions: 0,
CollisionRadius: 0.0,
Marker: new Marker(),
Hidden: false,
MatchedAt: "",
CreatedAt: "",
UpdatedAt: "",
Name: "",
FaceDist: 0.0,
Size: 0,
Score: 0,
Review: false,
Invalid: false,
Thumb: "",
};
}
@ -65,7 +72,7 @@ export class Face extends RestModel {
}
classes(selected) {
let classes = ["is-face", "uid-" + this.UID];
let classes = ["is-face", "uid-" + this.ID];
if (this.Hidden) classes.push("is-hidden");
if (selected) classes.push("is-selected");
@ -82,11 +89,15 @@ export class Face extends RestModel {
}
thumbnailUrl(size) {
if (!this.Marker) {
return `${config.contentUri}/svg/portrait`;
if (!size) {
size = "tile_160";
}
return this.Marker.thumbnailUrl(size);
if (this.Thumb) {
return `${config.contentUri}/t/${this.Thumb}/${config.previewToken()}/${size}`;
} else {
return `${config.contentUri}/svg/portrait`;
}
}
getDateString() {
@ -103,8 +114,35 @@ export class Face extends RestModel {
return this.update();
}
setName() {
if (!this.Name || this.Name.trim() === "") {
// Can't save an empty name.
return Promise.resolve(this);
}
this.SubjSrc = src.Manual;
const payload = { SubjSrc: this.SubjSrc, Name: this.Name };
return Api.put(Marker.getCollectionResource() + "/" + this.MarkerUID, payload).then((resp) => {
if (resp && resp.data && resp.data.Name) {
const data = resp.data;
this.setValues({
Name: data.Name,
SubjSrc: data.SubjSrc,
SubjUID: data.SubjUID,
Review: data.Review,
Invalid: data.Invalid,
Thumb: data.Thumb,
});
}
return Promise.resolve(this);
});
}
static batchSize() {
return 120;
return 24;
}
static getCollectionResource() {

View file

@ -130,7 +130,6 @@ export class File extends RestModel {
download() {
if (!this.Hash) {
console.warn("no file hash found for download", this);
return;
}

View file

@ -131,7 +131,7 @@ export class Marker extends RestModel {
}
static batchSize() {
return 60;
return 48;
}
static getCollectionResource() {

View file

@ -96,6 +96,16 @@ export class Model {
return result;
}
originalValue(key) {
if (this.__originalValues.hasOwnProperty(key) && key !== "__originalValues") {
return this.__originalValues[key];
} else if (this.hasOwnProperty(key) && key !== "__originalValues") {
return this[key];
}
return null;
}
wasChanged() {
const changed = this.getValues(true);

View file

@ -51,6 +51,7 @@ export class Subject extends RestModel {
Private: false,
Excluded: false,
FileCount: 0,
PhotoCount: 0,
Thumb: "",
ThumbSrc: "",
Metadata: {},
@ -124,7 +125,7 @@ export class Subject extends RestModel {
}
static batchSize() {
return 480;
return 60;
}
static getCollectionResource() {

View file

@ -77,7 +77,7 @@
<v-layout row wrap class="search-results album-results cards-view" :class="{'select-results': selection.length > 0}">
<v-flex
v-for="(album, index) in results"
:key="index"
:key="album.UID"
xs6 sm4 md3 xlg2 xxl1 d-flex
>
<v-card tile

View file

@ -55,7 +55,7 @@
<v-layout row wrap class="search-results label-results cards-view" :class="{'select-results': selection.length > 0}">
<v-flex
v-for="(label, index) in results"
:key="index"
:key="label.UID"
xs6 sm4 md3 lg2 xxl1 d-flex
>
<v-card tile

View file

@ -27,7 +27,7 @@
</v-container>
<v-list v-else-if="errors.length > 0" dense two-line class="transparent">
<v-list-tile
v-for="(err, index) in errors" :key="index"
v-for="err in errors" :key="err.ID"
avatar
@click="showDetails(err)"
>

View file

@ -46,7 +46,7 @@
<v-layout row wrap class="search-results file-results cards-view" :class="{'select-results': selection.length > 0}">
<v-flex
v-for="(model, index) in results"
:key="index"
:key="model.UID"
xs6 sm4 md3 lg2 xxl1 d-flex
>
<v-card tile

View file

@ -8,17 +8,23 @@
slider-color="secondary-dark"
:height="$vuetify.breakpoint.smAndDown ? 48 : 64"
>
<v-tab v-for="(item, index) in tabs" :id="'tab-' + item.name" :key="index" :class="item.class" ripple
@click="changePath(item.path)">
<v-tab v-for="(item, index) in tabs" :id="'tab-' + item.name" :key="index" :class="item.class"
ripple @click="changePath(item.path)">
<v-icon v-if="$vuetify.breakpoint.smAndDown" :title="item.label">{{ item.icon }}</v-icon>
<template v-else>
<v-icon :size="18" :left="!rtl" :right="rtl">{{ item.icon }}</v-icon> {{ item.label }}
<v-icon :size="18" :left="!rtl" :right="rtl">{{ item.icon }}</v-icon>
<v-badge color="secondary-dark" :left="rtl" :right="!rtl">
<template #badge>
<span v-if="item.count">{{ item.count }}</span>
</template>
{{ item.label }}
</v-badge>
</template>
</v-tab>
<v-tabs-items touchless>
<v-tab-item v-for="(item, index) in tabs" :key="index" lazy>
<component :is="item.component" :static-filter="item.filter" :active="active === index"></component>
<component :is="item.component" :static-filter="item.filter" :active="active === index" @updateFaceCount="onUpdateFaceCount"></component>
</v-tab-item>
</v-tabs-items>
</v-tabs>
@ -59,13 +65,10 @@ export default {
'class': '',
'path': '/people/new',
'icon': 'person_add',
'count': 0,
},
];
if (config.count.people === 0) {
tabName = "people-faces";
}
let active = 0;
if (typeof tabName === 'string' && tabName !== '') {
@ -83,7 +86,10 @@ export default {
};
},
methods: {
changePath: function (path) {
onUpdateFaceCount(count) {
this.tabs[1].count = count;
},
changePath (path) {
if (this.$route.path !== path) {
this.$router.replace(path);
}

View file

@ -1,7 +1,5 @@
<template>
<div v-infinite-scroll="loadMore" class="p-page p-page-faces" style="user-select: none"
:infinite-scroll-disabled="scrollDisabled" :infinite-scroll-distance="1200"
:infinite-scroll-listen-for-event="'scrollRefresh'">
<div class="p-page p-page-faces" style="user-select: none">
<v-form ref="form" class="p-faces-search" lazy-validation dense @submit.prevent="updateQuery">
<v-toolbar dense flat color="secondary-light pa-0">
@ -35,22 +33,21 @@
</v-alert>
<v-layout row wrap class="search-results face-results cards-view" :class="{'select-results': selection.length > 0}">
<v-flex
v-for="(model, index) in results"
:key="index"
v-for="model in results"
:key="model.ID"
xs12 sm6 md4 lg3 xl2 xxl1 d-flex
>
<v-card v-if="model.Marker"
:data-id="model.Marker.UID"
<v-card :data-id="model.ID"
tile style="user-select: none;"
:class="model.classes()"
class="result accent lighten-3">
<div class="card-background accent lighten-3"></div>
<v-img :src="model.Marker.thumbnailUrl('tile_320')"
<v-img :src="model.thumbnailUrl('tile_320')"
:transition="false"
aspect-ratio="1"
class="accent lighten-2 clickable"
@click.stop.prevent="$router.push(model.route(view))">
<v-btn v-if="!model.Marker.SubjUID && !model.Hidden" :ripple="false" :depressed="false" class="input-hide"
<v-btn v-if="!model.SubjUID && !model.Hidden" :ripple="false" :depressed="false" class="input-hide"
icon flat small absolute :title="$gettext('Hide')"
@click.stop.prevent="onHide(model)">
<v-icon color="white" class="action-hide">clear</v-icon>
@ -68,29 +65,27 @@
</v-btn>
</v-flex>
</v-layout>
<v-layout v-else-if="model.Marker.SubjUID" row wrap align-center>
<v-layout v-else-if="model.SubjUID" row wrap align-center>
<v-flex xs12 class="text-xs-left pa-0">
<v-text-field
v-model="model.Marker.Name"
v-model="model.Name"
:rules="[textRule]"
:disabled="busy"
:readonly="false"
browser-autocomplete="off"
class="input-name pa-0 ma-0"
hide-details
single-line
solo-inverted
clearable
clear-icon="eject"
@click:clear="onClearSubject(model.Marker)"
@change="onRename(model.Marker)"
@keyup.enter.native="onRename(model.Marker)"
@change="onRename(model)"
@keyup.enter.native="onRename(model)"
></v-text-field>
</v-flex>
</v-layout>
<v-layout v-else row wrap align-center>
<v-flex xs12 class="text-xs-left pa-0">
<v-combobox
v-model="model.Marker.Name"
v-model="model.Name"
style="z-index: 250"
:items="$config.values.people"
item-value="Name"
@ -108,8 +103,8 @@
prepend-inner-icon="person_add"
browser-autocomplete="off"
class="input-name pa-0 ma-0"
@change="onRename(model.Marker)"
@keyup.enter.native="onRename(model.Marker)"
@change="onRename(model)"
@keyup.enter.native="onRename(model)"
>
</v-combobox>
</v-flex>
@ -118,7 +113,7 @@
</v-card>
</v-flex>
</v-layout>
<div class="text-xs-center my-2">
<div class="text-xs-center mt-3 mb-2">
<v-btn
color="secondary" round
:to="{name: 'all', query: { q: 'face:new' }}"
@ -164,9 +159,10 @@ export default {
scrollDisabled: true,
loading: true,
busy: false,
batchSize: Face.batchSize(),
batchSize: 999,
offset: 0,
page: 0,
faceCount: 0,
selection: [],
settings: settings,
filter: filter,
@ -200,10 +196,6 @@ export default {
this.filter.order = this.sortOrder();
this.routeName = this.$route.name;
if (this.dirty) {
this.lastFilter = {};
}
this.search();
}
},
@ -213,7 +205,6 @@ export default {
this.subscriptions.push(Event.subscribe("faces", (ev, data) => this.onUpdate(ev, data)));
this.subscriptions.push(Event.subscribe("touchmove.top", () => this.refresh()));
this.subscriptions.push(Event.subscribe("touchmove.bottom", () => this.loadMore()));
},
destroyed() {
for (let i = 0; i < this.subscriptions.length; i++) {
@ -222,20 +213,13 @@ export default {
},
methods: {
searchCount() {
const offset = parseInt(window.localStorage.getItem("faces_offset"));
if(this.offset > 0 || !offset) {
return this.batchSize;
}
return offset + this.batchSize;
return this.batchSize;
},
sortOrder() {
return "relevance";
return "samples";
},
setOffset(offset) {
this.offset = offset;
window.localStorage.setItem("faces_offset", offset);
},
toggleLike(ev, index) {
const inputType = this.input.eval(ev, index);
@ -375,13 +359,18 @@ export default {
this.lastId = "";
},
loadMore() {
if (this.scrollDisabled) return;
if (this.scrollDisabled || !this.active) {
return;
}
this.scrollDisabled = true;
this.listen = false;
const count = this.dirty ? (this.page + 2) * this.batchSize : this.batchSize;
const offset = this.dirty ? 0 : this.offset;
// Always refresh all faces for now.
this.dirty = true;
const count = this.batchSize;
const offset = 0;
const params = {
count: count,
@ -397,22 +386,14 @@ export default {
Face.search(params).then(resp => {
this.results = this.dirty ? resp.models : this.results.concat(resp.models);
this.scrollDisabled = (resp.count < resp.limit);
this.setFaceCount(this.results.length);
if (this.scrollDisabled) {
this.setOffset(resp.offset);
if (this.results.length > 1) {
this.$notify.info(this.$gettextInterpolate(this.$gettext("All %{n} people loaded"), {n: this.results.length}));
}
if (!this.results.length) {
this.$notify.warn(this.$gettext("No people found"));
} else if (this.results.length === 1) {
this.$notify.info(this.$gettext("One person found"));
} else {
this.setOffset(resp.offset + resp.limit);
this.page++;
this.$nextTick(() => {
if (this.$root.$el.clientHeight <= window.document.documentElement.clientHeight + 300) {
this.$emit("scrollRefresh");
}
});
this.$notify.info(this.$gettextInterpolate(this.$gettext("%{n} people found"), {n: this.results.length}));
}
}).catch(() => {
this.scrollDisabled = false;
@ -458,11 +439,15 @@ export default {
return params;
},
refresh() {
if (this.loading) return;
if (this.loading || !this.active) {
return;
}
this.loading = true;
this.page = 0;
this.dirty = true;
this.scrollDisabled = false;
this.loadMore();
},
search() {
@ -470,7 +455,7 @@ export default {
// Don't query the same data more than once
if (JSON.stringify(this.lastFilter) === JSON.stringify(this.filter)) {
this.$nextTick(() => this.$emit("scrollRefresh"));
this.refresh();
return;
}
@ -487,24 +472,14 @@ export default {
this.offset = resp.limit;
this.results = resp.models;
this.scrollDisabled = (resp.count < resp.limit);
this.setFaceCount(this.results.length);
if (this.scrollDisabled) {
if (!this.results.length) {
this.$notify.warn(this.$gettext("No people found"));
} else if (this.results.length === 1) {
this.$notify.info(this.$gettext("One person found"));
} else {
this.$notify.info(this.$gettextInterpolate(this.$gettext("%{n} people found"), {n: this.results.length}));
}
if (!this.results.length) {
this.$notify.warn(this.$gettext("No people found"));
} else if (this.results.length === 1) {
this.$notify.info(this.$gettext("One person found"));
} else {
this.$notify.info(this.$gettext('More than 20 faces found'));
this.$nextTick(() => {
if (this.$root.$el.clientHeight <= window.document.documentElement.clientHeight + 300) {
this.$emit("scrollRefresh");
}
});
this.$notify.info(this.$gettextInterpolate(this.$gettext("%{n} people found"), {n: this.results.length}));
}
}).finally(() => {
this.dirty = false;
@ -514,19 +489,35 @@ export default {
},
onShow(face) {
this.busy = true;
face.show().finally(() => this.busy = false);
face.show().finally(() => {
this.busy = false;
this.changeFaceCount(1);
});
},
onHide(face) {
this.busy = true;
face.hide().finally(() => this.busy = false);
face.hide().finally(() => {
this.busy = false;
this.changeFaceCount(-1);
});
},
onClearSubject(marker) {
onRename(model) {
this.busy = true;
marker.clearSubject(marker).finally(() => this.busy = false);
this.$notify.blockUI();
model.setName().finally(() => {
this.$notify.unblockUI();
this.busy = false;
this.changeFaceCount(-1);
});
},
onRename(marker) {
this.busy = true;
marker.rename().finally(() => this.busy = false);
changeFaceCount(count) {
this.faceCount = this.faceCount + count;
this.$emit('updateFaceCount', this.faceCount);
},
setFaceCount(count) {
this.faceCount = count;
this.$emit('updateFaceCount', this.faceCount);
},
onUpdate(ev, data) {
if (!this.listen) return;

View file

@ -52,7 +52,7 @@
<v-layout row wrap class="search-results subject-results cards-view" :class="{'select-results': selection.length > 0}">
<v-flex
v-for="(model, index) in results"
:key="index"
:key="model.UID"
xs6 sm4 md3 lg2 xxl1 d-flex
>
<v-card tile
@ -132,11 +132,11 @@
</div>
<div class="caption mb-2">
<button v-if="model.FileCount === 1">
<button v-if="model.PhotoCount === 1">
<translate>Contains one entry.</translate>
</button>
<button v-else-if="model.FileCount > 0">
<translate :translate-params="{n: model.FileCount}">Contains %{n} entries.</translate>
<button v-else-if="model.PhotoCount > 0">
<translate :translate-params="{n: model.PhotoCount}">Contains %{n} entries.</translate>
</button>
</div>
</v-card-text>
@ -145,6 +145,8 @@
</v-layout>
</v-container>
</v-container>
<p-people-merge-dialog lazy :show="merge.show" :subj1="merge.subj1" :subj2="merge.subj2" @cancel="onCancelMerge"
@confirm="onMerge"></p-people-merge-dialog>
</div>
</template>
@ -191,6 +193,11 @@ export default {
titleRule: v => v.length <= this.$config.get("clip") || this.$gettext("Name too long"),
input: new Input(),
lastId: "",
merge: {
subj1: null,
subj2: null,
show: false,
}
};
},
watch: {
@ -208,10 +215,6 @@ export default {
this.filter.order = this.sortOrder();
this.routeName = this.$route.name;
if (this.dirty) {
this.lastFilter = {};
}
this.search();
}
},
@ -229,6 +232,39 @@ export default {
}
},
methods: {
onSave(m) {
const existing = this.$config.getPerson(m.Name);
if(!existing) {
m.update();
return;
}
if (existing.UID === m.UID) {
// Name didn't change.
return;
}
this.merge.subj1 = m;
this.merge.subj2 = existing;
this.merge.show = true;
},
onCancelMerge() {
this.merge.subj1.Name = this.merge.subj1.originalValue("Name");
this.merge.show = false;
this.merge.subj1 = null;
this.merge.subj2 = null;
},
onMerge() {
this.merge.show = false;
this.$notify.blockUI();
this.merge.subj1.update().finally(() => {
this.merge.subj1 = null;
this.merge.subj2 = null;
this.$notify.unblockUI();
this.refresh();
});
},
searchCount() {
const offset = parseInt(window.localStorage.getItem("subjects_offset"));
@ -326,9 +362,6 @@ export default {
}
}
},
onSave(m) {
m.update();
},
showAll() {
this.filter.all = "true";
this.updateQuery();
@ -383,7 +416,9 @@ export default {
this.lastId = "";
},
loadMore() {
if (this.scrollDisabled) return;
if (this.scrollDisabled || !this.active) {
return;
}
this.scrollDisabled = true;
this.listen = false;
@ -466,11 +501,15 @@ export default {
return params;
},
refresh() {
if (this.loading) return;
if (this.loading || !this.active) {
return;
}
this.loading = true;
this.page = 0;
this.dirty = true;
this.scrollDisabled = false;
this.loadMore();
},
search() {

View file

@ -45,7 +45,7 @@ import Feedback from "pages/about/feedback.vue";
import License from "pages/about/license.vue";
import Help from "pages/help.vue";
import { $gettext } from "common/vm";
import { session } from "./session";
import { config, session } from "./session";
const c = window.__CONFIG__;
const appName = c.name;
@ -264,6 +264,20 @@ export default [
component: People,
meta: { title: $gettext("People"), auth: true, background: "application-light" },
props: { tab: "people-subjects" },
beforeEnter: (to, from, next) => {
if (!config || !from || !from.name || from.name.startsWith("people")) {
next();
} else {
config.load().finally(() => {
// Open new faces tab when there are no people.
if (config.values.count.people === 0) {
next({ name: "people_faces" });
} else {
next();
}
});
}
},
},
{
name: "people_faces",

View file

@ -31,7 +31,7 @@
<v-layout row wrap class="search-results album-results cards-view" :class="{'select-results': selection.length > 0}">
<v-flex
v-for="(album, index) in results"
:key="index"
:key="album.UID"
xs6 sm4 md3 xlg2 xxl1 d-flex
>
<v-card tile

View file

@ -17,7 +17,7 @@
<v-layout row wrap class="search-results photo-results cards-view" :class="{'select-results': selectMode}">
<v-flex
v-for="(photo, index) in photos"
:key="index"
:key="photo.ID"
xs12 sm6 md4 lg3 xlg2 xxxl1 d-flex
>
<v-card tile

View file

@ -17,7 +17,7 @@
<v-layout row wrap class="search-results photo-results mosaic-view" :class="{'select-results': selectMode}">
<v-flex
v-for="(photo, index) in photos"
:key="index"
:key="photo.ID"
xs4 sm3 md2 lg1 d-flex
>
<v-card tile

View file

@ -194,3 +194,27 @@ test.meta("testID", "labels-004")("Delete label", async (t) => {
.typeText(Selector(".input-label input"), "Dome")
.click(Selector("button.p-photo-label-add"));
});
/*Does not work on sqlite
test.skip("testID", "labels-005")("Check label count", async (t) => {
await page.openNav();
await t.click(Selector(".nav-labels"));
await page.search("cat");
const LabelCat = await Selector("a.is-label", { timeout: 55000 }).nth(0).getAttribute("data-uid");
const CatCaption = await Selector("a[data-uid=" + LabelCat + "] div.caption").innerText;
console.log(CatCaption);
await t.click(Selector("a.is-label").withAttribute("data-uid", LabelCat));
const countPhotosCat = await Selector("div.is-photo").count;
await t.expect(CatCaption).contains(countPhotosCat.toString());
console.log(countPhotosCat);
await page.openNav();
await t.click(Selector(".nav-labels"));
await page.search("people");
const LabelPeople = await Selector("a.is-label", { timeout: 55000 }).nth(0).getAttribute("data-uid");
const PeopleCaption = await Selector("a[data-uid=" + LabelCat + "] div.caption").innerText;
console.log(PeopleCaption);
await t.click(Selector("a.is-label").withAttribute("data-uid", LabelPeople));
const countPhotosPeople = await Selector("div.is-photo").count;
await t.expect(CatCaption).contains(countPhotosPeople.toString());
console.log(countPhotosPeople);
});*/

View file

@ -80,8 +80,20 @@ test.meta("testID", "people-002")("Add + Rename", async (t) => {
.click(Selector("#tab-people"))
.expect(Selector("div.input-name input").nth(0).value)
.contains("Jane Doe")
.typeText(Selector("div.input-name input").nth(0), "Max Mu", { replace: true })
.click("button.action-close");
await page.openNav();
await t
.click(Selector(".nav-people"))
.click(Selector("a[data-uid=" + JaneUID + "] div.v-card__title"))
.typeText(Selector("div.input-rename input"), "Max Mu", { replace: true })
.pressKey("enter")
.expect(Selector("a[data-uid=" + JaneUID + "] div.v-card__title").innerText)
.contains("Max Mu")
.click(Selector("a.is-subject").withAttribute("data-uid", JaneUID));
await t.eval(() => location.reload());
await page.editSelected();
await t
.click(Selector("#tab-people"))
.expect(Selector("div.input-name input").nth(0).value)
.contains("Max Mu")
.click("button.action-next")
@ -99,35 +111,6 @@ test.meta("testID", "people-002")("Add + Rename", async (t) => {
.pressKey("enter");
const countPhotosSubjectAfterRename = await Selector("div.is-photo").count;
await t.expect(countPhotosSubjectAfterRename).eql(countPhotosSubject);
await page.openNav();
await t
.click(Selector(".nav-people"))
.expect(Selector("a[data-uid=" + JaneUID + "] div.v-card__title").innerText)
.contains("Max Mu")
.click(Selector("a[data-uid=" + JaneUID + "] div.v-card__title"))
.typeText(Selector("div.input-rename input"), "Jane Mu", { replace: true })
.pressKey("enter")
.expect(Selector("a[data-uid=" + JaneUID + "] div.v-card__title").innerText)
.contains("Jane Mu")
.click(Selector("a.is-subject").withAttribute("data-uid", JaneUID))
.expect(Selector("div.input-search input").value)
.contains("person:jane-mu");
await page.toggleSelectNthPhoto(0);
await page.toggleSelectNthPhoto(1);
await page.toggleSelectNthPhoto(2);
await page.editSelected();
await t
.click(Selector("#tab-people"))
.expect(Selector("div.input-name input").nth(0).value)
.contains("Jane Mu")
.click("button.action-next")
.expect(Selector("div.input-name input").nth(0).value)
.contains("Jane Mu")
.click("button.action-next")
.expect(Selector("div.input-name input").nth(0).value)
.contains("Jane Mu")
.click("button.action-close");
await page.clearSelection();
});
test.meta("testID", "people-003")("Add + Reject + Star", async (t) => {
@ -148,25 +131,9 @@ test.meta("testID", "people-003")("Add + Reject + Star", async (t) => {
.click(Selector("#tab-people-subjects > a"));
await t.typeText(Selector("div.input-search input"), "Andrea").pressKey("enter");
const AndreaUID = await Selector("a.is-subject").nth(0).getAttribute("data-uid");
await page.openNav();
await t.click(Selector(".nav-browse"));
await page.search("face:new filmpreis");
await page.toggleSelectNthPhoto(0);
await page.editSelected();
await t
.click(Selector("#tab-people"))
.expect(Selector("div.input-name input").nth(0).value)
.eql("")
.typeText(Selector("div.input-name input").nth(0), "Andrea Doe", { replace: true })
.click(Selector("div").withText("Andrea Doe"))
.expect(Selector("div.input-name input").nth(0).value)
.contains("Andrea Doe")
.click("button.action-close");
await page.clearSelection();
await page.openNav();
await t
.click(Selector(".nav-people"))
.click(Selector("a.is-subject").withAttribute("data-uid", AndreaUID));
await t.click(Selector("a.is-subject").withAttribute("data-uid", AndreaUID));
await t.eval(() => location.reload());
await t.wait(6000);
const countPhotosAndreaAfterAdd = await Selector("div.is-photo").count;
await page.toggleSelectNthPhoto(1);
await page.editSelected();
@ -186,10 +153,12 @@ test.meta("testID", "people-003")("Add + Reject + Star", async (t) => {
const countPhotosAndreaAfterReject = await Selector("div.is-photo").count;
const Diff = countPhotosAndreaAfterAdd - countPhotosAndreaAfterReject;
await t
.typeText(Selector("div.input-search input"), "Nicole", { replace: true })
.typeText(Selector("div.input-search input"), "person:nicole", { replace: true })
.pressKey("enter");
await t.eval(() => location.reload());
await t.wait(6000);
const countPhotosNicole = await Selector("div.is-photo").count;
await t.expect(countPhotosNicole).eql(Diff);
await t.expect(Diff).eql(countPhotosNicole);
await page.openNav();
await t
.click(Selector(".nav-people"))

View file

@ -49,6 +49,15 @@ describe("common/config", () => {
assert.equal(config.values.settings.ui.language, "en");
});
it("should test constructor with empty values", () => {
const storage = new StorageShim();
const values = {};
const config = new Config(storage, values);
assert.equal(config.debug, true);
assert.equal(config.demo, false);
assert.equal(config.apiUri, "/api/v1");
});
it("should store values", () => {
const storage = new StorageShim();
const values = { siteTitle: "Foo", country: "Germany", city: "Hamburg" };
@ -79,6 +88,93 @@ describe("common/config", () => {
assert.equal(config2.feature("places"), true);
assert.equal(config2.feature("download"), true);
});
it("should test get name", () => {
const result = config2.getPerson("a");
assert.equal(result, null);
const result2 = config2.getPerson("Andrea Sander");
assert.equal(result2.UID, "jr0jgyx2viicdnf7");
const result3 = config2.getPerson("Otto Sander");
assert.equal(result3.UID, "jr0jgyx2viicdn88");
});
it("should create, update and delete people", () => {
const storage = new StorageShim();
const values = { Debug: true, siteTitle: "Foo", country: "Germany", city: "Hamburg" };
const config = new Config(storage, values);
config.onPeople(".created");
assert.empty(config.values.people);
config.onPeople(".created", {
entities: [
{
UID: "abc123",
Name: "Test Name",
Keywords: ["Test", "Name"],
},
],
});
assert.equal(config.values.people[0].Name, "Test Name");
config.onPeople(".updated", {
entities: [
{
UID: "abc123",
Name: "New Name",
Keywords: ["New", "Name"],
},
],
});
assert.equal(config.values.people[0].Name, "New Name");
config.onPeople(".deleted", {
entities: [
{
UID: "abc123",
Name: "New Name",
Keywords: ["New", "Name"],
},
],
});
assert.equal(config.values.people[0].Name, "New Name");
});
it("should return if language is rtl", () => {
const result = config2.rtl();
assert.equal(result, false);
const storage = new StorageShim();
const values = {
Debug: true,
siteTitle: "Foo",
country: "Germany",
city: "Hamburg",
settings: {
ui: {
language: "he",
},
},
};
const config = new Config(storage, values);
const result2 = config.rtl();
assert.equal(result2, true);
const values2 = { siteTitle: "Foo", country: "Germany", city: "Hamburg" };
const config3 = new Config(storage, values2);
const result3 = config3.rtl();
assert.equal(result3, false);
});
it("should return album categories", () => {
const myConfig = new Config(new StorageShim(), Object.assign({}, window.__CONFIG__));
const result = myConfig.albumCategories();
assert.equal(result[0], "Animal");
const newValues = {
albumCategories: ["Mouse"],
};
myConfig.setValues(newValues);
const result2 = myConfig.albumCategories();
assert.equal(result2[0], "Mouse");
});
//TODO
/*it.only("should test onCount", () => {
const items = [{}, {}, {}];

View file

@ -23,7 +23,7 @@ const clientConfig = {
test: true,
demo: false,
sponsor: true,
albumCategories: null,
albumCategories: ["Animal", "Holiday"],
albums: [
{
ID: 69,
@ -218,6 +218,23 @@ const clientConfig = {
Name: "Unknown",
},
],
people: [
{
UID: "jr0jgyx2viicdnf7",
Name: "Andrea Sander",
Keywords: ["andrea"],
},
{
UID: "jr0jgyx2viicdn88",
Name: "Otto Sander",
Keywords: ["andrea"],
},
{
UID: "jr0jgzi2qmp5wt97",
Name: "Otto Sander",
Keywords: ["otto", "sander"],
},
],
thumbs: [
{
size: "fit_720",

View file

@ -293,6 +293,47 @@ Mock.onDelete("api/v1/photos/undefined/like").reply(200, { status: "ok" }, mockH
Mock.onPost("api/v1/albums/undefined/like").reply(200, { status: "ok" }, mockHeaders);
Mock.onDelete("api/v1/albums/undefined/like").reply(200, { status: "ok" }, mockHeaders);
Mock.onGet("api/v1/config").reply(200, clientConfig, mockHeaders);
Mock.onPut("api/v1/markers/mBC123ghytr", { Review: false, Invalid: false }).reply(
200,
{
success: "ok",
},
mockHeaders
);
Mock.onPut("api/v1/markers/mCC123ghytr", { Review: false, Invalid: true }).reply(
200,
{
success: "ok",
},
mockHeaders
);
Mock.onPut("api/v1/markers/mDC123ghytr", { SubjSrc: "manual", Name: "testname" }).reply(
200,
{
success: "ok",
},
mockHeaders
);
Mock.onDelete("api/v1/markers/mEC123ghytr/subject").reply(200, { success: "ok" }, mockHeaders);
Mock.onPut("api/v1/faces/f123ghytrfggd", { Hidden: false }).reply(
200,
{
success: "ok",
},
mockHeaders
);
Mock.onPut("api/v1/faces/f123ghytrfggd", { Hidden: true }).reply(
200,
{
success: "ok",
},
mockHeaders
);
Mock.onPost("api/v1/subjects/s123ghytrfggd/like").reply(200, { status: "ok" }, mockHeaders);
Mock.onDelete("api/v1/subjects/s123ghytrfggd/like").reply(200, { status: "ok" }, mockHeaders);
Mock.onGet("api/v1/config/options").reply(200, { success: "ok" }, mockHeaders);
Mock.onPost("api/v1/config/options").reply(200, { success: "ok" }, mockHeaders);
//Mock.onPost().reply(200);
//Mock.onDelete().reply(200);
/*

View file

@ -64,6 +64,25 @@ describe("model/album", () => {
const album = new Album(values);
const result = album.thumbnailUrl("xyz");
assert.equal(result, "/api/v1/t/d6b24d688564f7ddc7b245a414f003a8d8ff5a67/public/xyz");
const values2 = {
ID: 5,
Title: "Christmas 2019",
Slug: "christmas-2019",
UID: 66,
};
const album2 = new Album(values2);
const result2 = album2.thumbnailUrl("xyz");
assert.equal(result2, "/api/v1/albums/66/t/public/xyz");
const values3 = {
ID: 5,
Title: "Christmas 2019",
Slug: "christmas-2019",
};
const album3 = new Album(values3);
const result3 = album3.thumbnailUrl("xyz");
assert.equal(result3, "/api/v1/svg/album");
});
it("should get created date string", () => {
@ -93,6 +112,21 @@ describe("model/album", () => {
assert.equal(result, "May 2019");
});
it("should get album date string with invalid month", () => {
const values = {
ID: 5,
Title: "Christmas 2019",
Slug: "christmas-2019",
CreatedAt: "2012-07-08T14:45:39Z",
Day: 1,
Month: -5,
Year: 2000,
};
const album = new Album(values);
const result = album.getDateString();
assert.equal(result, "2000");
});
it("should get album date string with invalid year", () => {
const values = {
ID: 5,
@ -108,6 +142,21 @@ describe("model/album", () => {
assert.equal(result, "Unknown");
});
it("should get album date string", () => {
const values = {
ID: 5,
Title: "Christmas 2019",
Slug: "christmas-2019",
CreatedAt: "2012-07-08T14:45:39Z",
Day: 1,
Month: 5,
Year: 2000,
};
const album = new Album(values);
const result = album.getDateString();
assert.equal(result, "Monday, May 1, 2000");
});
it("should get day string", () => {
const values = {
ID: 5,

View file

@ -0,0 +1,52 @@
import "../fixtures";
import ConfigOptions from "model/config-options";
let chai = require("chai/chai");
let assert = chai.assert;
describe("model/config-options", () => {
it("should get options defaults", () => {
const values = {};
const options = new ConfigOptions(values);
const result = options.getDefaults();
assert.equal(result.Debug, false);
assert.equal(result.ReadOnly, false);
assert.equal(result.ThumbSize, 0);
});
it("should test changed", () => {
const values = {};
const options = new ConfigOptions(values);
assert.equal(options.changed(), false);
});
it("should load options", (done) => {
const values = {};
const options = new ConfigOptions(values);
options
.load()
.then((response) => {
assert.equal(response.success, "ok");
done();
})
.catch((error) => {
done(error);
});
assert.equal(options.changed(), false);
});
it("should save options", (done) => {
const values = { Debug: true };
const options = new ConfigOptions(values);
options
.save()
.then((response) => {
assert.equal(response.success, "ok");
done();
})
.catch((error) => {
done(error);
});
assert.equal(options.changed(), false);
});
});

View file

@ -0,0 +1,121 @@
import "../fixtures";
import Face from "model/face";
let chai = require("chai/chai");
let assert = chai.assert;
describe("model/face", () => {
it("should get face defaults", () => {
const values = {};
const face = new Face(values);
const result = face.getDefaults();
assert.equal(result.ID, "");
assert.equal(result.SampleRadius, 0.0);
});
it("should get route view", () => {
const values = { ID: "f123ghytrfggd", Samples: 5 };
const face = new Face(values);
const result = face.route("test");
assert.equal(result.name, "test");
assert.equal(result.query.q, "face:f123ghytrfggd");
});
it("should return classes", () => {
const values = { ID: "f123ghytrfggd", Samples: 5 };
const face = new Face(values);
const result = face.classes(true);
assert.include(result, "is-face");
assert.include(result, "uid-f123ghytrfggd");
assert.include(result, "is-selected");
assert.notInclude(result, "is-hidden");
const result2 = face.classes(false);
assert.include(result2, "is-face");
assert.include(result2, "uid-f123ghytrfggd");
assert.notInclude(result2, "is-selected");
assert.notInclude(result2, "is-hidden");
const values2 = { ID: "f123ghytrfggd", Samples: 5, Hidden: true };
const face2 = new Face(values2);
const result3 = face2.classes(true);
assert.include(result3, "is-face");
assert.include(result3, "uid-f123ghytrfggd");
assert.include(result3, "is-selected");
assert.include(result3, "is-hidden");
});
it("should get face entity name", () => {
const values = { ID: "f123ghytrfggd", Samples: 5 };
const face = new Face(values);
const result = face.getEntityName();
assert.equal(result, "f123ghytrfggd");
});
it("should get face title", () => {
const values = { ID: "f123ghytrfggd", Samples: 5 };
const face = new Face(values);
const result = face.getTitle();
assert.equal(result, undefined);
});
it("should get thumbnail url", () => {
const values = {
ID: "f123ghytrfggd",
Samples: 5,
MarkerUID: "ABC123ghytr",
FileUID: "fhjouohnnmnd",
Name: "",
Thumb: "7ca759a2b788cc5bcc08dbbce9854ff94a2f94d1",
};
const face = new Face(values);
const result = face.thumbnailUrl("xyz");
assert.equal(result, "/api/v1/t/7ca759a2b788cc5bcc08dbbce9854ff94a2f94d1/public/xyz");
const values2 = { ID: "f123ghytrfggd", Samples: 5, Thumb: "7ca759a2b788cc5bcc08dbbce9854ff94a2f94d1" };
const face2 = new Face(values2);
const result2 = face2.thumbnailUrl();
assert.equal(result2, "/api/v1/t/7ca759a2b788cc5bcc08dbbce9854ff94a2f94d1/public/tile_160");
});
it("should get date string", () => {
const values = {
ID: "f123ghytrfggd",
Samples: 5,
CreatedAt: "2012-07-08T14:45:39Z",
};
const face = new Face(values);
const result = face.getDateString();
assert.equal(result, "Jul 8, 2012, 2:45 PM");
});
it("show and hide face", () => {
const values = {
ID: "f123ghytrfggd",
Samples: 5,
CreatedAt: "2012-07-08T14:45:39Z",
Hidden: true,
};
const face = new Face(values);
assert.equal(face.Hidden, true);
face.show();
assert.equal(face.Hidden, false);
face.hide();
assert.equal(face.Hidden, true);
});
it("should return batch size", () => {
assert.equal(Face.batchSize(), 24);
});
it("should get collection resource", () => {
const result = Face.getCollectionResource();
assert.equal(result, "faces");
});
it("should get model name", () => {
const result = Face.getModelName();
assert.equal(result, "Face");
});
});

View file

@ -95,6 +95,16 @@ describe("model/file", () => {
};
const file3 = new File(values3);
assert.equal(file3.thumbnailUrl("tile_224"), "/api/v1/svg/raw");
const values4 = {
InstanceID: 5,
UID: "ABC123",
Hash: "54ghtfd",
Type: "jpg",
Name: "1/2/IMG123.jpg",
Sidecar: true,
};
const file4 = new File(values4);
assert.equal(file4.thumbnailUrl("tile_224"), "/api/v1/svg/file");
});
it("should return download url", () => {
@ -109,6 +119,17 @@ describe("model/file", () => {
assert.equal(file.getDownloadUrl("abc"), "/api/v1/dl/54ghtfd?t=2lbh9x09");
});
it("should not download as hash is missing", () => {
const values = {
InstanceID: 5,
UID: "ABC123",
Type: "jpg",
Name: "1/2/IMG123.jpg",
};
const file = new File(values);
assert.equal(file.download(), undefined);
});
it("should calculate size", () => {
const values = {
InstanceID: 5,

View file

@ -59,6 +59,25 @@ describe("model/label", () => {
const label = new Label(values);
const result = label.thumbnailUrl("xyz");
assert.equal(result, "/api/v1/t/c6b24d688564f7ddc7b245a414f003a8d8ff5a67/public/xyz");
const values2 = {
ID: 5,
UID: "ABC123",
Name: "Black Cat",
Slug: "black-cat",
};
const label2 = new Label(values2);
const result2 = label2.thumbnailUrl("xyz");
assert.equal(result2, "/api/v1/labels/ABC123/t/public/xyz");
const values3 = {
ID: 5,
Name: "Black Cat",
Slug: "black-cat",
};
const label3 = new Label(values3);
const result3 = label3.thumbnailUrl("xyz");
assert.equal(result3, "/api/v1/svg/label");
});
it("should get date string", () => {

View file

@ -0,0 +1,218 @@
import "../fixtures";
import Marker from "model/marker";
let chai = require("chai/chai");
let assert = chai.assert;
describe("model/marker", () => {
it("should get marker defaults", () => {
const values = { FileUID: "fghjojp" };
const marker = new Marker(values);
const result = marker.getDefaults();
assert.equal(result.UID, "");
assert.equal(result.FileUID, "");
});
it("should get route view", () => {
const values = { UID: "ABC123ghytr", FileUID: "fhjouohnnmnd", Type: "face", Src: "image" };
const marker = new Marker(values);
const result = marker.route("test");
assert.equal(result.name, "test");
assert.equal(result.query.q, "marker:ABC123ghytr");
});
it("should return classes", () => {
const values = { UID: "ABC123ghytr", FileUID: "fhjouohnnmnd", Type: "face", Src: "image" };
const marker = new Marker(values);
const result = marker.classes(true);
assert.include(result, "is-marker");
assert.include(result, "uid-ABC123ghytr");
assert.include(result, "is-selected");
assert.notInclude(result, "is-review");
assert.notInclude(result, "is-invalid");
const result2 = marker.classes(false);
assert.include(result2, "is-marker");
assert.include(result2, "uid-ABC123ghytr");
assert.notInclude(result2, "is-selected");
assert.notInclude(result2, "is-review");
assert.notInclude(result2, "is-invalid");
const values2 = {
UID: "mBC123ghytr",
FileUID: "fhjouohnnmnd",
Type: "face",
Src: "image",
Invalid: true,
Review: true,
};
const marker2 = new Marker(values2);
const result3 = marker2.classes(true);
assert.include(result3, "is-marker");
assert.include(result3, "uid-mBC123ghytr");
assert.include(result3, "is-selected");
assert.include(result3, "is-review");
assert.include(result3, "is-invalid");
});
it("should get marker entity name", () => {
const values = {
UID: "ABC123ghytr",
FileUID: "fhjouohnnmnd",
Type: "face",
Src: "image",
Name: "test",
};
const marker = new Marker(values);
const result = marker.getEntityName();
assert.equal(result, "test");
});
it("should get marker title", () => {
const values = {
UID: "ABC123ghytr",
FileUID: "fhjouohnnmnd",
Type: "face",
Src: "image",
Name: "test",
};
const marker = new Marker(values);
const result = marker.getTitle();
assert.equal(result, "test");
});
it("should get thumbnail url", () => {
const values = { UID: "ABC123ghytr", FileUID: "fhjouohnnmnd", Type: "face", Src: "image" };
const marker = new Marker(values);
const result = marker.thumbnailUrl("xyz");
assert.equal(result, "/api/v1/svg/portrait");
const values2 = {
UID: "ABC123ghytr",
FileUID: "fhjouohnnmnd",
Type: "face",
Src: "image",
Thumb: "nicethumbuid",
};
const marker2 = new Marker(values2);
const result2 = marker2.thumbnailUrl();
assert.equal(result2, "/api/v1/t/nicethumbuid/public/tile_160");
});
it("should get date string", () => {
const values = {
UID: "ABC123ghytr",
FileUID: "fhjouohnnmnd",
Type: "face",
Src: "image",
CreatedAt: "2012-07-08T14:45:39Z",
};
const marker = new Marker(values);
const result = marker.getDateString();
assert.equal(result, "Jul 8, 2012, 2:45 PM");
});
it("should approve marker", () => {
const values = {
UID: "mBC123ghytr",
FileUID: "fhjouohnnmnd",
Type: "face",
Src: "image",
Invalid: true,
Review: true,
};
const marker = new Marker(values);
assert.equal(marker.Review, true);
assert.equal(marker.Invalid, true);
marker.approve();
assert.equal(marker.Review, false);
assert.equal(marker.Invalid, false);
});
it("should reject marker", () => {
const values = {
UID: "mCC123ghytr",
FileUID: "fhjouohnnmnd",
Type: "face",
Src: "image",
Invalid: false,
Review: true,
};
const marker = new Marker(values);
assert.equal(marker.Review, true);
assert.equal(marker.Invalid, false);
marker.reject();
assert.equal(marker.Review, false);
assert.equal(marker.Invalid, true);
});
it("should rename marker", (done) => {
const values = {
UID: "mDC123ghytr",
FileUID: "fhjouohnnmnd",
Type: "face",
Src: "image",
Subject: "skhljkpigh",
Name: "",
SubjSrc: "manual",
};
const marker = new Marker(values);
assert.equal(marker.Name, "");
marker.rename();
assert.equal(marker.Name, "");
const values2 = {
UID: "mDC123ghytr",
FileUID: "fhjouohnnmnd",
Type: "face",
Src: "image",
Subject: "skhljkpigh",
Name: "testname",
SubjSrc: "manual",
};
const marker2 = new Marker(values2);
assert.equal(marker2.Name, "testname");
marker2
.rename()
.then((response) => {
assert.equal(response.success, "ok");
done();
})
.catch((error) => {
done(error);
});
});
it("should clear subject", (done) => {
const values = {
UID: "mEC123ghytr",
FileUID: "fhjouohnnmnd",
Type: "face",
Src: "image",
Subject: "skhljkpigh",
Name: "testname",
SubjSrc: "manual",
};
const marker = new Marker(values);
marker
.clearSubject()
.then((response) => {
assert.equal(response.success, "ok");
done();
})
.catch((error) => {
done(error);
});
});
it("should return batch size", () => {
assert.equal(Marker.batchSize(), 48);
});
it("should get collection resource", () => {
const result = Marker.getCollectionResource();
assert.equal(result, "markers");
});
it("should get model name", () => {
const result = Marker.getModelName();
assert.equal(result, "Marker");
});
});

View file

@ -0,0 +1,216 @@
import "../fixtures";
import Subject from "model/subject";
let chai = require("chai/chai");
let assert = chai.assert;
describe("model/subject", () => {
it("should get face defaults", () => {
const values = {};
const subject = new Subject(values);
const result = subject.getDefaults();
assert.equal(result.UID, "");
assert.equal(result.Favorite, false);
});
it("should get route view", () => {
const values = { UID: "s123ghytrfggd", Type: "person", Src: "manual" };
const subject = new Subject(values);
const result = subject.route("test");
assert.equal(result.name, "test");
assert.equal(result.query.q, "subject:s123ghytrfggd");
const values2 = {
UID: "s123ghytrfggd",
Type: "person",
Src: "manual",
Name: "Jane Doe",
Slug: "jane-doe",
};
const subject2 = new Subject(values2);
const result2 = subject2.route("test");
assert.equal(result2.name, "test");
assert.equal(result2.query.q, "person:jane-doe");
});
it("should return classes", () => {
const values = {
UID: "s123ghytrfggd",
Type: "person",
Src: "manual",
Name: "Jane Doe",
Slug: "jane-doe",
Favorite: false,
Excluded: true,
Private: true,
};
const subject = new Subject(values);
const result = subject.classes(true);
assert.include(result, "is-subject");
assert.include(result, "uid-s123ghytrfggd");
assert.include(result, "is-selected");
assert.notInclude(result, "is-favorite");
assert.include(result, "is-private");
assert.include(result, "is-excluded");
const values2 = {
UID: "s123ghytrfggd",
Type: "person",
Src: "manual",
Name: "Jane Doe",
Slug: "jane-doe",
Favorite: true,
Excluded: false,
Private: false,
};
const subject2 = new Subject(values2);
const result2 = subject2.classes(false);
assert.include(result2, "is-subject");
assert.include(result2, "uid-s123ghytrfggd");
assert.notInclude(result2, "is-selected");
assert.include(result2, "is-favorite");
assert.notInclude(result2, "is-private");
assert.notInclude(result2, "is-excluded");
});
it("should get subject entity name", () => {
const values = {
UID: "s123ghytrfggd",
Type: "person",
Src: "manual",
Name: "Jane Doe",
Slug: "jane-doe",
Favorite: false,
Excluded: true,
Private: true,
};
const subject = new Subject(values);
const result = subject.getEntityName();
assert.equal(result, "jane-doe");
});
it("should get subject title", () => {
const values = {
UID: "s123ghytrfggd",
Type: "person",
Src: "manual",
Name: "Jane Doe",
Slug: "jane-doe",
Favorite: false,
Excluded: true,
Private: true,
};
const subject = new Subject(values);
const result = subject.getTitle();
assert.equal(result, "Jane Doe");
});
it("should get thumbnail url", () => {
const values = {
UID: "s123ghytrfggd",
Type: "person",
Src: "manual",
Name: "Jane Doe",
Slug: "jane-doe",
Favorite: false,
Excluded: true,
Private: true,
Thumb: "nicethumb",
};
const subject = new Subject(values);
const result = subject.thumbnailUrl("xyz");
assert.equal(result, "/api/v1/t/nicethumb/public/xyz");
const result2 = subject.thumbnailUrl();
assert.equal(result2, "/api/v1/t/nicethumb/public/tile_160");
const values2 = {
UID: "s123ghytrfggd",
Type: "person",
Src: "manual",
Name: "Jane Doe",
Slug: "jane-doe",
Favorite: false,
Excluded: true,
Private: true,
};
const subject2 = new Subject(values2);
const result3 = subject2.thumbnailUrl("xyz");
assert.equal(result3, "/api/v1/svg/portrait");
});
it("should get date string", () => {
const values = {
UID: "s123ghytrfggd",
Type: "person",
Src: "manual",
Name: "Jane Doe",
Slug: "jane-doe",
Favorite: false,
Excluded: true,
Private: true,
Thumb: "nicethumb",
CreatedAt: "2012-07-08T14:45:39Z",
};
const subject = new Subject(values);
const result = subject.getDateString();
assert.equal(result, "Jul 8, 2012, 2:45 PM");
});
it("should like subject", () => {
const values = {
UID: "s123ghytrfggd",
Type: "person",
Src: "manual",
Name: "Jane Doe",
Slug: "jane-doe",
Favorite: false,
};
const subject = new Subject(values);
assert.equal(subject.Favorite, false);
subject.like();
assert.equal(subject.Favorite, true);
});
it("should unlike subject", () => {
const values = {
UID: "s123ghytrfggd",
Type: "person",
Src: "manual",
Name: "Jane Doe",
Slug: "jane-doe",
Favorite: true,
};
const subject = new Subject(values);
assert.equal(subject.Favorite, true);
subject.unlike();
assert.equal(subject.Favorite, false);
});
it("should toggle like", () => {
const values = {
UID: "s123ghytrfggd",
Type: "person",
Src: "manual",
Name: "Jane Doe",
Slug: "jane-doe",
Favorite: true,
};
const subject = new Subject(values);
assert.equal(subject.Favorite, true);
subject.toggleLike();
assert.equal(subject.Favorite, false);
subject.toggleLike();
assert.equal(subject.Favorite, true);
});
it("should return batch size", () => {
assert.equal(Subject.batchSize(), 60);
});
it("should get collection resource", () => {
const result = Subject.getCollectionResource();
assert.equal(result, "subjects");
});
it("should get model name", () => {
const result = Subject.getModelName();
assert.equal(result, "Subject");
});
});

View file

@ -24,6 +24,28 @@ describe("model/thumb", () => {
assert.equal(result.uid, "");
});
it("should get id", () => {
const values = {
uid: "55",
};
const thumb = new Thumb(values);
assert.equal(thumb.getId(), "55");
});
it("should return hasId", () => {
const values = {
uid: "55",
};
const thumb = new Thumb(values);
assert.equal(thumb.hasId(), true);
const values2 = {
title: "",
};
const thumb2 = new Thumb(values2);
assert.equal(thumb2.hasId(), false);
});
it("should toggle like", () => {
const values = {
uid: "55",
@ -125,6 +147,30 @@ describe("model/thumb", () => {
assert.equal(result2[0].description, "Nice description 2");
assert.equal(result2[0].original_w, 500);
assert.equal(result2.length, 1);
const values5 = {
ID: 8,
UID: "ABC123",
Description: "Nice description 2",
Hash: "abc345",
Files: [
{
UID: "123fgb",
Name: "1980/01/superCuteKitten.jpg",
Primary: true,
Type: "mov",
Width: 500,
Height: 600,
Hash: "1xxbgdt53",
},
],
};
const photo4 = new Photo(values5);
const Photos3 = [photo3, photo2, photo4];
const result3 = Thumb.fromFiles(Photos3);
assert.equal(result3.length, 1);
assert.equal(result3[0].uid, "ABC123");
assert.equal(result3[0].description, "Nice description 2");
assert.equal(result3[0].original_w, 500);
});
it("should test from files", () => {

34
go.mod
View file

@ -55,13 +55,12 @@ require (
github.com/studio-b12/gowebdav v0.0.0-20210917133250-a3a86976a1df
github.com/tensorflow/tensorflow v1.15.2
github.com/tidwall/gjson v1.9.1
github.com/ugorji/go v1.2.6 // indirect
github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6
github.com/urfave/cli v1.22.5
go4.org v0.0.0-20201209231011-d4a079459e60 // indirect
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d // indirect
golang.org/x/net v0.0.0-20210917221730-978cfadd31cf
golang.org/x/net v0.0.0-20210929193557-e81a3d93ecf6
golang.org/x/sys v0.0.0-20210917161153-d61c044b1678 // indirect
gonum.org/v1/gonum v0.9.3
google.golang.org/protobuf v1.27.1 // indirect
@ -69,4 +68,33 @@ require (
gopkg.in/yaml.v2 v2.4.0
)
go 1.16
require (
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20200717064901-2fccff4aa15e // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-sql-driver/mysql v1.5.0 // indirect
github.com/go-xmlfmt/xmlfmt v0.0.0-20191208150333-d5b6f63a941b // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/schema v1.2.0 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gosimple/unidecode v1.0.0 // indirect
github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/tidwall/match v1.0.3 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/ugorji/go/codec v1.2.6 // indirect
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 // indirect
golang.org/x/text v0.3.7 // indirect
google.golang.org/appengine v1.6.6 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
)
go 1.17

4
go.sum
View file

@ -464,8 +464,8 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210917221730-978cfadd31cf h1:R150MpwJIv1MpS0N/pc+NhTM8ajzvlmxlY5OYsrevXQ=
golang.org/x/net v0.0.0-20210917221730-978cfadd31cf/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210929193557-e81a3d93ecf6 h1:Z04ewVs7JhXaYkmDhBERPi41gnltfQpMWDnTnQbaCqk=
golang.org/x/net v0.0.0-20210929193557-e81a3d93ecf6/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=

View file

@ -526,10 +526,10 @@ func DownloadAlbum(router *gin.RouterGroup) {
}
log.Infof("download: added %s as %s", txt.Quote(file.FileName), txt.Quote(alias))
} else {
log.Errorf("download: file %s is missing", txt.Quote(file.FileName))
log.Errorf("download: failed finding %s", txt.Quote(file.FileName))
}
}
log.Infof("download: album zip %s created in %s", txt.Quote(zipFileName), time.Since(start))
log.Infof("download: created %s [%s]", txt.Quote(zipFileName), time.Since(start))
})
}

View file

@ -49,6 +49,12 @@ func logError(prefix string, err error) {
}
}
func logWarn(prefix string, err error) {
if err != nil {
log.Warnf("%s: %s", prefix, err.Error())
}
}
func UpdateClientConfig() {
conf := service.Config()

View file

@ -66,10 +66,10 @@ func BatchPhotosArchive(router *gin.RouterGroup) {
}
// Update precalculated photo and file counts.
logError("photos", entity.UpdatePhotoCounts())
logWarn("index", entity.UpdateCounts())
// Update album, subject, and label preview thumbs.
logError("photos", query.UpdatePreviews())
// Update album, subject, and label cover thumbs.
logWarn("index", query.UpdateCovers())
UpdateClientConfig()
@ -128,10 +128,10 @@ func BatchPhotosRestore(router *gin.RouterGroup) {
}
// Update precalculated photo and file counts.
logError("photos", entity.UpdatePhotoCounts())
logWarn("index", entity.UpdateCounts())
// Update album, subject, and label preview thumbs.
logError("photos", query.UpdatePreviews())
// Update album, subject, and label cover thumbs.
logWarn("index", query.UpdateCovers())
UpdateClientConfig()
@ -193,7 +193,7 @@ func BatchPhotosApprove(router *gin.RouterGroup) {
})
}
// BatchAlbumsDelete permanently deletes multiple albums.
// BatchAlbumsDelete permanently removes multiple albums.
//
// POST /api/v1/batch/albums/delete
func BatchAlbumsDelete(router *gin.RouterGroup) {
@ -264,7 +264,7 @@ func BatchPhotosPrivate(router *gin.RouterGroup) {
}
// Update precalculated photo and file counts.
logError("photos", entity.UpdatePhotoCounts())
logWarn("index", entity.UpdateCounts())
if photos, err := query.PhotoSelection(f); err == nil {
for _, p := range photos {
@ -328,7 +328,7 @@ func BatchLabelsDelete(router *gin.RouterGroup) {
})
}
// BatchPhotosDelete permanently deletes multiple photos from the archive.
// BatchPhotosDelete permanently removes multiple photos from the archive.
//
// POST /api/v1/batch/photos/delete
func BatchPhotosDelete(router *gin.RouterGroup) {
@ -382,7 +382,7 @@ func BatchPhotosDelete(router *gin.RouterGroup) {
// Any photos deleted?
if len(deleted) > 0 {
// Update precalculated photo and file counts.
logError("photos", entity.UpdatePhotoCounts())
logWarn("index", entity.UpdateCounts())
UpdateClientConfig()

View file

@ -46,7 +46,7 @@ func RemoveFromFolderCache(rootName string) {
cache.Delete(cacheKey)
if err := query.UpdateAlbumFolderPreviews(); err != nil {
if err := query.UpdateAlbumFolderCovers(); err != nil {
log.Error(err)
}
@ -65,7 +65,7 @@ func RemoveFromAlbumCoverCache(uid string) {
log.Debugf("removed %s from cache", cacheKey)
}
if err := query.UpdateAlbumPreviews(); err != nil {
if err := query.UpdateAlbumCovers(); err != nil {
log.Error(err)
}
}
@ -74,7 +74,7 @@ func RemoveFromAlbumCoverCache(uid string) {
func FlushCoverCache() {
service.CoverCache().Flush()
if err := query.UpdatePreviews(); err != nil {
if err := query.UpdateCovers(); err != nil {
log.Error(err)
}

View file

@ -1,7 +1,6 @@
package api
import (
"io/ioutil"
"net/http"
"os"
"path/filepath"
@ -81,7 +80,7 @@ func SaveConfigOptions(router *gin.RouterGroup) {
v := make(valueMap)
if fs.FileExists(fileName) {
yamlData, err := ioutil.ReadFile(fileName)
yamlData, err := os.ReadFile(fileName)
if err != nil {
log.Errorf("options: %s", err)
@ -118,7 +117,7 @@ func SaveConfigOptions(router *gin.RouterGroup) {
}
// Write YAML data to file.
if err := ioutil.WriteFile(fileName, yamlData, os.ModePerm); err != nil {
if err := os.WriteFile(fileName, yamlData, os.ModePerm); err != nil {
log.Errorf("options: %s", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, err)
return

View file

@ -76,7 +76,7 @@ func AlbumCover(router *gin.RouterGroup) {
f, err := query.AlbumCoverByUID(uid)
if err != nil {
log.Debugf("%s: no photos yet, using generic image for %s", albumCover, uid)
log.Debugf("%s: %s contains no photos, using generic cover", albumCover, uid)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
}
@ -84,7 +84,7 @@ func AlbumCover(router *gin.RouterGroup) {
fileName := photoprism.FileName(f.FileRoot, f.FileName)
if !fs.FileExists(fileName) {
log.Errorf("%s: could not find original for %s", albumCover, fileName)
log.Errorf("%s: found no original for %s", albumCover, fileName)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
// Set missing flag so that the file doesn't show up in search results anymore.

View file

@ -15,7 +15,7 @@ func TestAlbumCover(t *testing.T) {
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("album has no photo (because is not existing)", func(t *testing.T) {
t.Run("album contains no photos (because is not existing)", func(t *testing.T) {
app, router, conf := NewApiTest()
AlbumCover(router)
r := PerformRequest(app, "GET", "/api/v1/albums/987-986435/t/"+conf.PreviewToken()+"/tile_500")

View file

@ -85,7 +85,7 @@ func FolderCover(router *gin.RouterGroup) {
f, err := query.FolderCoverByUID(uid)
if err != nil {
log.Debugf("%s: no photos yet, using generic image for %s", folderCover, uid)
log.Debugf("%s: %s contains no photos, using generic cover", folderCover, uid)
c.Data(http.StatusOK, "image/svg+xml", folderIconSvg)
return
}

View file

@ -77,7 +77,7 @@ func StartImport(router *gin.RouterGroup) {
}
if len(f.Albums) > 0 {
log.Debugf("import: files will be added to album %s", strings.Join(f.Albums, " and "))
log.Debugf("import: adding files to album %s", strings.Join(f.Albums, " and "))
opt.Albums = f.Albums
}
@ -111,9 +111,9 @@ func StartImport(router *gin.RouterGroup) {
UpdateClientConfig()
// Update album, label, and subject preview images.
if err := query.UpdatePreviews(); err != nil {
log.Errorf("import: %s (update previews)", err)
// Update album, label, and subject cover thumbs.
if err := query.UpdateCovers(); err != nil {
log.Warnf("index: %s (update covers)", err)
}
c.JSON(http.StatusOK, i18n.Response{Code: http.StatusOK, Msg: msg})

View file

@ -4,10 +4,11 @@ import (
"fmt"
"net/http"
"github.com/photoprism/photoprism/internal/entity"
"github.com/dustin/go-humanize/english"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/i18n"
@ -26,7 +27,7 @@ func findFileMarker(c *gin.Context) (file *entity.File, marker *entity.Marker, e
// Check feature flags.
conf := service.Config()
if !conf.Settings().Features.People || !conf.Settings().Features.Edit {
if !conf.Settings().Features.People {
AbortFeatureDisabled(c)
return nil, nil, fmt.Errorf("feature disabled")
}
@ -70,27 +71,27 @@ func UpdateMarker(router *gin.RouterGroup) {
file, marker, err := findFileMarker(c)
if err != nil {
log.Debugf("api: %s (update marker)", err)
log.Debugf("marker: %s (find)", err)
return
}
markerForm, err := form.NewMarker(*marker)
if err != nil {
log.Errorf("photo: %s (new marker form)", err)
log.Errorf("marker: %s (new form)", err)
AbortSaveFailed(c)
return
}
if err := c.BindJSON(&markerForm); err != nil {
log.Errorf("photo: %s (update marker form)", err)
log.Errorf("marker: %s (update form)", err)
AbortBadRequest(c)
return
}
// Save marker.
if changed, err := marker.SaveForm(markerForm); err != nil {
log.Errorf("photo: %s (save marker form)", err)
log.Errorf("marker: %s", err)
AbortSaveFailed(c)
return
} else if changed {
@ -98,15 +99,15 @@ func UpdateMarker(router *gin.RouterGroup) {
if res, err := service.Faces().Optimize(); err != nil {
log.Errorf("faces: %s (optimize)", err)
} else if res.Merged > 0 {
log.Infof("faces: %d clusters merged", res.Merged)
log.Infof("faces: merged %s", english.Plural(res.Merged, "cluster", "clusters"))
}
}
if err := query.UpdateSubjectPreviews(); err != nil {
log.Errorf("faces: %s (update previews)", err)
if err := query.UpdateSubjectCovers(); err != nil {
log.Errorf("faces: %s (update covers)", err)
}
if err := entity.UpdateSubjectFileCounts(); err != nil {
if err := entity.UpdateSubjectCounts(); err != nil {
log.Errorf("faces: %s (update counts)", err)
}
}
@ -148,9 +149,9 @@ func ClearMarkerSubject(router *gin.RouterGroup) {
log.Errorf("faces: %s (clear subject)", err)
AbortSaveFailed(c)
return
} else if err := query.UpdateSubjectPreviews(); err != nil {
log.Errorf("faces: %s (update previews)", err)
} else if err := entity.UpdateSubjectFileCounts(); err != nil {
} else if err := query.UpdateSubjectCovers(); err != nil {
log.Errorf("faces: %s (update covers)", err)
} else if err := entity.UpdateSubjectCounts(); err != nil {
log.Errorf("faces: %s (update counts)", err)
}

View file

@ -81,11 +81,11 @@ func PhotoUnstack(router *gin.RouterGroup) {
AbortEntityNotFound(c)
return
} else if related.Len() == 0 {
log.Errorf("photo: no files found for %s (unstack)", txt.Quote(baseName))
log.Errorf("photo: found no files for %s (unstack)", txt.Quote(baseName))
AbortEntityNotFound(c)
return
} else if related.Main == nil {
log.Errorf("photo: no main file found for %s (unstack)", txt.Quote(baseName))
log.Errorf("photo: found no main file for %s (unstack)", txt.Quote(baseName))
AbortEntityNotFound(c)
return
}
@ -141,7 +141,7 @@ func PhotoUnstack(router *gin.RouterGroup) {
// Handle error...
log.Errorf("photo: %s (unstack %s)", err.Error(), txt.Quote(r.BaseName()))
// Remove new photo from database.
// Remove new photo from index.
if _, err := newPhoto.Delete(true); err != nil {
log.Errorf("photo: %s (unstack %s)", err.Error(), txt.Quote(r.BaseName()))
}

View file

@ -64,7 +64,7 @@ func SharePreview(router *gin.RouterGroup) {
var f form.PhotoSearch
// Previews may only contain public content in shared albums.
// Covers may only contain public content in shared albums.
f.Album = share
f.Public = true
f.Private = false

View file

@ -106,7 +106,8 @@ func UpdateSubject(router *gin.RouterGroup) {
}
if _, err := m.UpdateName(f.SubjName); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UcFirst(err.Error())})
log.Errorf("subject: %s", err)
AbortSaveFailed(c)
return
}

View file

@ -126,7 +126,7 @@ func CreateZip(router *gin.RouterGroup) {
elapsed := int(time.Since(start).Seconds())
log.Infof("download: zip %s created in %s", txt.Quote(zipBaseName), time.Since(start))
log.Infof("download: created %s [%s]", txt.Quote(zipBaseName), time.Since(start))
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "message": i18n.Msg(i18n.MsgZipCreatedIn, elapsed), "filename": zipBaseName})
})

View file

@ -5,7 +5,6 @@
package main
import (
"io/ioutil"
"log"
"os"
"path/filepath"
@ -35,10 +34,10 @@ func main() {
fileName := "rules.yml"
if !fs.FileExists(fileName) {
log.Panicf("classify: label rules not found in %s", txt.Quote(filepath.Base(fileName)))
log.Panicf("classify: found no label rules in %s", txt.Quote(filepath.Base(fileName)))
}
yamlConfig, err := ioutil.ReadFile(fileName)
yamlConfig, err := os.ReadFile(fileName)
if err != nil {
panic(err)

View file

@ -5,7 +5,6 @@ import (
"bytes"
"fmt"
"image"
"io/ioutil"
"math"
"os"
"path"
@ -49,7 +48,7 @@ func (t *TensorFlow) File(filename string) (result Labels, err error) {
return result, nil
}
imageBuffer, err := ioutil.ReadFile(filename)
imageBuffer, err := os.ReadFile(filename)
if err != nil {
return nil, err

View file

@ -1,7 +1,7 @@
package classify
import (
"io/ioutil"
"os"
"sync"
"testing"
@ -84,7 +84,7 @@ func TestTensorFlow_Labels(t *testing.T) {
t.Run("chameleon_lime.jpg", func(t *testing.T) {
tensorFlow := NewTest(t)
if imageBuffer, err := ioutil.ReadFile(examplesPath + "/chameleon_lime.jpg"); err != nil {
if imageBuffer, err := os.ReadFile(examplesPath + "/chameleon_lime.jpg"); err != nil {
t.Error(err)
} else {
result, err := tensorFlow.Labels(imageBuffer)
@ -108,7 +108,7 @@ func TestTensorFlow_Labels(t *testing.T) {
t.Run("dog_orange.jpg", func(t *testing.T) {
tensorFlow := NewTest(t)
if imageBuffer, err := ioutil.ReadFile(examplesPath + "/dog_orange.jpg"); err != nil {
if imageBuffer, err := os.ReadFile(examplesPath + "/dog_orange.jpg"); err != nil {
t.Error(err)
} else {
result, err := tensorFlow.Labels(imageBuffer)
@ -132,7 +132,7 @@ func TestTensorFlow_Labels(t *testing.T) {
t.Run("Random.docx", func(t *testing.T) {
tensorFlow := NewTest(t)
if imageBuffer, err := ioutil.ReadFile(examplesPath + "/Random.docx"); err != nil {
if imageBuffer, err := os.ReadFile(examplesPath + "/Random.docx"); err != nil {
t.Error(err)
} else {
result, err := tensorFlow.Labels(imageBuffer)
@ -143,7 +143,7 @@ func TestTensorFlow_Labels(t *testing.T) {
t.Run("6720px_white.jpg", func(t *testing.T) {
tensorFlow := NewTest(t)
if imageBuffer, err := ioutil.ReadFile(examplesPath + "/6720px_white.jpg"); err != nil {
if imageBuffer, err := os.ReadFile(examplesPath + "/6720px_white.jpg"); err != nil {
t.Error(err)
} else {
result, err := tensorFlow.Labels(imageBuffer)
@ -158,7 +158,7 @@ func TestTensorFlow_Labels(t *testing.T) {
t.Run("disabled true", func(t *testing.T) {
tensorFlow := New(assetsPath, true)
if imageBuffer, err := ioutil.ReadFile(examplesPath + "/dog_orange.jpg"); err != nil {
if imageBuffer, err := os.ReadFile(examplesPath + "/dog_orange.jpg"); err != nil {
t.Error(err)
} else {
result, err := tensorFlow.Labels(imageBuffer)
@ -224,7 +224,7 @@ func TestTensorFlow_MakeTensor(t *testing.T) {
t.Run("cat_brown.jpg", func(t *testing.T) {
tensorFlow := NewTest(t)
imageBuffer, err := ioutil.ReadFile(examplesPath + "/cat_brown.jpg")
imageBuffer, err := os.ReadFile(examplesPath + "/cat_brown.jpg")
if err != nil {
t.Fatal(err)
@ -238,7 +238,7 @@ func TestTensorFlow_MakeTensor(t *testing.T) {
t.Run("Random.docx", func(t *testing.T) {
tensorFlow := NewTest(t)
imageBuffer, err := ioutil.ReadFile(examplesPath + "/Random.docx")
imageBuffer, err := os.ReadFile(examplesPath + "/Random.docx")
assert.Nil(t, err)
result, err := tensorFlow.createTensor(imageBuffer, "jpeg")

View file

@ -5,7 +5,6 @@ import (
"context"
"errors"
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
@ -157,7 +156,7 @@ func backupAction(ctx *cli.Context) error {
fmt.Println(out.String())
} else {
// Write output to file.
if err := ioutil.WriteFile(indexFileName, []byte(out.String()), os.ModePerm); err != nil {
if err := os.WriteFile(indexFileName, []byte(out.String()), os.ModePerm); err != nil {
return err
}
}

View file

@ -4,6 +4,8 @@ import (
"context"
"time"
"github.com/dustin/go-humanize/english"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
@ -55,9 +57,7 @@ func cleanUpAction(ctx *cli.Context) error {
if thumbs, orphans, err := w.Start(opt); err != nil {
return err
} else {
elapsed := time.Since(start)
log.Infof("cleanup: removed %d index entries and %d orphan thumbnails in %s", orphans, thumbs, elapsed)
log.Infof("removed %s and %s in %s", english.Plural(orphans, "index entry", "index entries"), english.Plural(thumbs, "thumbnail", "thumbnails"), time.Since(start))
}
conf.Shutdown()

View file

@ -26,9 +26,12 @@ func configAction(ctx *cli.Context) error {
fmt.Printf("%-25s VALUE\n", "NAME")
// Feature flags.
// Feature flags and auth.
fmt.Printf("%-25s %t\n", "debug", conf.Debug())
fmt.Printf("%-25s %s\n", "log-level", conf.LogLevel())
fmt.Printf("%-25s %s\n", "log-filename", conf.LogFilename())
fmt.Printf("%-25s %t\n", "public", conf.Public())
fmt.Printf("%-25s %s\n", "admin-password", strings.Repeat("*", utf8.RuneCountInString(conf.AdminPassword())))
fmt.Printf("%-25s %t\n", "read-only", conf.ReadOnly())
fmt.Printf("%-25s %t\n", "experimental", conf.Experimental())
@ -84,29 +87,21 @@ func configAction(ctx *cli.Context) error {
// Site information.
fmt.Printf("%-25s %s\n", "site-url", conf.SiteUrl())
fmt.Printf("%-25s %s\n", "site-preview", conf.SitePreview())
fmt.Printf("%-25s %s\n", "site-author", conf.SiteAuthor())
fmt.Printf("%-25s %s\n", "site-title", conf.SiteTitle())
fmt.Printf("%-25s %s\n", "site-caption", conf.SiteCaption())
fmt.Printf("%-25s %s\n", "site-description", conf.SiteDescription())
fmt.Printf("%-25s %s\n", "site-author", conf.SiteAuthor())
fmt.Printf("%-25s %s\n", "cdn-url", conf.CdnUrl("/"))
fmt.Printf("%-25s %s\n", "content-uri", conf.ContentUri())
fmt.Printf("%-25s %s\n", "static-uri", conf.StaticUri())
fmt.Printf("%-25s %s\n", "api-uri", conf.ApiUri())
fmt.Printf("%-25s %s\n", "base-uri", conf.BaseUri("/"))
// Logging.
fmt.Printf("%-25s %s\n", "log-level", conf.LogLevel())
fmt.Printf("%-25s %s\n", "log-filename", conf.LogFilename())
fmt.Printf("%-25s %s\n", "pid-filename", conf.PIDFilename())
// HTTP server configuration.
fmt.Printf("%-25s %s\n", "http-host", conf.HttpHost())
fmt.Printf("%-25s %d\n", "http-port", conf.HttpPort())
fmt.Printf("%-25s %s\n", "http-mode", conf.HttpMode())
// Passwords.
fmt.Printf("%-25s %s\n", "admin-password", strings.Repeat("*", utf8.RuneCountInString(conf.AdminPassword())))
// Database configuration.
fmt.Printf("%-25s %s\n", "database-driver", dbDriver)
fmt.Printf("%-25s %s\n", "database-server", conf.DatabaseServer())
@ -147,9 +142,14 @@ func configAction(ctx *cli.Context) error {
fmt.Printf("%-25s %d\n", "face-size", conf.FaceSize())
fmt.Printf("%-25s %f\n", "face-score", conf.FaceScore())
fmt.Printf("%-25s %d\n", "face-overlap", conf.FaceOverlap())
fmt.Printf("%-25s %d\n", "face-cluster-size", conf.FaceClusterSize())
fmt.Printf("%-25s %d\n", "face-cluster-score", conf.FaceClusterScore())
fmt.Printf("%-25s %d\n", "face-cluster-core", conf.FaceClusterCore())
fmt.Printf("%-25s %f\n", "face-cluster-dist", conf.FaceClusterDist())
fmt.Printf("%-25s %f\n", "face-match-dist", conf.FaceMatchDist())
// Other.
fmt.Printf("%-25s %s\n", "pid-filename", conf.PIDFilename())
return nil
}

View file

@ -18,6 +18,11 @@ func TestConfigCommand(t *testing.T) {
err = ConfigCommand.Run(ctx)
})
if err != nil {
t.Fatal(err)
}
// Expected config command output.
assert.Contains(t, output, "NAME VALUE")
assert.Contains(t, output, "config-file")
assert.Contains(t, output, "darktable-cli")
@ -25,7 +30,4 @@ func TestConfigCommand(t *testing.T) {
assert.Contains(t, output, "import-path")
assert.Contains(t, output, "cache-path")
assert.Contains(t, output, "assets-path")
assert.Equal(t, output, output)
assert.Nil(t, err)
}

View file

@ -16,8 +16,8 @@ import (
// ConvertCommand registers the convert cli command.
var ConvertCommand = cli.Command{
Name: "convert",
Usage: "Converts originals in other formats to JPEG and AVC sidecar files",
UsageText: `To limit scope, a sub folder may be passed as first argument.`,
Usage: "Converts media files in other formats to JPEG / AVC",
ArgsUsage: "[path]",
Action: convertAction,
}

View file

@ -16,10 +16,11 @@ import (
// CopyCommand registers the copy cli command.
var CopyCommand = cli.Command{
Name: "copy",
Aliases: []string{"cp"},
Usage: "Copies files to originals folder, converts and indexes them as needed",
Action: copyAction,
Name: "cp",
Aliases: []string{"copy"},
Usage: "Copies media files to originals",
ArgsUsage: "[path]",
Action: copyAction,
}
// copyAction copies photos to originals path. Default import path is used if no path argument provided
@ -59,7 +60,7 @@ func copyAction(ctx *cli.Context) error {
}
if sourcePath == conf.OriginalsPath() {
return errors.New("import folder is identical with originals")
return errors.New("import path is identical with originals")
}
log.Infof("copying media files from %s to %s", sourcePath, conf.OriginalsPath())
@ -72,6 +73,8 @@ func copyAction(ctx *cli.Context) error {
elapsed := time.Since(start)
log.Infof("import completed in %s", elapsed)
conf.Shutdown()
return nil
}

View file

@ -6,6 +6,7 @@ import (
"strings"
"time"
"github.com/dustin/go-humanize/english"
"github.com/manifoldco/promptui"
"github.com/urfave/cli"
@ -17,10 +18,10 @@ import (
"github.com/photoprism/photoprism/pkg/txt"
)
// FacesCommand registers the faces cli command.
// FacesCommand registers the facial recognition subcommands.
var FacesCommand = cli.Command{
Name: "faces",
Usage: "Facial recognition sub-commands",
Usage: "Facial recognition subcommands",
Subcommands: []cli.Command{
{
Name: "stats",
@ -33,14 +34,14 @@ var FacesCommand = cli.Command{
Flags: []cli.Flag{
cli.BoolFlag{
Name: "fix, f",
Usage: "issues will be fixed automatically",
Usage: "fix discovered issues",
},
},
Action: facesAuditAction,
},
{
Name: "reset",
Usage: "Removes people and faces",
Usage: "Removes people and faces after confirmation",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "force, f",
@ -61,7 +62,7 @@ var FacesCommand = cli.Command{
Flags: []cli.Flag{
cli.BoolFlag{
Name: "force, f",
Usage: "update all faces",
Usage: "update all faces",
},
},
Action: facesUpdateAction,
@ -183,7 +184,7 @@ func facesResetAction(ctx *cli.Context) error {
// facesResetAllAction removes all people, faces, and face markers.
func facesResetAllAction(ctx *cli.Context) error {
actionPrompt := promptui.Prompt{
Label: "Permanently delete all people and faces?",
Label: "Permanently remove all people and faces?",
IsConfirm: true,
}
@ -259,7 +260,9 @@ func facesIndexAction(ctx *cli.Context) error {
}
indexed = w.Start(opt)
} else if w := service.Purge(); w != nil {
}
if w := service.Purge(); w != nil {
opt := photoprism.PurgeOptions{
Path: subPath,
Ignore: indexed,
@ -268,13 +271,13 @@ func facesIndexAction(ctx *cli.Context) error {
if files, photos, err := w.Start(opt); err != nil {
log.Error(err)
} else if len(files) > 0 || len(photos) > 0 {
log.Infof("purge: removed %d files and %d photos", len(files), len(photos))
log.Infof("purge: removed %s and %s", english.Plural(len(files), "file", "files"), english.Plural(len(photos), "photo", "photos"))
}
}
elapsed := time.Since(start)
log.Infof("indexed %d files in %s", len(indexed), elapsed)
log.Infof("indexed %s in %s", english.Plural(len(indexed), "file", "files"), elapsed)
conf.Shutdown()
@ -339,7 +342,7 @@ func facesOptimizeAction(ctx *cli.Context) error {
} else {
elapsed := time.Since(start)
log.Infof("%d face clusters merged in %s", res.Merged, elapsed)
log.Infof("merged %s in %s", english.Plural(res.Merged, "face cluster", "face clusters"), elapsed)
}
conf.Shutdown()

View file

@ -16,10 +16,11 @@ import (
// ImportCommand registers the import cli command.
var ImportCommand = cli.Command{
Name: "import",
Aliases: []string{"mv"},
Usage: "Moves files to originals folder, converts and indexes them as needed",
Action: importAction,
Name: "mv",
Aliases: []string{"import"},
Usage: "Moves media files to originals",
ArgsUsage: "[path]",
Action: importAction,
}
// importAction moves photos to originals path. Default import path is used if no path argument provided
@ -59,7 +60,7 @@ func importAction(ctx *cli.Context) error {
}
if sourcePath == conf.OriginalsPath() {
return errors.New("import folder is identical with originals")
return errors.New("import path is identical with originals")
}
log.Infof("moving media files from %s to %s", sourcePath, conf.OriginalsPath())
@ -72,6 +73,8 @@ func importAction(ctx *cli.Context) error {
elapsed := time.Since(start)
log.Infof("import completed in %s", elapsed)
conf.Shutdown()
return nil
}

View file

@ -6,6 +6,8 @@ import (
"strings"
"time"
"github.com/dustin/go-humanize/english"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
@ -18,7 +20,7 @@ import (
// IndexCommand registers the index cli command.
var IndexCommand = cli.Command{
Name: "index",
Usage: "indexes media files in the originals folder",
Usage: "Indexes original media files",
ArgsUsage: "[path]",
Flags: indexFlags,
Action: indexAction,
@ -75,7 +77,10 @@ func indexAction(ctx *cli.Context) error {
}
indexed = w.Start(opt)
} else if w := service.Purge(); w != nil {
}
if w := service.Purge(); w != nil {
purgeStart := time.Now()
opt := photoprism.PurgeOptions{
Path: subPath,
Ignore: indexed,
@ -84,9 +89,12 @@ func indexAction(ctx *cli.Context) error {
if files, photos, err := w.Start(opt); err != nil {
log.Error(err)
} else if len(files) > 0 || len(photos) > 0 {
log.Infof("purge: removed %d files and %d photos", len(files), len(photos))
log.Infof("purge: removed %s and %s [%s]", english.Plural(len(files), "file", "files"), english.Plural(len(photos), "photo", "photos"), time.Since(purgeStart))
}
} else if ctx.Bool("cleanup") {
}
if ctx.Bool("cleanup") {
cleanupStart := time.Now()
w := service.CleanUp()
opt := photoprism.CleanUpOptions{
@ -96,13 +104,13 @@ func indexAction(ctx *cli.Context) error {
if thumbs, orphans, err := w.Start(opt); err != nil {
return err
} else {
log.Infof("cleanup: removed %d index entries and %d orphan thumbnails", orphans, thumbs)
log.Infof("cleanup: removed %s and %s [%s]", english.Plural(orphans, "index entry", "index entries"), english.Plural(thumbs, "thumbnail", "thumbnails"), time.Since(cleanupStart))
}
}
elapsed := time.Since(start)
log.Infof("indexed %d files in %s", len(indexed), elapsed)
log.Infof("indexed %s in %s", english.Plural(len(indexed), "file", "files"), elapsed)
conf.Shutdown()

View file

@ -0,0 +1,55 @@
package commands
import (
"testing"
"time"
"github.com/leandro-lugaresi/hub"
"github.com/photoprism/photoprism/internal/event"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/capture"
)
func TestIndexCommand(t *testing.T) {
var err error
ctx := config.CliTestContext()
s := event.Subscribe("log.info")
defer event.Unsubscribe(s)
logs := ""
assert.IsType(t, hub.Subscription{}, s)
go func() {
for msg := range s.Receiver {
logs += msg.Fields["message"].(string) + "\n"
}
}()
stdout := capture.Output(func() {
err = IndexCommand.Run(ctx)
})
if err != nil {
t.Fatal(err)
}
if stdout != "" {
t.Errorf("unexpected stdout output: %s", stdout)
}
time.Sleep(time.Second)
if output := logs; output != "" {
// Expected index command output.
assert.Contains(t, output, "indexing originals")
assert.Contains(t, output, "classify: loading labels")
assert.Contains(t, output, "index: found no .ppignore file")
} else {
t.Fatal("log output missing")
}
}

Some files were not shown because too many files have changed in this diff Show more