From fe2330ebf607874df66349dd5a9cf42068933427 Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Sat, 8 Jul 2023 15:46:22 +0200 Subject: [PATCH 01/38] feat(mobile): reduce UI rebuilds (#3129) --- .../home/ui/asset_grid/immich_asset_grid.dart | 107 ++++++------------ 1 file changed, 35 insertions(+), 72 deletions(-) diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart index 21a33b51c..f9f018350 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart @@ -50,86 +50,49 @@ class ImmichAssetGrid extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - var settings = ref.watch(appSettingsServiceProvider); - - // Needs to suppress hero animations when navigating to this widget - final enableHeroAnimations = useState(false); - final transitionDuration = ModalRoute.of(context)?.transitionDuration; - + final settings = ref.watch(appSettingsServiceProvider); final perRow = useState( assetsPerRow ?? settings.getSetting(AppSettingsEnum.tilesPerRow)!, ); final scaleFactor = useState(7.0 - perRow.value); final baseScaleFactor = useState(7.0 - perRow.value); - useEffect( - () { - // Wait for transition to complete, then re-enable - if (transitionDuration == null) { - // No route transition found, maybe we opened this up first - enableHeroAnimations.value = true; - } else { - // Unfortunately, using the transition animation itself didn't - // seem to work reliably. So instead, wait until the duration of the - // animation has elapsed to re-enable the hero animations - Future.delayed(transitionDuration).then((_) { - enableHeroAnimations.value = true; - }); - } - return null; - }, - [], - ); - - Future onWillPop() async { - enableHeroAnimations.value = false; - return true; - } - Widget buildAssetGridView(RenderList renderList) { - return WillPopScope( - onWillPop: onWillPop, - child: HeroMode( - enabled: enableHeroAnimations.value, - child: RawGestureDetector( - gestures: { - CustomScaleGestureRecognizer: - GestureRecognizerFactoryWithHandlers< - CustomScaleGestureRecognizer>( - () => CustomScaleGestureRecognizer(), - (CustomScaleGestureRecognizer scale) { - scale.onStart = (details) { - baseScaleFactor.value = scaleFactor.value; - }; + return RawGestureDetector( + gestures: { + CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers< + CustomScaleGestureRecognizer>( + () => CustomScaleGestureRecognizer(), + (CustomScaleGestureRecognizer scale) { + scale.onStart = (details) { + baseScaleFactor.value = scaleFactor.value; + }; - scale.onUpdate = (details) { - scaleFactor.value = - max(min(5.0, baseScaleFactor.value * details.scale), 1.0); - if (7 - scaleFactor.value.toInt() != perRow.value) { - perRow.value = 7 - scaleFactor.value.toInt(); - } - }; - scale.onEnd = (details) {}; - }) - }, - child: ImmichAssetGridView( - onRefresh: onRefresh, - assetsPerRow: perRow.value, - listener: listener, - showStorageIndicator: showStorageIndicator ?? - settings.getSetting(AppSettingsEnum.storageIndicator), - renderList: renderList, - margin: margin, - selectionActive: selectionActive, - preselectedAssets: preselectedAssets, - canDeselect: canDeselect, - dynamicLayout: dynamicLayout ?? - settings.getSetting(AppSettingsEnum.dynamicLayout), - showMultiSelectIndicator: showMultiSelectIndicator, - visibleItemsListener: visibleItemsListener, - topWidget: topWidget, - ), - ), + scale.onUpdate = (details) { + scaleFactor.value = + max(min(5.0, baseScaleFactor.value * details.scale), 1.0); + if (7 - scaleFactor.value.toInt() != perRow.value) { + perRow.value = 7 - scaleFactor.value.toInt(); + } + }; + }) + }, + child: ImmichAssetGridView( + onRefresh: onRefresh, + assetsPerRow: perRow.value, + listener: listener, + showStorageIndicator: showStorageIndicator ?? + settings.getSetting(AppSettingsEnum.storageIndicator), + renderList: renderList, + margin: margin, + selectionActive: selectionActive, + preselectedAssets: preselectedAssets, + canDeselect: canDeselect, + dynamicLayout: dynamicLayout ?? + settings.getSetting(AppSettingsEnum.dynamicLayout), + showMultiSelectIndicator: showMultiSelectIndicator, + visibleItemsListener: visibleItemsListener, + topWidget: topWidget, ), ); } From b262bcec03e13b018db02722b88ce9c3cb2068cf Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 8 Jul 2023 10:09:34 -0500 Subject: [PATCH 02/38] [Localizely] Translations update (#3158) --- mobile/assets/i18n/cs-CZ.json | 13 ++ mobile/assets/i18n/da-DK.json | 21 ++- mobile/assets/i18n/de-DE.json | 45 +++-- mobile/assets/i18n/en-US.json | 28 +-- mobile/assets/i18n/es-ES.json | 13 ++ mobile/assets/i18n/fi-FI.json | 13 ++ mobile/assets/i18n/fr-FR.json | 13 ++ mobile/assets/i18n/it-IT.json | 103 ++++++----- mobile/assets/i18n/ja-JP.json | 311 ++++++++++++++++---------------- mobile/assets/i18n/ko-KR.json | 13 ++ mobile/assets/i18n/nb-NO.json | 177 ++++++++++--------- mobile/assets/i18n/nl-NL.json | 145 ++++++++------- mobile/assets/i18n/pl-PL.json | 13 ++ mobile/assets/i18n/ru-RU.json | 13 ++ mobile/assets/i18n/sk-SK.json | 115 ++++++------ mobile/assets/i18n/sv-SE.json | 13 ++ mobile/assets/i18n/zh-CN.json | 323 ++++++++++++++++++---------------- 17 files changed, 790 insertions(+), 582 deletions(-) diff --git a/mobile/assets/i18n/cs-CZ.json b/mobile/assets/i18n/cs-CZ.json index 441262c43..525694a7b 100644 --- a/mobile/assets/i18n/cs-CZ.json +++ b/mobile/assets/i18n/cs-CZ.json @@ -1,6 +1,8 @@ { "add_to_album_bottom_sheet_added": "Přidáno do {album}", "add_to_album_bottom_sheet_already_exists": "Již v {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", "advanced_settings_tile_subtitle": "Pokročilé uživatelské nastavení", "advanced_settings_tile_title": "Pokročilé", "advanced_settings_troubleshooting_subtitle": "Povolit dodatečné funkce pro řešení problémů", @@ -20,6 +22,7 @@ "album_viewer_appbar_share_leave": "Opustit album", "album_viewer_appbar_share_remove": "Odstranit z alba", "album_viewer_page_share_add_users": "Přidat uživatele", + "all_people_page_title": "People", "all_videos_page_title": "Videa", "archive_page_no_archived_assets": "No archived assets found", "archive_page_title": "Archív ({})", @@ -191,6 +194,15 @@ "notification_permission_list_tile_content": "Udělte oprávnění k aktivaci oznámení.", "notification_permission_list_tile_enable_button": "Povolit oznámení", "notification_permission_list_tile_title": "Povolení oznámení", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", "permission_onboarding_continue_anyway": "Přesto pokračovat", "permission_onboarding_get_started": "Začít", "permission_onboarding_go_to_settings": "Přejít do nastavení", @@ -211,6 +223,7 @@ "search_page_motion_photos": "Pohyblivé fotky", "search_page_no_objects": "Informace o objektech nejsou k dispozici", "search_page_no_places": "Informace o místě nejsou k dispozici", + "search_page_people": "People", "search_page_places": "Místa", "search_page_recently_added": "Nedávno přidané", "search_page_screenshots": "Snímky obrazovky", diff --git a/mobile/assets/i18n/da-DK.json b/mobile/assets/i18n/da-DK.json index 56d70e385..e656578b3 100644 --- a/mobile/assets/i18n/da-DK.json +++ b/mobile/assets/i18n/da-DK.json @@ -1,6 +1,8 @@ { "add_to_album_bottom_sheet_added": "Tilføjet til {album}", "add_to_album_bottom_sheet_already_exists": "Allerede i {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", "advanced_settings_tile_subtitle": "Avancerede brugerindstillinger", "advanced_settings_tile_title": "Arkivér", "advanced_settings_troubleshooting_subtitle": "Slå ekstra funktioner for fejlsøgning til", @@ -20,11 +22,12 @@ "album_viewer_appbar_share_leave": "Forlad album", "album_viewer_appbar_share_remove": "Fjern fra album", "album_viewer_page_share_add_users": "Tilføj brugere", + "all_people_page_title": "People", "all_videos_page_title": "Videoer", - "archive_page_no_archived_assets": "No archived assets found", + "archive_page_no_archived_assets": "Ingen arkiverede elementer blev fundet", "archive_page_title": "Arkivér ({})", "asset_list_layout_settings_dynamic_layout_title": "Dynamisk layout", - "asset_list_layout_settings_group_automatically": "Automatic", + "asset_list_layout_settings_group_automatically": "Automatisk", "asset_list_layout_settings_group_by": "Gruppér elementer pr. ", "asset_list_layout_settings_group_by_month": "Måned", "asset_list_layout_settings_group_by_month_day": "Måned + dag", @@ -120,7 +123,7 @@ "control_bottom_app_bar_delete": "Slet", "control_bottom_app_bar_favorite": "Favorit", "control_bottom_app_bar_share": "Del", - "control_bottom_app_bar_unarchive": "Unarchive", + "control_bottom_app_bar_unarchive": "Afakivér", "create_album_page_untitled": "Uden titel", "create_shared_album_page_create": "Opret", "create_shared_album_page_share": "Del", @@ -144,7 +147,7 @@ "experimental_settings_new_asset_list_title": "Aktiver eksperimentelt fotogitter", "experimental_settings_subtitle": "Brug på eget ansvar!", "experimental_settings_title": "Eksperimentelle", - "favorites_page_no_favorites": "No favorite assets found", + "favorites_page_no_favorites": "Ingen favoritter blev fundet", "favorites_page_title": "Favoritter", "home_page_add_to_album_conflicts": "Tilføjede {added} elementer til album {album}. {failed} elementer er allerede i albummet.", "home_page_add_to_album_err_local": "Kan endnu ikke tilføje lokale elementer til album. Springer over..", @@ -191,6 +194,15 @@ "notification_permission_list_tile_content": "Tillad at bruge notifikationer.", "notification_permission_list_tile_enable_button": "Slå notifikationer til", "notification_permission_list_tile_title": "Notifikationstilladelser", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", "permission_onboarding_continue_anyway": "Fortsæt alligevel", "permission_onboarding_get_started": "Kom i gang", "permission_onboarding_go_to_settings": "Gå til indstillinger", @@ -211,6 +223,7 @@ "search_page_motion_photos": "Bevægelsesbilleder", "search_page_no_objects": "Ingen elementer er tilgængelige", "search_page_no_places": "Ingen placeringsinformation er tilgængelig", + "search_page_people": "People", "search_page_places": "Steder", "search_page_recently_added": "Nyligt tilføjet", "search_page_screenshots": "Skærmbilleder", diff --git a/mobile/assets/i18n/de-DE.json b/mobile/assets/i18n/de-DE.json index db62631cb..f506712a0 100644 --- a/mobile/assets/i18n/de-DE.json +++ b/mobile/assets/i18n/de-DE.json @@ -1,6 +1,8 @@ { "add_to_album_bottom_sheet_added": "Zu {album} hinzugefügt", "add_to_album_bottom_sheet_already_exists": "Bereits in {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", "advanced_settings_tile_subtitle": "Erweiterte Benutzereinstellungen", "advanced_settings_tile_title": "Sonstige", "advanced_settings_troubleshooting_subtitle": "Aktiviere erweiterte Funktionen zur Fehlersuche", @@ -20,11 +22,12 @@ "album_viewer_appbar_share_leave": "Album verlassen", "album_viewer_appbar_share_remove": "Entferne vom Album", "album_viewer_page_share_add_users": "Nutzer hinzufügen", + "all_people_page_title": "People", "all_videos_page_title": "Videos", - "archive_page_no_archived_assets": "No archived assets found", + "archive_page_no_archived_assets": "Keine archivierten Elemente gefunden", "archive_page_title": "Archive ({})", "asset_list_layout_settings_dynamic_layout_title": "Dynamisches Layout", - "asset_list_layout_settings_group_automatically": "Automatic", + "asset_list_layout_settings_group_automatically": "Automatisch", "asset_list_layout_settings_group_by": "Gruppiere Elemente nach", "asset_list_layout_settings_group_by_month": "Monat", "asset_list_layout_settings_group_by_month_day": "Monat + Tag", @@ -37,7 +40,7 @@ "backup_album_selection_page_selection_info": "Auswahl", "backup_album_selection_page_total_assets": "Elemente", "backup_all": "Alle", - "backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…", + "backup_background_service_backup_failed_message": "Fehler beim Sichern von Elementen. Probiere erneut...", "backup_background_service_connection_failed_message": "Konnte keine Verbindung zum Server herstellen. Neuer Versuch...", "backup_background_service_current_upload_notification": "Lädt {} hoch", "backup_background_service_default_notification": "Suche nach neuen Elementen…", @@ -53,13 +56,13 @@ "backup_controller_page_background_battery_info_ok": "OK", "backup_controller_page_background_battery_info_title": "Batterieoptimierungen", "backup_controller_page_background_charging": "Nur während des Ladens", - "backup_controller_page_background_configure_error": "Failed to configure the background service", + "backup_controller_page_background_configure_error": "Konnte Hintergrundservice nicht konfigurieren", "backup_controller_page_background_delay": "Delay new assets backup: {}", "backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app", - "backup_controller_page_background_is_off": "Automatic background backup is off", - "backup_controller_page_background_is_on": "Automatic background backup is on", - "backup_controller_page_background_turn_off": "Turn off background service", - "backup_controller_page_background_turn_on": "Turn on background service", + "backup_controller_page_background_is_off": "Automatische Sicherung im Hintergrund ist deaktiviert", + "backup_controller_page_background_is_on": "Automatische Sicherung im Hintergrund ist aktiviert", + "backup_controller_page_background_turn_off": "Hintergrundservice ausschalten", + "backup_controller_page_background_turn_on": "Hintergrundservice einschalten", "backup_controller_page_background_wifi": "Nur im WLAN", "backup_controller_page_backup": "Sicherung", "backup_controller_page_backup_selected": "Ausgewählt: ", @@ -92,15 +95,15 @@ "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", "cache_settings_clear_cache_button": "Zwischenspeicher löschen", "cache_settings_clear_cache_button_title": "Löscht den Zwischenspeicher der App. Dies wird die Leistungsfähigkeit der App deutlich einschränken, bis der Zwischenspeicher wieder aufgebaut wurde.", - "cache_settings_image_cache_size": "Image cache size ({} assets)", + "cache_settings_image_cache_size": "{} Bilder im Zwischenspeicher", "cache_settings_statistics_album": "Library thumbnails", "cache_settings_statistics_assets": "{} assets ({})", "cache_settings_statistics_full": "Full images", "cache_settings_statistics_shared": "Shared album thumbnails", "cache_settings_statistics_thumbnail": "Vorschaubilder", "cache_settings_statistics_title": "Zwischenspeicher Nutzung", - "cache_settings_subtitle": "Control the caching behaviour of the Immich mobile application", - "cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)", + "cache_settings_subtitle": "Kontrolliere wie Immich den Zwischenspeicher nutzen soll", + "cache_settings_thumbnail_size": "{} Vorschaubilder im Zwischenspeicher", "cache_settings_title": "Zwischenspeicher Einstellungen", "change_password_form_confirm_password": "Passwort bestätigen", "change_password_form_description": "Hallo {firstName} {lastName}\n\nDas ist entweder das erste Mal dass du dich einloggst oder eine Anfrage zur Änderung deines Passwortes wurde gestellt. Bitte gebe das neue Passwort ein.", @@ -120,7 +123,7 @@ "control_bottom_app_bar_delete": "Löschen", "control_bottom_app_bar_favorite": "Favorit", "control_bottom_app_bar_share": "Teilen", - "control_bottom_app_bar_unarchive": "Unarchive", + "control_bottom_app_bar_unarchive": "Dearchivieren", "create_album_page_untitled": "Unbenannt", "create_shared_album_page_create": "Erstellen", "create_shared_album_page_share": "Teilen", @@ -144,7 +147,7 @@ "experimental_settings_new_asset_list_title": "Experimentelle Fotogitter aktivieren", "experimental_settings_subtitle": "Benutzung auf eigene Gefahr!", "experimental_settings_title": "Experimentell", - "favorites_page_no_favorites": "No favorite assets found", + "favorites_page_no_favorites": "Keine favorisierten Elemente gefunden", "favorites_page_title": "Favoriten", "home_page_add_to_album_conflicts": "{added} Elemente zu {album} hinzugefügt. {failed} Elemente sind bereits vorhanden.", "home_page_add_to_album_err_local": "Kann lokale Elemente noch nicht zu Alben hinzufügen, überspringe", @@ -191,6 +194,15 @@ "notification_permission_list_tile_content": "Erlaube Berechtigung für Benachrichtigungen", "notification_permission_list_tile_enable_button": "Aktiviere Benachrichtigungen", "notification_permission_list_tile_title": "Benachrichtigungs-Berechtigung", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", "permission_onboarding_continue_anyway": "Trotzdem fortfahren", "permission_onboarding_get_started": "Get started", "permission_onboarding_go_to_settings": "Gehe zu Einstellungen", @@ -211,6 +223,7 @@ "search_page_motion_photos": "Live Photos", "search_page_no_objects": "Keine Objektinformationen verfügbar", "search_page_no_places": "Keine Informationen über Orte verfügbar", + "search_page_people": "People", "search_page_places": "Orte", "search_page_recently_added": "Zuletzt hinzugefügt", "search_page_screenshots": "Bildschirmfotos", @@ -220,14 +233,14 @@ "search_page_view_all_button": "Alle anzeigen", "search_page_your_activity": "Deine Aktivität", "search_result_page_new_search_hint": "Neue Suche", - "search_suggestion_list_smart_search_hint_1": "Intelligente Suche ist standardmäßig aktiviert; um nach Metadaten zu suchen Syntax benutzen", + "search_suggestion_list_smart_search_hint_1": "Intelligente Suche ist standardmäßig aktiviert; um nach Metadaten zu suchen, folgenden Syntax benutzen: ", "search_suggestion_list_smart_search_hint_2": "m:dein-suchbegriff", "select_additional_user_for_sharing_page_suggestions": "Vorschläge", "select_user_for_sharing_page_err_album": "Album konnte nicht erstellt werden", "select_user_for_sharing_page_share_suggestions": "Suggestions", "server_info_box_app_version": "App Version", "server_info_box_server_version": "Server Version", - "setting_image_viewer_help": "Der Detailviewer lädt zuerst die kleine Miniaturansicht, dann die Vorschau in mittlerer Größe (falls aktiviert) und schließlich das Original (falls aktiviert).", + "setting_image_viewer_help": "Der Detailbildbetrachter lädt zuerst die kleine Miniaturansicht, dann die Vorschau in mittlerer Größe (falls aktiviert) und schließlich das Original (falls aktiviert).", "setting_image_viewer_original_subtitle": "Aktivieren, um das Originalbild in voller Auflösung (groß!) zu laden. Deaktivieren, um den Datenverbrauch zu reduzieren (sowohl im Netzwerk als auch im Gerätespeicher).", "setting_image_viewer_original_title": "Original laden", "setting_image_viewer_preview_subtitle": "Aktivieren, um ein Bild mit mittlerer Auflösung zu laden. Deaktivieren, um entweder das Original direkt zu laden oder nur die Miniaturansicht zu verwenden.", @@ -240,7 +253,7 @@ "setting_notifications_notify_seconds": "{} Sekunden", "setting_notifications_single_progress_subtitle": "Detaillierte Upload Informationen für jedes Element.", "setting_notifications_single_progress_title": "Zeige Hintergrund-Sicherungs Detailfortschritt", - "setting_notifications_subtitle": "Adjust your notification preferences", + "setting_notifications_subtitle": "Passe Deine Benachrichtigungen an", "setting_notifications_title": "Benachrichtigungen", "setting_notifications_total_progress_subtitle": "Gesamter Upload-Fortschritt (abgeschlossen/Anzahl Elemente)", "setting_notifications_total_progress_title": "Zeige Hintergrundsicherungsfortschritt", diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 3158f79ba..47e60789c 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -1,12 +1,12 @@ { "add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", "advanced_settings_tile_subtitle": "Advanced user's settings", "advanced_settings_tile_title": "Advanced", "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", "advanced_settings_troubleshooting_title": "Troubleshooting", - "advanced_settings_prefer_remote_title": "Prefer remote images", - "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", "album_info_card_backup_album_excluded": "EXCLUDED", "album_info_card_backup_album_included": "INCLUDED", "album_thumbnail_card_item": "1 item", @@ -22,6 +22,7 @@ "album_viewer_appbar_share_leave": "Leave album", "album_viewer_appbar_share_remove": "Remove from album", "album_viewer_page_share_add_users": "Add users", + "all_people_page_title": "People", "all_videos_page_title": "Videos", "archive_page_no_archived_assets": "No archived assets found", "archive_page_title": "Archive ({})", @@ -193,6 +194,15 @@ "notification_permission_list_tile_content": "Grant permission to enable notifications.", "notification_permission_list_tile_enable_button": "Enable Notifications", "notification_permission_list_tile_title": "Notification Permission", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", "permission_onboarding_continue_anyway": "Continue anyway", "permission_onboarding_get_started": "Get started", "permission_onboarding_go_to_settings": "Go to settings", @@ -213,13 +223,13 @@ "search_page_motion_photos": "Motion Photos", "search_page_no_objects": "No Objects Info Available", "search_page_no_places": "No Places Info Available", + "search_page_people": "People", "search_page_places": "Places", "search_page_recently_added": "Recently added", "search_page_screenshots": "Screenshots", "search_page_selfies": "Selfies", "search_page_things": "Things", "search_page_videos": "Videos", - "search_page_people": "People", "search_page_view_all_button": "View all", "search_page_your_activity": "Your activity", "search_result_page_new_search_hint": "New Search", @@ -260,15 +270,6 @@ "sharing_page_empty_list": "EMPTY LIST", "sharing_silver_appbar_create_shared_album": "Create shared album", "sharing_silver_appbar_share_partner": "Share with partner", - "partner_page_title": "Partner", - "partner_page_no_more_users": "No more users to add", - "partner_page_empty_message": "Your photos are not yet shared with any partner.", - "partner_page_shared_to_title": "Shared to", - "partner_page_select_partner": "Select partner", - "partner_page_add_partner": "Add partner", - "partner_page_partner_add_failed": "Failed to add partner", - "partner_page_stop_sharing_title": "Stop sharing your photos?", - "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", "tab_controller_nav_library": "Library", "tab_controller_nav_photos": "Photos", "tab_controller_nav_search": "Search", @@ -288,6 +289,5 @@ "version_announcement_overlay_text_1": "Hi friend, there is a new release of", "version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", - "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", - "all_people_page_title": "People" + "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89" } \ No newline at end of file diff --git a/mobile/assets/i18n/es-ES.json b/mobile/assets/i18n/es-ES.json index 6c4c9d690..229f1aa64 100644 --- a/mobile/assets/i18n/es-ES.json +++ b/mobile/assets/i18n/es-ES.json @@ -1,6 +1,8 @@ { "add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", "advanced_settings_tile_subtitle": "Advanced user's settings", "advanced_settings_tile_title": "Advanced", "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", @@ -20,6 +22,7 @@ "album_viewer_appbar_share_leave": "Abandonar álbum ", "album_viewer_appbar_share_remove": "Eliminar del álbum ", "album_viewer_page_share_add_users": "Añadir usuarios", + "all_people_page_title": "People", "all_videos_page_title": "Videos", "archive_page_no_archived_assets": "No archived assets found", "archive_page_title": "Archive ({})", @@ -191,6 +194,15 @@ "notification_permission_list_tile_content": "Grant permission to enable notifications.", "notification_permission_list_tile_enable_button": "Enable Notifications", "notification_permission_list_tile_title": "Notification Permission", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", "permission_onboarding_continue_anyway": "Continue anyway", "permission_onboarding_get_started": "Get started", "permission_onboarding_go_to_settings": "Go to settings", @@ -211,6 +223,7 @@ "search_page_motion_photos": "Motion Photos", "search_page_no_objects": "No Objects Info Available", "search_page_no_places": "No hay información de lugares disponibles", + "search_page_people": "People", "search_page_places": "Lugares", "search_page_recently_added": "Recently added", "search_page_screenshots": "Screenshots", diff --git a/mobile/assets/i18n/fi-FI.json b/mobile/assets/i18n/fi-FI.json index d7a2a3f4e..79cbe7b7a 100644 --- a/mobile/assets/i18n/fi-FI.json +++ b/mobile/assets/i18n/fi-FI.json @@ -1,6 +1,8 @@ { "add_to_album_bottom_sheet_added": "Lisätty albumiin {album}", "add_to_album_bottom_sheet_already_exists": "Kohde on jo albumissa {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", "advanced_settings_tile_subtitle": "Edistyneen käyttäjän asetukset", "advanced_settings_tile_title": "Edistyneet", "advanced_settings_troubleshooting_subtitle": "Kytke vianetsinnän lisäominaisuudet päälle", @@ -20,6 +22,7 @@ "album_viewer_appbar_share_leave": "Poistu albumista", "album_viewer_appbar_share_remove": "Poista albumista", "album_viewer_page_share_add_users": "Lisää käyttäjiä", + "all_people_page_title": "People", "all_videos_page_title": "Videot", "archive_page_no_archived_assets": "No archived assets found", "archive_page_title": "Arkisto ({})", @@ -191,6 +194,15 @@ "notification_permission_list_tile_content": "Myönnä käyttöoikeus ottaaksesi ilmoitukset käyttöön.", "notification_permission_list_tile_enable_button": "Ota ilmoitukset käyttöön", "notification_permission_list_tile_title": "Ilmoitusten käyttöoikeus", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", "permission_onboarding_continue_anyway": "Jatka silti", "permission_onboarding_get_started": "Aloittaminen", "permission_onboarding_go_to_settings": "Siirry asetuksiin", @@ -211,6 +223,7 @@ "search_page_motion_photos": "Liikekuvat", "search_page_no_objects": "Objektitietoja ei ole saatavilla", "search_page_no_places": "Paikkatietoja ei ole saatavilla", + "search_page_people": "People", "search_page_places": "Paikat", "search_page_recently_added": "Viimeksi lisätyt", "search_page_screenshots": "Näyttökuvat", diff --git a/mobile/assets/i18n/fr-FR.json b/mobile/assets/i18n/fr-FR.json index b36f80cfd..62dd69e65 100644 --- a/mobile/assets/i18n/fr-FR.json +++ b/mobile/assets/i18n/fr-FR.json @@ -1,6 +1,8 @@ { "add_to_album_bottom_sheet_added": "Ajouté à {album}", "add_to_album_bottom_sheet_already_exists": "Déjà dans {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", "advanced_settings_tile_subtitle": "Advanced user's settings", "advanced_settings_tile_title": "Advanced", "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", @@ -20,6 +22,7 @@ "album_viewer_appbar_share_leave": "Quitter l'album", "album_viewer_appbar_share_remove": "Retirer de l'album", "album_viewer_page_share_add_users": "Ajouter des utilisateurs", + "all_people_page_title": "People", "all_videos_page_title": "Videos", "archive_page_no_archived_assets": "No archived assets found", "archive_page_title": "Archive ({})", @@ -191,6 +194,15 @@ "notification_permission_list_tile_content": "Accordez la permission d'activer les notifications.", "notification_permission_list_tile_enable_button": "Activer les notifications", "notification_permission_list_tile_title": "Permission de notification", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", "permission_onboarding_continue_anyway": "Continue anyway", "permission_onboarding_get_started": "Get started", "permission_onboarding_go_to_settings": "Go to settings", @@ -211,6 +223,7 @@ "search_page_motion_photos": "Motion Photos", "search_page_no_objects": "Aucune information disponible sur les objets", "search_page_no_places": "Aucune information disponible sur la localisation", + "search_page_people": "People", "search_page_places": "Lieux", "search_page_recently_added": "Recently added", "search_page_screenshots": "Screenshots", diff --git a/mobile/assets/i18n/it-IT.json b/mobile/assets/i18n/it-IT.json index ecbc007a3..8d737f4a4 100644 --- a/mobile/assets/i18n/it-IT.json +++ b/mobile/assets/i18n/it-IT.json @@ -1,17 +1,19 @@ { "add_to_album_bottom_sheet_added": "Aggiunto in {album}", "add_to_album_bottom_sheet_already_exists": "Già presente in {album}", - "advanced_settings_tile_subtitle": "Advanced user's settings", - "advanced_settings_tile_title": "Advanced", - "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", - "advanced_settings_troubleshooting_title": "Troubleshooting", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_tile_subtitle": "Impostazioni aggiuntive utenti", + "advanced_settings_tile_title": "Avanzato", + "advanced_settings_troubleshooting_subtitle": "Attiva funzioni addizionali per la risoluzione dei problemi", + "advanced_settings_troubleshooting_title": "Risoluzione problemi", "album_info_card_backup_album_excluded": "ESCLUSI", "album_info_card_backup_album_included": "INCLUSI", "album_thumbnail_card_item": "1 elemento ", "album_thumbnail_card_items": "{} elementi", "album_thumbnail_card_shared": "Condiviso", - "album_thumbnail_owned": "Owned", - "album_thumbnail_shared_by": "Shared by {}", + "album_thumbnail_owned": "Posseduto", + "album_thumbnail_shared_by": "Condiviso da {}", "album_viewer_appbar_share_delete": "Elimina album ", "album_viewer_appbar_share_err_delete": "Impossibile cancellare l'album ", "album_viewer_appbar_share_err_leave": "Impossibile lasciare l'album ", @@ -20,11 +22,12 @@ "album_viewer_appbar_share_leave": "Lascia album", "album_viewer_appbar_share_remove": "Rimuovere dall'album ", "album_viewer_page_share_add_users": "Aggiungi utenti", - "all_videos_page_title": "Videos", - "archive_page_no_archived_assets": "No archived assets found", - "archive_page_title": "Archive ({})", + "all_people_page_title": "People", + "all_videos_page_title": "Video", + "archive_page_no_archived_assets": "Nessuna oggetto archiviato", + "archive_page_title": "Archivia ({})", "asset_list_layout_settings_dynamic_layout_title": "Layout dinamico", - "asset_list_layout_settings_group_automatically": "Automatic", + "asset_list_layout_settings_group_automatically": "Automatico", "asset_list_layout_settings_group_by": "Raggruppa le immagini per", "asset_list_layout_settings_group_by_month": "Mese", "asset_list_layout_settings_group_by_month_day": "Mese + giorno", @@ -110,24 +113,24 @@ "common_add_to_album": "Aggiungi all'album", "common_change_password": "Cambia Password", "common_create_new_album": "Crea nuovo Album", - "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", + "common_server_error": "Si prega di controllare la connessione network, che il server sia raggiungibile e che le versione del server e app sono gli stessi", "common_shared": "Condivisi", "control_bottom_app_bar_add_to_album": "Aggiungi all'album", "control_bottom_app_bar_album_info": "{} elementi", "control_bottom_app_bar_album_info_shared": "{} elementi · Condivisi", - "control_bottom_app_bar_archive": "Archive", + "control_bottom_app_bar_archive": "Archivia", "control_bottom_app_bar_create_new_album": "Crea nuovo album", "control_bottom_app_bar_delete": "Elimina", "control_bottom_app_bar_favorite": "Preferiti", "control_bottom_app_bar_share": "Condividi", - "control_bottom_app_bar_unarchive": "Unarchive", + "control_bottom_app_bar_unarchive": "Rimuovi dagli archivi", "create_album_page_untitled": "Senza titolo", "create_shared_album_page_create": "Crea", "create_shared_album_page_share": "Condividi", "create_shared_album_page_share_add_assets": "AGGIUNGI OGGETTI", "create_shared_album_page_share_select_photos": "Seleziona foto", - "curated_location_page_title": "Places", - "curated_object_page_title": "Things", + "curated_location_page_title": "Location", + "curated_object_page_title": "Oggetti", "daily_title_text_date": "E, dd MMM", "daily_title_text_date_year": "E, dd MMM, yyyy", "date_format": "E, d LLL, y • hh:mm", @@ -135,8 +138,8 @@ "delete_dialog_cancel": "Annulla", "delete_dialog_ok": "Elimina", "delete_dialog_title": "Cancella definitivamente", - "description_input_hint_text": "Add description...", - "description_input_submit_error": "Error updating description, check the log for more details", + "description_input_hint_text": "Aggiungi descrizione...", + "description_input_submit_error": "Errore modificare descrizione, controlli I log per maggiori dettagli", "exif_bottom_sheet_description": "Aggiungi una descrizione...", "exif_bottom_sheet_details": "DETTAGLI", "exif_bottom_sheet_location": "POSIZIONE", @@ -144,26 +147,26 @@ "experimental_settings_new_asset_list_title": "Attiva griglia di foto sperimentale", "experimental_settings_subtitle": "Usalo a tuo rischio!", "experimental_settings_title": "Sperimentale", - "favorites_page_no_favorites": "No favorite assets found", + "favorites_page_no_favorites": "Nessun preferito", "favorites_page_title": "Preferiti", "home_page_add_to_album_conflicts": "Aggiunti {added} elementi all'album {album}. {failed} elementi erano già presenti nell'album.", "home_page_add_to_album_err_local": "Non puoi aggiungere negli album foto ancora non caricate", "home_page_add_to_album_success": "Aggiunti {added} elementi all'album {album}", - "home_page_archive_err_local": "Can not archive local assets yet, skipping", + "home_page_archive_err_local": "Non puoi archiviare immagini non ancora caricate", "home_page_building_timeline": "Costruendo il Timeline", "home_page_favorite_err_local": "Non puoi aggiungere tra i preferiti le foto ancora non caricate", "home_page_first_time_notice": "Se è la prima volta che usi l'app, assicurati di scegliere gli album per avere il Timeline con immagini e video", "image_viewer_page_state_provider_download_error": "Errore nel Download", "image_viewer_page_state_provider_download_success": "Download con successo", "library_page_albums": "Album", - "library_page_archive": "Archive", - "library_page_device_albums": "Albums on Device", + "library_page_archive": "Archivia", + "library_page_device_albums": "Album sul dispositivo", "library_page_favorites": "Preferiti", "library_page_new_album": "Nuovo Album", "library_page_sharing": "Condividendo", "library_page_sort_created": "Creato il più recente", "library_page_sort_title": "Titolo album", - "login_form_api_exception": "API exception. Please check the server URL and try again.", + "login_form_api_exception": "API error, per favore ricontrolli URL del server e riprovi", "login_form_button_text": "Login", "login_form_email_hint": "tuaemail@email.com", "login_form_endpoint_hint": "http://ip-del-tuo-server:port/api", @@ -178,49 +181,59 @@ "login_form_failed_login": "Errore nel login, controlla URL del server e le credenziali (email e password)", "login_form_label_email": "Email", "login_form_label_password": "Password", - "login_form_next_button": "Next", + "login_form_next_button": "Prossimo", "login_form_password_hint": "password ", "login_form_save_login": "Rimani connesso ", - "login_form_server_empty": "Enter a server URL.", - "login_form_server_error": "Could not connect to server.", + "login_form_server_empty": "Inserisci URL del server", + "login_form_server_error": "Non è possibile connettersi al server", "monthly_title_text_date_format": "MMMM y", - "motion_photos_page_title": "Motion Photos", + "motion_photos_page_title": "Motion Foto", "notification_permission_dialog_cancel": "Annulla", "notification_permission_dialog_content": "Per attivare le notifiche, vai alle Impostazioni e seleziona concedi", "notification_permission_dialog_settings": "Impostazioni", "notification_permission_list_tile_content": "Concedi i permessi per attivare le notifiche", "notification_permission_list_tile_enable_button": "Attiva notifiche", "notification_permission_list_tile_title": "Permessi delle Notifiche", - "permission_onboarding_continue_anyway": "Continue anyway", - "permission_onboarding_get_started": "Get started", - "permission_onboarding_go_to_settings": "Go to settings", - "permission_onboarding_grant_permission": "Grant permission", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", + "permission_onboarding_continue_anyway": "Continua lo stesso", + "permission_onboarding_get_started": "Inizia", + "permission_onboarding_go_to_settings": "Vai a Impostazioni", + "permission_onboarding_grant_permission": "Concedi i permessi", "permission_onboarding_log_out": "Log out", - "permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.", - "permission_onboarding_permission_granted": "Permission granted! You are all set.", - "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", - "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "permission_onboarding_permission_denied": "Permessi negati. Per usare Immich concedi i permessi ai video e foto dalle impostazioni", + "permission_onboarding_permission_granted": "Concessi i permessi! Ora sei tutto apposto", + "permission_onboarding_permission_limited": "Permessi limitati. Perché Immich possa controllare e fare i backup di tutte le foto, concedere i permessi all'intera galleria dalle impostazioni ", + "permission_onboarding_request": "Immich richiede i permessi per vedere le tue foto e video", "profile_drawer_app_logs": "Logs", "profile_drawer_client_server_up_to_date": "Client e server sono aggiornati", "profile_drawer_settings": "Impostazioni ", "profile_drawer_sign_out": "Logout", - "recently_added_page_title": "Recently Added", + "recently_added_page_title": "Aggiunti di recente", "search_bar_hint": "Cerca le tue foto", - "search_page_categories": "Categories", - "search_page_favorites": "Favorites", - "search_page_motion_photos": "Motion Photos", + "search_page_categories": "Categoria", + "search_page_favorites": "Preferiti", + "search_page_motion_photos": "Motion Foto", "search_page_no_objects": "Nessuna informazione relativa all'oggetto disponibile", "search_page_no_places": "Nessun informazione sul luogo disponibile", + "search_page_people": "People", "search_page_places": "Luoghi", - "search_page_recently_added": "Recently added", - "search_page_screenshots": "Screenshots", - "search_page_selfies": "Selfies", + "search_page_recently_added": "Aggiunte di recente", + "search_page_screenshots": "Screenshot", + "search_page_selfies": "Selfie", "search_page_things": "Oggetti", - "search_page_videos": "Videos", - "search_page_view_all_button": "View all", - "search_page_your_activity": "Your activity", + "search_page_videos": "Video", + "search_page_view_all_button": "Guarda tutto", + "search_page_your_activity": "Tua attività ", "search_result_page_new_search_hint": "Nuova ricerca ", - "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", + "search_suggestion_list_smart_search_hint_1": "\nRicerca Smart è attiva di default, per usare la ricerca con i metadata usare la seguente sintassi", "search_suggestion_list_smart_search_hint_2": "m:your-search-term", "select_additional_user_for_sharing_page_suggestions": "Suggerimenti ", "select_user_for_sharing_page_err_album": "Impossibile nel creare l'album ", diff --git a/mobile/assets/i18n/ja-JP.json b/mobile/assets/i18n/ja-JP.json index e81a99c02..78c1f6258 100644 --- a/mobile/assets/i18n/ja-JP.json +++ b/mobile/assets/i18n/ja-JP.json @@ -1,97 +1,100 @@ { - "add_to_album_bottom_sheet_added": "{album}に追加しました", - "add_to_album_bottom_sheet_already_exists": "{album}にもう存在してます", - "advanced_settings_tile_subtitle": "Advanced user's settings", - "advanced_settings_tile_title": "Advanced", - "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", - "advanced_settings_troubleshooting_title": "Troubleshooting", + "add_to_album_bottom_sheet_added": "{album}に追加", + "add_to_album_bottom_sheet_already_exists": "{album}に追加済み", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_tile_subtitle": "追加ユーザー設定", + "advanced_settings_tile_title": "詳細設定", + "advanced_settings_troubleshooting_subtitle": "トラブルシューティング用の詳細設定をオンにする", + "advanced_settings_troubleshooting_title": "トラブルシューティング", "album_info_card_backup_album_excluded": "除外中", "album_info_card_backup_album_included": "選択中", - "album_thumbnail_card_item": "項目数: 1", - "album_thumbnail_card_items": "項目数: {}", + "album_thumbnail_card_item": "1枚", + "album_thumbnail_card_items": "{}枚", "album_thumbnail_card_shared": "共有済み", - "album_thumbnail_owned": "Owned", - "album_thumbnail_shared_by": "Shared by {}", + "album_thumbnail_owned": "所有中", + "album_thumbnail_shared_by": "{}が共有中", "album_viewer_appbar_share_delete": "アルバムを削除", - "album_viewer_appbar_share_err_delete": "削除に失敗...", - "album_viewer_appbar_share_err_leave": "退会に失敗...", - "album_viewer_appbar_share_err_remove": "アルバムから写真を除外する際にエラー発生", - "album_viewer_appbar_share_err_title": "タイトルの変更に失敗...", - "album_viewer_appbar_share_leave": "アルバムから退会", - "album_viewer_appbar_share_remove": "アルバムから除外", + "album_viewer_appbar_share_err_delete": "削除失敗", + "album_viewer_appbar_share_err_leave": "脱退失敗", + "album_viewer_appbar_share_err_remove": "アルバムから写真を削除する際にエラー発生", + "album_viewer_appbar_share_err_title": "タイトル変更の失敗", + "album_viewer_appbar_share_leave": "アルバムから脱退", + "album_viewer_appbar_share_remove": "アルバムから削除", "album_viewer_page_share_add_users": "ユーザーを追加", - "all_videos_page_title": "Videos", - "archive_page_no_archived_assets": "No archived assets found", - "archive_page_title": "Archive ({})", + "all_people_page_title": "People", + "all_videos_page_title": "ビデオ", + "archive_page_no_archived_assets": "アーカイブ済みの写真またはビデオがありません", + "archive_page_title": "アーカイブ({})", "asset_list_layout_settings_dynamic_layout_title": "ダイナミックレイアウト", - "asset_list_layout_settings_group_automatically": "Automatic", - "asset_list_layout_settings_group_by": "写真をグループ分けする方法:", + "asset_list_layout_settings_group_automatically": "自動", + "asset_list_layout_settings_group_by": "写真のグループ分け", "asset_list_layout_settings_group_by_month": "月", - "asset_list_layout_settings_group_by_month_day": "月+日", + "asset_list_layout_settings_group_by_month_day": "月 + 日", "asset_list_settings_subtitle": "グリッドに関する設定", "asset_list_settings_title": "グリッド", - "backup_album_selection_page_albums_device": "端末上のアルバム数は {} だよ", - "backup_album_selection_page_albums_tap": "タップで選択、ダブルタップで除外だよ", - "backup_album_selection_page_assets_scatter": "同じ写真がいろんなアルバムに登録されてる事があるから、アルバムを含めたり除外したりしてどの写真を保存するか選択できるよ。", + "backup_album_selection_page_albums_device": "端末上のアルバム数: {} ", + "backup_album_selection_page_albums_tap": "タップで選択、ダブルタップで除外", + "backup_album_selection_page_assets_scatter": "同じ写真が複数のアルバムに登録されていることがあるので、アルバムを選択・除外してバックアップする写真を選べます。", "backup_album_selection_page_select_albums": "アルバムを選択", - "backup_album_selection_page_selection_info": "選択、又は除外されているアルバム", + "backup_album_selection_page_selection_info": "選択・除外中のアルバム", "backup_album_selection_page_total_assets": "選択されたアルバムの写真と動画の数", "backup_all": "全て", "backup_background_service_backup_failed_message": "アップロードに失敗しました。リトライ中", "backup_background_service_connection_failed_message": "サーバーに接続できません。リトライ中", "backup_background_service_current_upload_notification": " {} をアップロード中", - "backup_background_service_default_notification": "新しい写真をチェックしてるよ", + "backup_background_service_default_notification": "新しい写真を確認中", "backup_background_service_error_title": "バックアップエラー", "backup_background_service_in_progress_notification": "バックアップ中", "backup_background_service_upload_failure_notification": "{} のアップロードに失敗", "backup_controller_page_albums": "アルバム", "backup_controller_page_background_app_refresh_disabled_content": "バックグラウンドで写真のバックアップを行いたい場合はバックグラウンド更新を \n設定 > 一般 > Appのバックグラウンド更新 \nからオンにしてください", - "backup_controller_page_background_app_refresh_disabled_title": "バックグラウンドバックアップはオフになってます", + "backup_controller_page_background_app_refresh_disabled_title": "バックグラウンド更新はオフになっています", "backup_controller_page_background_app_refresh_enable_button_text": "設定を開く", - "backup_controller_page_background_battery_info_link": "方法を見る", - "backup_controller_page_background_battery_info_message": "バックグラウンドバックアップが正常に動作するためにImmichに適用されてるバッテリーの最適化と自動調整をオフにしてね。\n\n端末によって方法が変わるから各々調べてね", + "backup_controller_page_background_battery_info_link": "詳細", + "backup_controller_page_background_battery_info_message": "バックグラウンド処理を正常に動作させるためには、Immichに適用されているバッテリーの最適化や自動調整をオフにしてください。\n\n端末によって変更方法が異なります。", "backup_controller_page_background_battery_info_ok": "了解", "backup_controller_page_background_battery_info_title": "バッテリーの最適化", - "backup_controller_page_background_charging": "充電中のみに行う", - "backup_controller_page_background_configure_error": "バックグラウンドサービスの構築に失敗しました", - "backup_controller_page_background_delay": "新しい写真のバックアップを遅らせる: {}", - "backup_controller_page_background_description": "バックグラウンドバックアップをオンにしてアプリを開かなくても自動で画像をアップロードするようにします", - "backup_controller_page_background_is_off": "自動バックグラウンドバックアップはオフになってます", - "backup_controller_page_background_is_on": "自動バックグラウンドバックアップはオンになってます", + "backup_controller_page_background_charging": "充電中のみ", + "backup_controller_page_background_configure_error": "バックグラウンドサービスの構築に失敗", + "backup_controller_page_background_delay": "新しい写真のバックアップ遅延: {}", + "backup_controller_page_background_description": "アプリを開かずにバックアップを行います", + "backup_controller_page_background_is_off": "バックグランドサービスがオフになっています", + "backup_controller_page_background_is_on": "バックグランドサービスがオンになっています", "backup_controller_page_background_turn_off": "バックグラウンドサービスをオフにする", "backup_controller_page_background_turn_on": "バックグラウンドサービスをオンにする", - "backup_controller_page_background_wifi": "WiFi接続中のみに行う", - "backup_controller_page_backup": "バックアップ済み", - "backup_controller_page_backup_selected": "選択済み:", + "backup_controller_page_background_wifi": "WiFi接続中のみ", + "backup_controller_page_backup": "バックアップ", + "backup_controller_page_backup_selected": "選択中:", "backup_controller_page_backup_sub": "バックアップされた写真と動画の数", - "backup_controller_page_cancel": "キャンセルするよ", - "backup_controller_page_created": "{} に作成されたよ", - "backup_controller_page_desc_backup": "ONにすれば自動的に新しい写真などがバックアップされるようになるよ", - "backup_controller_page_excluded": "除外されてるアルバム:", + "backup_controller_page_cancel": "キャンセル", + "backup_controller_page_created": "{} 作成", + "backup_controller_page_desc_backup": "アプリを開いているときに写真と動画をバックアップします", + "backup_controller_page_excluded": "除外中のアルバム:", "backup_controller_page_failed": "失敗: ({})", "backup_controller_page_filename": "ファイル名: {} [{}] ", "backup_controller_page_id": "ID: {}", "backup_controller_page_info": "バックアップ情報", - "backup_controller_page_none_selected": "何も選んでないよ", + "backup_controller_page_none_selected": "なし", "backup_controller_page_remainder": "残り", "backup_controller_page_remainder_sub": "残りの写真と動画の数", "backup_controller_page_select": "選択", - "backup_controller_page_server_storage": "サーバーの容量", - "backup_controller_page_start_backup": "バックアップを開始", - "backup_controller_page_status_off": "バックアップがOFFだよ", - "backup_controller_page_status_on": "バックアップがONだよ", + "backup_controller_page_server_storage": "サーバー容量", + "backup_controller_page_start_backup": "バックアップ開始", + "backup_controller_page_status_off": "バックアップがオフになっています", + "backup_controller_page_status_on": "バックアップがオンになっています", "backup_controller_page_storage_format": "使用済み: {}/{}", "backup_controller_page_to_backup": "バックアップされるアルバム", - "backup_controller_page_total": "トータル", + "backup_controller_page_total": "合計", "backup_controller_page_total_sub": "選択されたアルバムの写真と動画の数", - "backup_controller_page_turn_off": "バックアップOFF", - "backup_controller_page_turn_on": "バックアップON", - "backup_controller_page_uploading_file_info": "アップロードされてるファイルに関する情報", - "backup_err_only_album": "唯一のアルバムを削除する事はできないよ", + "backup_controller_page_turn_off": "バックアップをオフにする", + "backup_controller_page_turn_on": "バックアップをオンにする", + "backup_controller_page_uploading_file_info": "アップロード中のファイル", + "backup_err_only_album": "最低1つのアルバムを選択してください", "backup_info_card_assets": "写真と動画", "cache_settings_album_thumbnails": "ライブラリのサムネイル ({}枚)", "cache_settings_clear_cache_button": "キャッシュをクリア", - "cache_settings_clear_cache_button_title": "キャッシュを削除するけど、キャッシュを作り直すまでアプリのパフォーマンスが著しく低下するよ", + "cache_settings_clear_cache_button_title": "キャッシュを削除(キャッシュ再生成までアプリのパフォーマンスが著しく低下)", "cache_settings_image_cache_size": "キャッシュのサイズ ({}枚) ", "cache_settings_statistics_album": "ライブラリのサムネイル", "cache_settings_statistics_assets": "{} 枚 ({}枚中)", @@ -99,137 +102,147 @@ "cache_settings_statistics_shared": "共有アルバムのサムネイル", "cache_settings_statistics_thumbnail": "サムネイル", "cache_settings_statistics_title": "キャッシュ", - "cache_settings_subtitle": "キャッシュの動作を変更できるよ", + "cache_settings_subtitle": "キャッシュの動作を変更する", "cache_settings_thumbnail_size": "サムネイルのキャッシュのサイズ ({}枚)", "cache_settings_title": "キャッシュの設定", - "change_password_form_confirm_password": "パスワードを確定", - "change_password_form_description": "{lastaName} {firstName}さん こんにちは\n\nサーバーにアクセスするのが初めて、又はパスワードリセットのリクエストがされました。新しいパスワードを入力してください", + "change_password_form_confirm_password": "確定", + "change_password_form_description": "{lastaName} {firstName}さん こんにちは\n\nサーバーにアクセスするのが初めてか、パスワードリセットのリクエストがされました。新しいパスワードを入力してください", "change_password_form_new_password": "新しいパスワード", "change_password_form_password_mismatch": "パスワードが一致しません", "change_password_form_reenter_new_password": "再度パスワードを入力してください", "common_add_to_album": "アルバムに追加", "common_change_password": "パスワードを変更", "common_create_new_album": "アルバムを作成", - "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", + "common_server_error": "ネットワーク接続を確認し、サーバーが接続できる状態にあるか確認してください。アプリとサーバーのバージョンが一致しているかも確認してください。", "common_shared": "共有済み", "control_bottom_app_bar_add_to_album": "アルバムに追加", - "control_bottom_app_bar_album_info": "{}枚の写真", - "control_bottom_app_bar_album_info_shared": "{}枚の共有中の写真", - "control_bottom_app_bar_archive": "Archive", - "control_bottom_app_bar_create_new_album": "新しいアルバムを作成", + "control_bottom_app_bar_album_info": "{}枚", + "control_bottom_app_bar_album_info_shared": "{}枚 · 共有済", + "control_bottom_app_bar_archive": "アーカイブ", + "control_bottom_app_bar_create_new_album": "アルバムを作成", "control_bottom_app_bar_delete": "削除", "control_bottom_app_bar_favorite": "お気に入り", "control_bottom_app_bar_share": "共有", - "control_bottom_app_bar_unarchive": "Unarchive", - "create_album_page_untitled": "タイトル無し", + "control_bottom_app_bar_unarchive": "アーカイブを解除", + "create_album_page_untitled": "タイトルなし", "create_shared_album_page_create": "作成", "create_shared_album_page_share": "共有", "create_shared_album_page_share_add_assets": "写真を追加", "create_shared_album_page_share_select_photos": "写真を選択", - "curated_location_page_title": "Places", - "curated_object_page_title": "Things", + "curated_location_page_title": "撮影場所", + "curated_object_page_title": "被写体", "daily_title_text_date": "MM月 DD日, EE", "daily_title_text_date_year": "yyyy年 MM月 DD日, EE", "date_format": "MM月 DD日, EE • hh時mm分", - "delete_dialog_alert": "サーバーからも端末からも永久的に削除されるけど良いの?", + "delete_dialog_alert": "サーバーとデバイスの両方から永久的に削除されます!", "delete_dialog_cancel": "キャンセル", "delete_dialog_ok": "削除", "delete_dialog_title": "永久的に削除", - "description_input_hint_text": "Add description...", - "description_input_submit_error": "Error updating description, check the log for more details", - "exif_bottom_sheet_description": "概要を追加", - "exif_bottom_sheet_details": "詳細な情報", - "exif_bottom_sheet_location": "撮影地", + "description_input_hint_text": "説明を追加", + "description_input_submit_error": "説明の編集に失敗、詳細の確認はログで行ってください", + "exif_bottom_sheet_description": "説明を追加", + "exif_bottom_sheet_details": "詳細", + "exif_bottom_sheet_location": "撮影場所", "experimental_settings_new_asset_list_subtitle": "製作途中(WIP)", - "experimental_settings_new_asset_list_title": "試験的なグリッドを有効", - "experimental_settings_subtitle": "試験的だから自己責任でね", - "experimental_settings_title": "試験的", - "favorites_page_no_favorites": "No favorite assets found", + "experimental_settings_new_asset_list_title": "試験的なグリッドを有効化", + "experimental_settings_subtitle": "試験的機能につき自己責任で!", + "experimental_settings_title": "試験的機能", + "favorites_page_no_favorites": "お気に入り登録された写真またはビデオがありません", "favorites_page_title": "お気に入り", - "home_page_add_to_album_conflicts": "{album}に{added}枚写真を追加しました。{failed}枚の写真は常に存在してたよ", - "home_page_add_to_album_err_local": "まだアップロードされてない写真はアルバムに登録できないよ", + "home_page_add_to_album_conflicts": "{album}に{added}枚写真を追加しました。追加済みの{failed}枚はスキップしました。", + "home_page_add_to_album_err_local": "まだアップロードされてない項目はアルバムに登録できません", "home_page_add_to_album_success": "{album}に{added}枚写真を追加しました", - "home_page_archive_err_local": "Can not archive local assets yet, skipping", - "home_page_building_timeline": "タイムラインを構築中", - "home_page_favorite_err_local": "まだアップロードされてない写真はお気に入り登録できないよ", - "home_page_first_time_notice": "アプリを使うのがはじめての場合タイムラインに写真を表示するためにアルバムを選択してね", - "image_viewer_page_state_provider_download_error": "ダウンロードエラー", - "image_viewer_page_state_provider_download_success": "ダウンロードできました", + "home_page_archive_err_local": "まだアップロードされてない項目はアーカイブできません", + "home_page_building_timeline": "タイムライン構築中", + "home_page_favorite_err_local": "まだアップロードされてない項目はお気に入り登録できません", + "home_page_first_time_notice": "はじめてアプリを使う場合、タイムラインに写真を表示するためにアルバムを選択してください", + "image_viewer_page_state_provider_download_error": "ダウンロード失敗", + "image_viewer_page_state_provider_download_success": "ダウンロード成功", "library_page_albums": "アルバム", - "library_page_archive": "Archive", - "library_page_device_albums": "Albums on Device", + "library_page_archive": "アーカイブ", + "library_page_device_albums": "デバイス上のアルバム", "library_page_favorites": "お気に入り", "library_page_new_album": "新しいアルバム", "library_page_sharing": "共有中", - "library_page_sort_created": "最後に作成した", - "library_page_sort_title": "アルバムのタイトル", - "login_form_api_exception": "API exception. Please check the server URL and try again.", + "library_page_sort_created": "作成日時", + "library_page_sort_title": "アルバム名", + "login_form_api_exception": "APIエラー。URLをチェックしてもう一度試してください", "login_form_button_text": "ログイン", - "login_form_email_hint": "example@email.com", + "login_form_email_hint": "hoge@email.com", "login_form_endpoint_hint": "https://example.com:port/api", "login_form_endpoint_url": "サーバーエンドポイントURL", - "login_form_err_http": "http://かhttps://かを指定してね", - "login_form_err_invalid_email": "メールアドレスが有効じゃないよ", - "login_form_err_invalid_url": "無効なURLです", - "login_form_err_leading_whitespace": "最初に半角スペースが含まれてるよ", - "login_form_err_trailing_whitespace": "最後に半角スペースが含まれてるよ", - "login_form_failed_get_oauth_server_config": "OAuthを使ってのログインに失敗しました。サーバーのURLを確認してください", - "login_form_failed_get_oauth_server_disable": "OAuthはこのサーバーで使えません", - "login_form_failed_login": "ログインエラー。サーバーのURL、メールアドレスとパスワードを再確認してね", + "login_form_err_http": "http://かhttps://かを指定してください", + "login_form_err_invalid_email": "メールアドレスが無効です", + "login_form_err_invalid_url": "無効なURL", + "login_form_err_leading_whitespace": "最初にスペースが含まれています", + "login_form_err_trailing_whitespace": "最後にスペースが含まれています", + "login_form_failed_get_oauth_server_config": "OAuthログインに失敗しました。サーバーのURLを確認してください。", + "login_form_failed_get_oauth_server_disable": "このサーバーではOAuthが使えません", + "login_form_failed_login": "ログインエラー。サーバーのURL・メールアドレス・パスワードを再確認してください。", "login_form_label_email": "メールアドレス", "login_form_label_password": "パスワード", - "login_form_next_button": "Next", + "login_form_next_button": "次", "login_form_password_hint": "パスワード", - "login_form_save_login": "ログインしたままにする", - "login_form_server_empty": "Enter a server URL.", - "login_form_server_error": "Could not connect to server.", + "login_form_save_login": "ログインを保持", + "login_form_server_empty": "URLを入力", + "login_form_server_error": "サーバーに接続できません", "monthly_title_text_date_format": "yyyy年 MM月", - "motion_photos_page_title": "Motion Photos", + "motion_photos_page_title": "モーションフォト", "notification_permission_dialog_cancel": "キャンセル", "notification_permission_dialog_content": "通知を許可するには設定を開いてオンにしてください", "notification_permission_dialog_settings": "設定", "notification_permission_list_tile_content": "通知の許可 をオンにしてください", "notification_permission_list_tile_enable_button": "通知をオンにする", "notification_permission_list_tile_title": "通知の許可", - "permission_onboarding_continue_anyway": "Continue anyway", - "permission_onboarding_get_started": "Get started", - "permission_onboarding_go_to_settings": "Go to settings", - "permission_onboarding_grant_permission": "Grant permission", - "permission_onboarding_log_out": "Log out", - "permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.", - "permission_onboarding_permission_granted": "Permission granted! You are all set.", - "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", - "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", + "permission_onboarding_continue_anyway": "無視して続行", + "permission_onboarding_get_started": "はじめる", + "permission_onboarding_go_to_settings": "システム設定", + "permission_onboarding_grant_permission": "許可する", + "permission_onboarding_log_out": "ログアウト", + "permission_onboarding_permission_denied": "写真へのアクセスが許可されていません。このアプリを使うには設定から写真と動画へのアクセスを許可してください", + "permission_onboarding_permission_granted": "写真へのアクセスが許可されました", + "permission_onboarding_permission_limited": "写真へのアクセスが制限されています。Immichに写真のバックアップと管理を行わせるにはシステム設定から写真と動画のアクセス権限を変更してください。", + "permission_onboarding_request": "Immichは写真へのアクセス許可が必要です", "profile_drawer_app_logs": "ログ", - "profile_drawer_client_server_up_to_date": "サーバーとクライアント、両方最新バージョンだよ", + "profile_drawer_client_server_up_to_date": "すべて最新です", "profile_drawer_settings": "設定", "profile_drawer_sign_out": "サインアウト", - "recently_added_page_title": "Recently Added", + "recently_added_page_title": "最近", "search_bar_hint": "写真を検索", - "search_page_categories": "Categories", - "search_page_favorites": "Favorites", - "search_page_motion_photos": "Motion Photos", + "search_page_categories": "カテゴリ", + "search_page_favorites": "お気に入り", + "search_page_motion_photos": "モーションフォト", "search_page_no_objects": "被写体に関するデータがなし", - "search_page_no_places": "場所に関するデータがなし", + "search_page_no_places": "場所に関するデータなし", + "search_page_people": "People", "search_page_places": "撮影地", - "search_page_recently_added": "Recently added", - "search_page_screenshots": "Screenshots", - "search_page_selfies": "Selfies", - "search_page_things": "カテゴリ", - "search_page_videos": "Videos", - "search_page_view_all_button": "View all", - "search_page_your_activity": "Your activity", + "search_page_recently_added": "最近追加", + "search_page_screenshots": "スクリーンショット", + "search_page_selfies": "自撮り", + "search_page_things": "被写体", + "search_page_videos": "ビデオ", + "search_page_view_all_button": "すべて表示", + "search_page_your_activity": "アクティビティ", "search_result_page_new_search_hint": "検索", - "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", - "search_suggestion_list_smart_search_hint_2": "m:your-search-term", + "search_suggestion_list_smart_search_hint_1": "スマート検索はデフォルトでオンになっています。メタデータで検索を行う場合:", + "search_suggestion_list_smart_search_hint_2": "m:単語", "select_additional_user_for_sharing_page_suggestions": "ユーザーリスト", - "select_user_for_sharing_page_err_album": "アルバム作成に失敗...", - "select_user_for_sharing_page_share_suggestions": "ユーザーの一覧", + "select_user_for_sharing_page_err_album": "アルバム作成に失敗", + "select_user_for_sharing_page_share_suggestions": "ユーザ一覧", "server_info_box_app_version": "アプリVer.", "server_info_box_server_version": "サーバーVer.", - "setting_image_viewer_help": "ディテールビューは最初にサムネイルをロードします。次に中画質のサイズの写真が表示されます。次ににオプションがオンになってる場合大きいサイズの写真がロードされて、最後にオンになってる場合オリジナルサイズの写真がロードされます", - "setting_image_viewer_original_subtitle": "オリジナルの写真を表示したい時にオンにしてください(最大の画質で表示されるので携帯のモバイルデータとストレージの消費量が増えます)。節約したい場合はオフにしてください", - "setting_image_viewer_original_title": "オリジナルをロードする", + "setting_image_viewer_help": "写真をタップするとサムネイル・中画質(要設定)・オリジナル(要設定)の順に読み込みます", + "setting_image_viewer_original_subtitle": "オリジナルの画像を表示したい時にオンにしてください(最大画質で表示されるのでモバイルデータとストレージの消費量が増えます)。", + "setting_image_viewer_original_title": "オリジナル画像を読み込む", "setting_image_viewer_preview_subtitle": "中画質の写真をロードしたい時にオンにしてください。直接最大画質の写真を表示したい場合はオフにしてください(ロード中はサムネイルが代わりに表示されます)", "setting_image_viewer_preview_title": "プレビュー画像をロードする", "setting_notifications_notify_failures_grace_period": "バックアップ失敗の通知: {}", @@ -239,22 +252,22 @@ "setting_notifications_notify_never": "行わない", "setting_notifications_notify_seconds": "{}秒", "setting_notifications_single_progress_subtitle": "アップロード中の写真の詳細", - "setting_notifications_single_progress_title": "バックグランドバックアップの詳細を表示", + "setting_notifications_single_progress_title": "実行中のバックアップの詳細を表示", "setting_notifications_subtitle": "通知設定を変更する", "setting_notifications_title": "通知", - "setting_notifications_total_progress_subtitle": "アップロードの進行状況 (完了済み/全体) ", - "setting_notifications_total_progress_title": "バックグラウンドバックアップの進行状況を表示", + "setting_notifications_total_progress_subtitle": "アップロードの進行状況 (完了済み/全体枚数) ", + "setting_notifications_total_progress_title": "実行中のバックアップの進行状況を表示", "setting_pages_app_bar_settings": "設定", - "settings_require_restart": "設定の適用にImmichの再起動が必要だよ", + "settings_require_restart": "Immichを再起動して設定を適用してください", "share_add": "追加", "share_add_photos": "写真を追加", "share_add_title": "タイトルを追加", "share_create_album": "アルバムを作成", - "share_dialog_preparing": "準備中...ちょっと待ってね", - "share_invite": "アルバムに参加", + "share_dialog_preparing": "準備中", + "share_invite": "アルバムに招待", "sharing_page_album": "共有アルバム", - "sharing_page_description": "共有アルバムを作成して同じネットワークにいる人たちに写真を共有してみよう!", - "sharing_page_empty_list": "共有アルバムが無いよ", + "sharing_page_description": "共有アルバムを作成して同じネットワークにいる人たちに写真を共有", + "sharing_page_empty_list": "共有アルバムなし", "sharing_silver_appbar_create_shared_album": "共有アルバムを作成", "sharing_silver_appbar_share_partner": "パートナーと共有", "tab_controller_nav_library": "ライブラリ", @@ -262,19 +275,19 @@ "tab_controller_nav_search": "検索", "tab_controller_nav_sharing": "共有", "theme_setting_asset_list_storage_indicator_title": "ストレージに関する情報を表示", - "theme_setting_asset_list_tiles_per_row_title": "一列のの写真の数: {} ", + "theme_setting_asset_list_tiles_per_row_title": "一列ごとの枚数: {} ", "theme_setting_dark_mode_switch": "ダークモード", - "theme_setting_image_viewer_quality_subtitle": "画像ビューアの画質の設定", - "theme_setting_image_viewer_quality_title": "画像ビューア", + "theme_setting_image_viewer_quality_subtitle": "画像ビューの画質の設定", + "theme_setting_image_viewer_quality_title": "画像ビュー", "theme_setting_system_theme_switch": "自動 (端末の設定を反映) ", - "theme_setting_theme_subtitle": "アプリの見た目の設定", + "theme_setting_theme_subtitle": "テーマ設定", "theme_setting_theme_title": "テーマ", - "theme_setting_three_stage_loading_subtitle": "三段階読み込みを有効にするとパフォーマンスが改善する可能性があるけど、データ使用量が凄く増えるよ", + "theme_setting_three_stage_loading_subtitle": "三段階読み込みを有効にするとパフォーマンスが改善する可能性がありますが、ネットワーク負荷が著しく増加します", "theme_setting_three_stage_loading_title": "三段階読み込みをオンにする", "version_announcement_overlay_ack": "了解", "version_announcement_overlay_release_notes": "更新情報", - "version_announcement_overlay_text_1": "こんにちは、又はこんばんは!新しい", - "version_announcement_overlay_text_2": "のバージョンが公開中だよ。", - "version_announcement_overlay_text_3": "を確認してみてね。あと、docker-composeや.envファイルが最新の状態に更新されてか、特にWatchTowerなどのツールを使ってDockerイメージを自動アップデートしてる人は確認してね", - "version_announcement_overlay_title": "新しいバージョン、公開中\uD83C\uDF89" + "version_announcement_overlay_text_1": "こんにちは、またはこんばんは!新しい", + "version_announcement_overlay_text_2": "のバージョンが公開中です。", + "version_announcement_overlay_text_3": "を確認してみてください。docker-composeや.envファイルが最新の状態に更新されているか、特にWatchTowerなどのツールを使ってDockerイメージを自動アップデートしてる人は確認してください。", + "version_announcement_overlay_title": "サーバーの新バージョンリリース\uD83C\uDF89" } \ No newline at end of file diff --git a/mobile/assets/i18n/ko-KR.json b/mobile/assets/i18n/ko-KR.json index 0426bb89b..6b60aa387 100644 --- a/mobile/assets/i18n/ko-KR.json +++ b/mobile/assets/i18n/ko-KR.json @@ -1,6 +1,8 @@ { "add_to_album_bottom_sheet_added": "{album}에 추가", "add_to_album_bottom_sheet_already_exists": "{album}에 이미 포함되어 있습니다", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", "advanced_settings_tile_subtitle": "Advanced user's settings", "advanced_settings_tile_title": "Advanced", "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", @@ -20,6 +22,7 @@ "album_viewer_appbar_share_leave": "앨범 나가기", "album_viewer_appbar_share_remove": "앨범에서 제거", "album_viewer_page_share_add_users": "사용자 추가", + "all_people_page_title": "People", "all_videos_page_title": "Videos", "archive_page_no_archived_assets": "No archived assets found", "archive_page_title": "Archive ({})", @@ -191,6 +194,15 @@ "notification_permission_list_tile_content": "알림 활성화 권한허용", "notification_permission_list_tile_enable_button": "알림 활성화", "notification_permission_list_tile_title": "알림 권한", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", "permission_onboarding_continue_anyway": "Continue anyway", "permission_onboarding_get_started": "Get started", "permission_onboarding_go_to_settings": "Go to settings", @@ -211,6 +223,7 @@ "search_page_motion_photos": "Motion Photos", "search_page_no_objects": "발견된 사물이\n없습니다", "search_page_no_places": "발견된 장소가\n없습니다", + "search_page_people": "People", "search_page_places": "장소", "search_page_recently_added": "Recently added", "search_page_screenshots": "Screenshots", diff --git a/mobile/assets/i18n/nb-NO.json b/mobile/assets/i18n/nb-NO.json index 21296cabd..be2fb89ba 100644 --- a/mobile/assets/i18n/nb-NO.json +++ b/mobile/assets/i18n/nb-NO.json @@ -1,6 +1,8 @@ { "add_to_album_bottom_sheet_added": "Lagt til i {album}", "add_to_album_bottom_sheet_already_exists": "Allerede i {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", "advanced_settings_tile_subtitle": "Avanserte brukerinnstillinger", "advanced_settings_tile_title": "Avansert", "advanced_settings_troubleshooting_subtitle": "Aktiver ekstra funksjoner for feilsøking", @@ -9,55 +11,56 @@ "album_info_card_backup_album_included": "INKLUDERT", "album_thumbnail_card_item": "1 objekt", "album_thumbnail_card_items": "{} objekter", - "album_thumbnail_card_shared": "Delt", - "album_thumbnail_owned": "Eid", + "album_thumbnail_card_shared": " · Delt", + "album_thumbnail_owned": "Ditt album", "album_thumbnail_shared_by": "Delt av {}", "album_viewer_appbar_share_delete": "Slett album", - "album_viewer_appbar_share_err_delete": "Feilet ved sletting av album", + "album_viewer_appbar_share_err_delete": "Kunne ikke slette albumet", "album_viewer_appbar_share_err_leave": "Kunne ikke forlate albumet", - "album_viewer_appbar_share_err_remove": "Det oppstod ett problem ved fjerning av objekter fra albumet", + "album_viewer_appbar_share_err_remove": "Det oppstod et problem ved fjerning av objekter fra albumet", "album_viewer_appbar_share_err_title": "Feilet ved endring av albumtittel", "album_viewer_appbar_share_leave": "Forlat album", "album_viewer_appbar_share_remove": "Fjern fra album", "album_viewer_page_share_add_users": "Legg til brukere", + "all_people_page_title": "People", "all_videos_page_title": "Videoer", - "archive_page_no_archived_assets": "No archived assets found", + "archive_page_no_archived_assets": "Ingen arkiverte objekter funnet", "archive_page_title": "Arkiv ({})", - "asset_list_layout_settings_dynamic_layout_title": "Dynamisk layout", - "asset_list_layout_settings_group_automatically": "Automatic", + "asset_list_layout_settings_dynamic_layout_title": "Dynamisk bildeorganisering", + "asset_list_layout_settings_group_automatically": "Automatisk", "asset_list_layout_settings_group_by": "Grupper bilder etter", "asset_list_layout_settings_group_by_month": "Måned", "asset_list_layout_settings_group_by_month_day": "Måned + dag", - "asset_list_settings_subtitle": "Innstillinger for layout for fotorutenett", + "asset_list_settings_subtitle": "Innstillinger for layout av fotorutenett", "asset_list_settings_title": "Fotorutenett", - "backup_album_selection_page_albums_device": "Albumer på enhet ({})", + "backup_album_selection_page_albums_device": "Album på enhet ({})", "backup_album_selection_page_albums_tap": "Trykk for å inkludere, dobbelttrykk for å ekskludere", - "backup_album_selection_page_assets_scatter": "Objekter kan eksistere i flere album. Men album kan kun bli inkludert eller ekskludert under backup-prosessen.", + "backup_album_selection_page_assets_scatter": "Objekter kan bli spredd over flere album. Album kan derfor bli inkludert eller ekskludert under sikkerhetskopieringen.", "backup_album_selection_page_select_albums": "Velg album", - "backup_album_selection_page_selection_info": "Valginfo", - "backup_album_selection_page_total_assets": "Totalt unike objekter", + "backup_album_selection_page_selection_info": "Valginformasjon", + "backup_album_selection_page_total_assets": "Totalt antall unike objekter", "backup_all": "Alle", - "backup_background_service_backup_failed_message": "Feilet ved backup av objekter. Prøver på nytt...", - "backup_background_service_connection_failed_message": "Feilet ved tilkobling til server. Prøver på nytt...", + "backup_background_service_backup_failed_message": "Sikkerhetskopiering av objekter feilet. Prøver på nytt ...", + "backup_background_service_connection_failed_message": "Tilkobling til server feilet. Prøver på nytt ...", "backup_background_service_current_upload_notification": "Laster opp {}", - "backup_background_service_default_notification": "Sjekker for nye objekter...", - "backup_background_service_error_title": "Backup feil", - "backup_background_service_in_progress_notification": "Foretar backup av objekter...", - "backup_background_service_upload_failure_notification": "Filet under opplasting {}", + "backup_background_service_default_notification": "Ser etter nye objekter ...", + "backup_background_service_error_title": "Sikkerhetskopieringsfeil", + "backup_background_service_in_progress_notification": "Sikkerhetskopierer objekter ...", + "backup_background_service_upload_failure_notification": "Opplasting feilet {}", "backup_controller_page_albums": "Sikkerhetskopier albumer", - "backup_controller_page_background_app_refresh_disabled_content": "Aktiver oppdatering av bakgrunnsapp i Innstillinger > Generelt > Oppdater bakgrunnsapp for å bruke sikkerhetskopiering i bakgrunnen.", - "backup_controller_page_background_app_refresh_disabled_title": "Oppdatering av bakgrunnsapp er deaktivert", + "backup_controller_page_background_app_refresh_disabled_content": "Aktiver bakgrunnsoppdatering i Innstillinger > Generelt > Bakgrunnsoppdatering for å bruke sikkerhetskopiering i bakgrunnen.", + "backup_controller_page_background_app_refresh_disabled_title": "Bakgrunnsoppdateringer er deaktivert", "backup_controller_page_background_app_refresh_enable_button_text": "Gå til innstillinger", - "backup_controller_page_background_battery_info_link": "Hvis meg hvordan", - "backup_controller_page_background_battery_info_message": "For den beste bakgrunnsbackup opplevelsen, deaktiver enhver batterioptimalisering som begrenser bakgrunnsaktiviteten til Immich.\n\nSiden dette er en enhets-spesifik justering, se innstillinger for den aktuelle enhet.", + "backup_controller_page_background_battery_info_link": "Vis meg hvordan", + "backup_controller_page_background_battery_info_message": "For at sikkerhetskopiering i bakgrunnen skal fungere optimalt, deaktiver enhver batterioptimalisering som kan begrense bakgrunnsaktiviteten til Immich.\n\nSiden dette er en enhetsspesifikk justering, må du finne det i innstillingene på enheten din.", "backup_controller_page_background_battery_info_ok": "OK", "backup_controller_page_background_battery_info_title": "Batterioptimalisering", "backup_controller_page_background_charging": "Kun ved lading", - "backup_controller_page_background_configure_error": "Feilet under konfigurering av bakgrunnstjenesten", - "backup_controller_page_background_delay": "Forsink backup av nye objekter: {}", - "backup_controller_page_background_description": "Skru på bakgrunnstjenesten for å automatisk ta backup av alle nye objekter uten å måtte åpne appen", - "backup_controller_page_background_is_off": "Automatisk bakgrunnsbackup er deaktivert", - "backup_controller_page_background_is_on": "Automatisk bakgrunnsbackup er aktivert", + "backup_controller_page_background_configure_error": "Konfigurering av bakgrunnstjenesten feilet", + "backup_controller_page_background_delay": "Forsink sikkerhetskopiering av nye objekter: {}", + "backup_controller_page_background_description": "Skru på bakgrunnstjenesten for å automatisk sikkerhetskopiere alle nye objekter uten å måtte åpne appen", + "backup_controller_page_background_is_off": "Automatisk sikkerhetskopiering i bakgrunnener deaktivert", + "backup_controller_page_background_is_on": "Automatisk sikkerhetskopiering i bakgrunnen er aktivert", "backup_controller_page_background_turn_off": "Skru av bakgrunnstjenesten", "backup_controller_page_background_turn_on": "Skru på bakgrunnstjenesten", "backup_controller_page_background_wifi": "Kun på WiFi", @@ -65,19 +68,19 @@ "backup_controller_page_backup_selected": "Valgte:", "backup_controller_page_backup_sub": "Opplastede bilder og videoer", "backup_controller_page_cancel": "Avbryt", - "backup_controller_page_created": "Opprettet den: {}", + "backup_controller_page_created": "Opprettet: {}", "backup_controller_page_desc_backup": "Slå på sikkerhetskopiering i forgrunnen for automatisk å laste opp nye objekter til serveren når du åpner appen.", "backup_controller_page_excluded": "Ekskludert:", - "backup_controller_page_failed": "Mislyktes ({})", + "backup_controller_page_failed": "Feilet ({})", "backup_controller_page_filename": "Filnavn: {} [{}]", "backup_controller_page_id": "ID: {}", - "backup_controller_page_info": "Sikkerhetskopi informasjon", + "backup_controller_page_info": "Informasjon om sikkerhetskopi", "backup_controller_page_none_selected": "Ingen valgt", "backup_controller_page_remainder": "Gjenstår", - "backup_controller_page_remainder_sub": "Gjenstående bilder og videoer å laste opp fra utvalg", + "backup_controller_page_remainder_sub": "Gjenstående bilder og videoer å laste opp fra utvalget", "backup_controller_page_select": "Velg", "backup_controller_page_server_storage": "Serverlagring", - "backup_controller_page_start_backup": "Start backup", + "backup_controller_page_start_backup": "Start sikkerhetskopiering", "backup_controller_page_status_off": "Automatisk sikkerhetskopiering i forgrunnen er av", "backup_controller_page_status_on": "Automatisk sikkerhetskopiering i forgrunnen er på", "backup_controller_page_storage_format": "{} av {} brukt", @@ -86,21 +89,21 @@ "backup_controller_page_total_sub": "Alle unike bilder og videoer fra valgte album", "backup_controller_page_turn_off": "Slå av sikkerhetskopiering i forgrunnen", "backup_controller_page_turn_on": "Slå på sikkerhetskopiering i forgrunnen", - "backup_controller_page_uploading_file_info": "Filinformasjon på opplastende fil", + "backup_controller_page_uploading_file_info": "Laster opp filinformasjon", "backup_err_only_album": "Kan ikke fjerne det eneste albumet", "backup_info_card_assets": "objekter", - "cache_settings_album_thumbnails": "Bibliotekside miniatyrbilder ({} objekter)", + "cache_settings_album_thumbnails": "Bibliotekminiatyrbilder ({} objekter)", "cache_settings_clear_cache_button": "Tøm buffer", - "cache_settings_clear_cache_button_title": "Tømmer app'ens buffer. Dette vil ha betydelig innvirkning på appens ytelse inntil buffer er gjenoppbygd.", - "cache_settings_image_cache_size": "Bilde bufferstørrelse ({} objekter)", - "cache_settings_statistics_album": "Bibliotek miniatyrbilder", + "cache_settings_clear_cache_button_title": "Tømmer app-ens buffer. Dette vil ha betydelig innvirkning på appens ytelse inntil bufferen er gjenoppbygd.", + "cache_settings_image_cache_size": "Størrelse på bildebuffer ({} objekter)", + "cache_settings_statistics_album": "Bibliotekminiatyrbilder", "cache_settings_statistics_assets": "{} objekter ({})", "cache_settings_statistics_full": "Originalbilder", - "cache_settings_statistics_shared": "Delte album miniatyrbilder", + "cache_settings_statistics_shared": "Delte albumminiatyrbilder", "cache_settings_statistics_thumbnail": "Miniatyrbilder", "cache_settings_statistics_title": "Bufferbruk", - "cache_settings_subtitle": "Kontroller bufringsadferden til Immich-mobilapplikasjonen", - "cache_settings_thumbnail_size": "MIniatyrbilder bufferstørrelse ({} objekter)", + "cache_settings_subtitle": "Kontroller bufringsadferden til Immich-appen", + "cache_settings_thumbnail_size": "Størrelse på miniatyrbildebuffer ({} objekter)", "cache_settings_title": "Bufringsinnstillinger", "change_password_form_confirm_password": "Bekreft passord", "change_password_form_description": "Hei {firstName} {lastName}!\n\nDette er enten første gang du logger på systemet, eller det er sendt en forespørsel om å endre passordet ditt. Vennligst skriv inn det nye passordet nedenfor.", @@ -110,7 +113,7 @@ "common_add_to_album": "Legg til i album", "common_change_password": "Endre passord", "common_create_new_album": "Lag nytt album", - "common_server_error": "Sjekk nettverkstilkobling, vær sikker på at serveren er mulig å nå og at app/server-versjoner er kompatible.", + "common_server_error": "Sjekk nettverkstilkoblingen din, forsikre deg om at serveren er mulig å nå, og at app-/server-versjonene er kompatible.", "common_shared": "Delt", "control_bottom_app_bar_add_to_album": "Legg til i album", "control_bottom_app_bar_album_info": "{} objekter", @@ -120,7 +123,7 @@ "control_bottom_app_bar_delete": "Slett", "control_bottom_app_bar_favorite": "Favoritt", "control_bottom_app_bar_share": "Del", - "control_bottom_app_bar_unarchive": "Unarchive", + "control_bottom_app_bar_unarchive": "Fjern fra arkiv", "create_album_page_untitled": "Uten navn", "create_shared_album_page_create": "Opprett", "create_shared_album_page_share": "Del", @@ -131,20 +134,20 @@ "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", - "delete_dialog_alert": "Disse objektene vil bli slettet permanent fra Immich og fra enheten", + "delete_dialog_alert": "Disse objektene vil bli slettet permanent fra Immich og fra enheten din", "delete_dialog_cancel": "Avbryt", "delete_dialog_ok": "Slett", "delete_dialog_title": "Slett permanent", - "description_input_hint_text": "Legg til beskrivelse...", + "description_input_hint_text": "Legg til beskrivelse ...", "description_input_submit_error": "Feil ved oppdatering av beskrivelse, sjekk loggen for flere detaljer", - "exif_bottom_sheet_description": "Legg til beskrivelse...", + "exif_bottom_sheet_description": "Legg til beskrivelse ...", "exif_bottom_sheet_details": "DETALJER", "exif_bottom_sheet_location": "PLASSERING", "experimental_settings_new_asset_list_subtitle": "Under utvikling", - "experimental_settings_new_asset_list_title": "Aktiver eksperimentell grid-visning", + "experimental_settings_new_asset_list_title": "Aktiver eksperimentell rutenettsvisning", "experimental_settings_subtitle": "Bruk på egen risiko!", "experimental_settings_title": "Eksperimentelt", - "favorites_page_no_favorites": "No favorite assets found", + "favorites_page_no_favorites": "Ingen favorittobjekter funnet", "favorites_page_title": "Favoritter", "home_page_add_to_album_conflicts": "Lagt til {added} objekter til album {album}. {failed} objekter er allerede i albumet.", "home_page_add_to_album_err_local": "Kan ikke legge til lokale objekter til album enda, hopper over", @@ -152,7 +155,7 @@ "home_page_archive_err_local": "Kan ikke arkivere lokale objekter enda, hopper over", "home_page_building_timeline": "Genererer tidslinjen", "home_page_favorite_err_local": "Kan ikke sette favoritt på lokale objekter enda, hopper over", - "home_page_first_time_notice": "Hvis dette er første gangen du benytter appen, så velg ett album(eller flere) slik at tidslinjen kan genereres med dine bilder og videoer.", + "home_page_first_time_notice": "Hvis dette er første gangen du benytter appen, velg et album (eller flere) for sikkerhetskopiering, slik at tidslinjen kan fylles med dine bilder og videoer.", "image_viewer_page_state_provider_download_error": "Nedlasting feilet", "image_viewer_page_state_provider_download_success": "Nedlasting vellykket", "library_page_albums": "Albumer", @@ -162,35 +165,44 @@ "library_page_new_album": "Nytt album", "library_page_sharing": "Deling", "library_page_sort_created": "Nylig opplastet", - "library_page_sort_title": "Album tittel", - "login_form_api_exception": "API feil. Sjekk server URL og prøv igjen.", + "library_page_sort_title": "Albumtittel", + "login_form_api_exception": "API-feil. Sjekk URL-en til serveren og prøv igjen.", "login_form_button_text": "Logg inn", "login_form_email_hint": "dinepost@epost.no", "login_form_endpoint_hint": "http://din-server-ip:port/api", "login_form_endpoint_url": "Serverendepunkt-URL", "login_form_err_http": "Vennligst spesifiser http:// eller https://", - "login_form_err_invalid_email": "Ugyldig Epostadresse", + "login_form_err_invalid_email": "Ugyldig e-postadresse", "login_form_err_invalid_url": "Ugyldig URL", "login_form_err_leading_whitespace": "Ledende mellomrom", "login_form_err_trailing_whitespace": "Etterfølgende mellomrom", - "login_form_failed_get_oauth_server_config": "Feil innlogging ved bruk av OAuth, sjekk server URL", - "login_form_failed_get_oauth_server_disable": "OAuth innlogging er ikke tilgjengelig på denne serveren", - "login_form_failed_login": "Feil ved innlogging, sjekk server URL, epost og passord", - "login_form_label_email": "Epostadresse", + "login_form_failed_get_oauth_server_config": "Feil innlogging ved bruk av OAuth, sjekk serverens URL", + "login_form_failed_get_oauth_server_disable": "OAuth-innlogging er ikke tilgjengelig på denne serveren", + "login_form_failed_login": "Feil ved innlogging, sjekk serverens URL, e-post og passord", + "login_form_label_email": "E-postadresse", "login_form_label_password": "Passord", "login_form_next_button": "Neste", "login_form_password_hint": "passord", "login_form_save_login": "Forbli innlogget", - "login_form_server_empty": "Skriv inn en server URL.", + "login_form_server_empty": "Skriv inn en server-URL.", "login_form_server_error": "Kan ikke koble til server.", "monthly_title_text_date_format": "MMMM y", "motion_photos_page_title": "Bevegelige bilder", "notification_permission_dialog_cancel": "Avbryt", "notification_permission_dialog_content": "For å aktivere notifikasjoner, gå til Innstillinger og velg tillat.", "notification_permission_dialog_settings": "Innstillinger", - "notification_permission_list_tile_content": "Tillat tilgang for å aktivere notifikasjoner", + "notification_permission_list_tile_content": "Gi tilgang for å aktivere notifikasjoner", "notification_permission_list_tile_enable_button": "Aktiver notifikasjoner", "notification_permission_list_tile_title": "Notifikasjonstilgang", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", "permission_onboarding_continue_anyway": "Fortsett uansett", "permission_onboarding_get_started": "Kom i gang", "permission_onboarding_go_to_settings": "Gå til innstillinger", @@ -198,10 +210,10 @@ "permission_onboarding_log_out": "Logg ut", "permission_onboarding_permission_denied": "Tilgang avvist. For å bruke Immich, tillat å vise bilde og videoer i Innstillinger.", "permission_onboarding_permission_granted": "Tilgang gitt! Du er i gang.", - "permission_onboarding_permission_limited": "Tilgang begrenset. For å la Immich ta backup og håndtere galleriet, tillatt bilde og video-tilgang i Innstillinger.", + "permission_onboarding_permission_limited": "Begrenset tilgang. For å la Immich sikkerhetskopiere og håndtere galleriet, tillatt bilde- og video-tilgang i Innstillinger.", "permission_onboarding_request": "Immich trenger tilgang til å se dine bilder og videoer", "profile_drawer_app_logs": "Logg", - "profile_drawer_client_server_up_to_date": "Klient og Server er oppdatert", + "profile_drawer_client_server_up_to_date": "Klient og server er oppdatert", "profile_drawer_settings": "Innstillinger", "profile_drawer_sign_out": "Logg ut", "recently_added_page_title": "Nylig lagt til", @@ -210,8 +222,9 @@ "search_page_favorites": "Favoritter", "search_page_motion_photos": "Bevegelige bilder", "search_page_no_objects": "Ingen objektinfo tilgjengelig", - "search_page_no_places": "Ingen plasseringsinfo tilgjengelig", - "search_page_places": "Plasser", + "search_page_no_places": "Ingen stedsinformasjon er tilgjengelig", + "search_page_people": "People", + "search_page_places": "Steder", "search_page_recently_added": "Nylig lagt til", "search_page_screenshots": "Skjermbilder", "search_page_selfies": "Selfier", @@ -220,17 +233,17 @@ "search_page_view_all_button": "Vis alle", "search_page_your_activity": "Din aktivitet", "search_result_page_new_search_hint": "Nytt søk", - "search_suggestion_list_smart_search_hint_1": "Smartsøk er aktivert som standard, for å søke etter metadata bruk syntaks ", + "search_suggestion_list_smart_search_hint_1": "Smartsøk er aktivert som standard, for å søke etter metadata bruk syntaksen ", "search_suggestion_list_smart_search_hint_2": "m:ditt-søkeord", "select_additional_user_for_sharing_page_suggestions": "Forslag", "select_user_for_sharing_page_err_album": "Feilet ved oppretting av album", "select_user_for_sharing_page_share_suggestions": "Forslag", - "server_info_box_app_version": "App versjon", - "server_info_box_server_version": "Server versjon", - "setting_image_viewer_help": "Først lastes mikrobilder, deretter forhåndsvisningsbildet (hvis aktivert), til slutt lastes original (hvis aktivert).", - "setting_image_viewer_original_subtitle": "Aktiver for å laste originalbildet i full oppløsning (Stort!). Deaktiver for å spare databruk (både nettverksbruk og bufferdata på enheten).", + "server_info_box_app_version": "App-versjon", + "server_info_box_server_version": "Server-versjon", + "setting_image_viewer_help": "Detaljvisningen laster først miniatyrbildet, deretter forhåndsvisningsbildet (hvis aktivert), og til slutt originalen (hvis aktivert).", + "setting_image_viewer_original_subtitle": "Aktiver for å laste originalbildet i full oppløsning (stort!). Deaktiver for å spare databruk (både nettverksbruk og bufferdata på enheten).", "setting_image_viewer_original_title": "Last originalbildet", - "setting_image_viewer_preview_subtitle": "Aktiver for å laste ett bilde av medium oppløsning. Deaktiver for å enten direkte laste inn originalen eller kun benytte miniatyrbilde.", + "setting_image_viewer_preview_subtitle": "Aktiver for å laste et bilde av medium oppløsning. Deaktiver for å enten direkte laste inn originalen eller kun benytte miniatyrbilde.", "setting_image_viewer_preview_title": "Last forhåndsvisningsbilde", "setting_notifications_notify_failures_grace_period": "Varsle om sikkerhetskopieringsfeil i bakgrunnen: {}", "setting_notifications_notify_hours": "{} timer", @@ -238,22 +251,22 @@ "setting_notifications_notify_minutes": "{} minutter", "setting_notifications_notify_never": "aldri", "setting_notifications_notify_seconds": "{} sekunder", - "setting_notifications_single_progress_subtitle": "Detaljert opplastingsinformasjon pr objekt", - "setting_notifications_single_progress_title": "Vis detaljert status på bakgrunnsbackup", + "setting_notifications_single_progress_subtitle": "Detaljert opplastingsinformasjon per objekt", + "setting_notifications_single_progress_title": "Vis detaljert status på sikkerhetskopiering i bakgrunnen", "setting_notifications_subtitle": "Juster notifikasjonsinnstillinger", "setting_notifications_title": "Notifikasjoner", "setting_notifications_total_progress_subtitle": "Total opplastingsstatus (fullført/totalt objekter)", - "setting_notifications_total_progress_title": "Vis status på bakgrunnsbackup", + "setting_notifications_total_progress_title": "Vis status på sikkerhetskopiering i bakgrunnen", "setting_pages_app_bar_settings": "Innstillinger", "settings_require_restart": "Vennligst restart Immich for å aktivere denne innstillingen", "share_add": "Legg til", "share_add_photos": "Legg til bilder", "share_add_title": "Legg til tittel", "share_create_album": "Opprett album", - "share_dialog_preparing": "Forbereder...", + "share_dialog_preparing": "Forbereder ...", "share_invite": "Inviter til album", "sharing_page_album": "Delte album", - "sharing_page_description": "Lag delte album for å dele bilder og videoer med folk i nettverket ditt.", + "sharing_page_description": "Lag delte albumer for å dele bilder og videoer med folk i nettverket ditt.", "sharing_page_empty_list": "TOM LISTE", "sharing_silver_appbar_create_shared_album": "Lag delt album", "sharing_silver_appbar_share_partner": "Del med partner", @@ -261,20 +274,20 @@ "tab_controller_nav_photos": "Bilder", "tab_controller_nav_search": "Søk", "tab_controller_nav_sharing": "Deling", - "theme_setting_asset_list_storage_indicator_title": "Vis lagringsindiaktor på objektfliser", - "theme_setting_asset_list_tiles_per_row_title": "Antall ressurser per rad ({})", + "theme_setting_asset_list_storage_indicator_title": "Vis lagringsindiaktor på objekter i fotorutenettet", + "theme_setting_asset_list_tiles_per_row_title": "Antall objekter per rad ({})", "theme_setting_dark_mode_switch": "Mørk modus", - "theme_setting_image_viewer_quality_subtitle": "Juster kvaliteten på detaljer med bildeviser", + "theme_setting_image_viewer_quality_subtitle": "Juster kvaliteten på bilder i detaljvisning", "theme_setting_image_viewer_quality_title": "Kvalitet på bildevisning", - "theme_setting_system_theme_switch": "Automatisk (følg system)", - "theme_setting_theme_subtitle": "Velg app'ens temainnstilling", + "theme_setting_system_theme_switch": "Automatisk (følg systeminnstillinger)", + "theme_setting_theme_subtitle": "Velg app-ens temainnstilling", "theme_setting_theme_title": "Tema", - "theme_setting_three_stage_loading_subtitle": "Tre-trinns lasting kan øke lasteytelsen, men forårsaker betydelig høyere nettverksbelastning", - "theme_setting_three_stage_loading_title": "Aktiver 3-stegs innlasting", + "theme_setting_three_stage_loading_subtitle": "Tre-trinns innlasting kan øke lasteytelsen, men forårsaker betydelig høyere nettverksbelastning", + "theme_setting_three_stage_loading_title": "Aktiver tre-trinns innlasting", "version_announcement_overlay_ack": "Bekreft", - "version_announcement_overlay_release_notes": "Endringslogg", + "version_announcement_overlay_release_notes": "endringsloggen", "version_announcement_overlay_text_1": "Hei, det er en ny versjon av", - "version_announcement_overlay_text_2": "vennligst ta deg tid til å besøke", - "version_announcement_overlay_text_3": " og verifiser at din docker-compose og .env oppsett er oppdatert for å forhindre en eventuell miskonfigurasjon. Spesielt hvis du benytter WatchTower eller en annen tjeneste som håndterer oppdatering av applikasjoner på serveren automatisk.", + "version_announcement_overlay_text_2": "vennligst ta deg tid til å besøke ", + "version_announcement_overlay_text_3": " og verifiser at docker-compose og .env-oppsettet ditt er oppdatert for å forhindre en eventuell feilkonfigurasjon, spesielt hvis du benytter WatchTower eller en annen tjeneste som håndterer oppdatering av server-applikasjonen automatisk.", "version_announcement_overlay_title": "Ny serverversjon tilgjengelig" } \ No newline at end of file diff --git a/mobile/assets/i18n/nl-NL.json b/mobile/assets/i18n/nl-NL.json index 4841f2801..e2973eabb 100644 --- a/mobile/assets/i18n/nl-NL.json +++ b/mobile/assets/i18n/nl-NL.json @@ -1,9 +1,11 @@ { "add_to_album_bottom_sheet_added": "Toegevoegd aan {album}", "add_to_album_bottom_sheet_already_exists": "Staat al in {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", "advanced_settings_tile_subtitle": "Geavanceerde gebruikersinstellingen", "advanced_settings_tile_title": "Geavanceerd", - "advanced_settings_troubleshooting_subtitle": "Schakel extra functies in voor probleemoplossing", + "advanced_settings_troubleshooting_subtitle": "Schakel extra functies voor probleemoplossing in ", "advanced_settings_troubleshooting_title": "Probleemoplossing", "album_info_card_backup_album_excluded": "UITGESLOTEN", "album_info_card_backup_album_included": "INGESLOTEN", @@ -13,53 +15,54 @@ "album_thumbnail_owned": "Eigenaar", "album_thumbnail_shared_by": "Gedeeld door {}", "album_viewer_appbar_share_delete": "Verwijder album", - "album_viewer_appbar_share_err_delete": "Fout bij verwijderen album", - "album_viewer_appbar_share_err_leave": "Fout bij verlaten album", + "album_viewer_appbar_share_err_delete": "Verwijderen album mislukt", + "album_viewer_appbar_share_err_leave": "Verlaten album mislukt", "album_viewer_appbar_share_err_remove": "Er gaat iets mis bij het verwijderen van items uit het album", - "album_viewer_appbar_share_err_title": "Fout bij wijzigen album titel", + "album_viewer_appbar_share_err_title": "Albumtitel wijzigen mislukt", "album_viewer_appbar_share_leave": "Verlaat album", "album_viewer_appbar_share_remove": "Verwijder uit album", "album_viewer_page_share_add_users": "Gebruikers toevoegen", + "all_people_page_title": "People", "all_videos_page_title": "Video's", - "archive_page_no_archived_assets": "No archived assets found", + "archive_page_no_archived_assets": "Geen gearchiveerde items gevonden", "archive_page_title": "Archief ({})", "asset_list_layout_settings_dynamic_layout_title": "Dynamische layout", - "asset_list_layout_settings_group_automatically": "Automatic", + "asset_list_layout_settings_group_automatically": "Automatisch", "asset_list_layout_settings_group_by": "Groupeer items per", "asset_list_layout_settings_group_by_month": "Maand", "asset_list_layout_settings_group_by_month_day": "Maand + dag", - "asset_list_settings_subtitle": "Foto grid layout instellingen", - "asset_list_settings_title": "Foto Grid", + "asset_list_settings_subtitle": "Fotorasterlayoutinstellingen", + "asset_list_settings_title": "Fotoraster", "backup_album_selection_page_albums_device": "Albums op apparaat ({})", "backup_album_selection_page_albums_tap": "Tik om in te voegen, dubbel tik om uit te sluiten", "backup_album_selection_page_assets_scatter": "Items kunnen over verschillende albums verdeeld zijn, dus albums kunnen ingesloten of uitgesloten zijn van het backup proces.", - "backup_album_selection_page_select_albums": "Selecteer albums", - "backup_album_selection_page_selection_info": "Selectie info", + "backup_album_selection_page_select_albums": "Albums selecteren", + "backup_album_selection_page_selection_info": "Selectie-info", "backup_album_selection_page_total_assets": "Totaal unieke items", "backup_all": "Alle", - "backup_background_service_backup_failed_message": "Fout bij backuppen items. Opnieuw proberen…", + "backup_background_service_backup_failed_message": "Fout bij back-uppen items. Opnieuw proberen…", "backup_background_service_connection_failed_message": "Fout bij verbinden server. Opnieuw proberen…", "backup_background_service_current_upload_notification": "Uploaden {}", - "backup_background_service_default_notification": "Controleren op nieuw items…", - "backup_background_service_error_title": "Backup fout", - "backup_background_service_in_progress_notification": "Back-up maken van items…", + "backup_background_service_default_notification": "Controleren op nieuwe items…", + "backup_background_service_error_title": "Backupfout", + "backup_background_service_in_progress_notification": "Back-up van items maken…", "backup_background_service_upload_failure_notification": "Fout bij upload {}", - "backup_controller_page_albums": "Back-up albums", + "backup_controller_page_albums": "Back-upalbums", "backup_controller_page_background_app_refresh_disabled_content": "Schakel verversen op de achtergrond in via Instellingen > Algemeen > Ververs op achtergrond, om back-ups op de achtergrond te maken.", - "backup_controller_page_background_app_refresh_disabled_title": "Ververs op achtergrond uitgeschakeld", + "backup_controller_page_background_app_refresh_disabled_title": "Verversen op achtergrond uitgeschakeld", "backup_controller_page_background_app_refresh_enable_button_text": "Ga naar instellingen", - "backup_controller_page_background_battery_info_link": "Toon me hoe", - "backup_controller_page_background_battery_info_message": "Schakel voor de beste back-up ervaring op de achtergrond alle batterij optimalisaties uit, die de achtergrondactiviteit van Immich beperkt.\n\nAangezien dit apparaatspecifiek is, zoek de vereiste informatie op voor de fabrikant van je apparaat.", + "backup_controller_page_background_battery_info_link": "Laat zien hoe", + "backup_controller_page_background_battery_info_message": "Schakel voor de beste back-upervaring op de achtergrond alle batterijoptimalisaties uit, die de achtergrondactiviteit van Immich beperken.\n\nAangezien dit apparaatspecifiek is, zoek de vereiste informatie op voor de fabrikant van je apparaat.", "backup_controller_page_background_battery_info_ok": "OK", - "backup_controller_page_background_battery_info_title": "Batterij optimalisaties", + "backup_controller_page_background_battery_info_title": "Batterijoptimalisaties", "backup_controller_page_background_charging": "Alleen tijdens opladen", - "backup_controller_page_background_configure_error": "Achtergrondservice configuratie mislukt", - "backup_controller_page_background_delay": "Back-up vertraging nieuwe items: {}", + "backup_controller_page_background_configure_error": "Achtergrondserviceconfiguratie mislukt", + "backup_controller_page_background_delay": "Back-upvertraging nieuwe items: {}", "backup_controller_page_background_description": "Schakel de achtergrondservice in om automatisch een back-up te maken van nieuwe items zonder de app te hoeven openen", - "backup_controller_page_background_is_off": "Automatische achtergrond back-up staat uit", - "backup_controller_page_background_is_on": "Automatische achtergrond back-up staat aan", - "backup_controller_page_background_turn_off": "Zet achtergrondservice uit", - "backup_controller_page_background_turn_on": "Zet achtergrondservice aan", + "backup_controller_page_background_is_off": "Automatische achtergrondback-up staat uit", + "backup_controller_page_background_is_on": "Automatische achtergrondback-up staat aan", + "backup_controller_page_background_turn_off": "Achtergrondservice uitzetten", + "backup_controller_page_background_turn_on": "Achtergrondservice aanzetten", "backup_controller_page_background_wifi": "Alleen op WiFi", "backup_controller_page_backup": "Back-up", "backup_controller_page_backup_selected": "Geselecteerd: ", @@ -71,12 +74,12 @@ "backup_controller_page_failed": "Mislukt ({})", "backup_controller_page_filename": "Bestandsnaam: {} [{}]", "backup_controller_page_id": "ID: {}", - "backup_controller_page_info": "Back-up informatie", + "backup_controller_page_info": "Back-upinformatie", "backup_controller_page_none_selected": "Geen geselecteerd", "backup_controller_page_remainder": "Resterend", "backup_controller_page_remainder_sub": "Resterende foto's en video's om een back-up van te maken uit selectie", "backup_controller_page_select": "Selecteer", - "backup_controller_page_server_storage": "Server opslag", + "backup_controller_page_server_storage": "Serveropslag", "backup_controller_page_start_backup": "Back-up uitvoeren", "backup_controller_page_status_off": "Automatische back-up op de voorgrond staat uit", "backup_controller_page_status_on": "Automatische back-up op de voorgrond staat aan", @@ -84,8 +87,8 @@ "backup_controller_page_to_backup": "Albums om een back-up van te maken", "backup_controller_page_total": "Totaal", "backup_controller_page_total_sub": "Alle unieke foto's en video's uit geselecteerde albums", - "backup_controller_page_turn_off": "Zet back-up op de voorgrond uit", - "backup_controller_page_turn_on": "Zet back-up op de voorgrond aan", + "backup_controller_page_turn_off": "Back-up op de voorgrond uitzetten", + "backup_controller_page_turn_on": "Back-up op de voorgrond aanzetten", "backup_controller_page_uploading_file_info": "Bestandsgegevens uploaden", "backup_err_only_album": "Kan het enige album niet verwijderen", "backup_info_card_assets": "items", @@ -93,34 +96,34 @@ "cache_settings_clear_cache_button": "Cache wissen", "cache_settings_clear_cache_button_title": "Wist de cache van de app. Dit zal de presentaties van de app aanzienlijk beïnvloeden totdat de cache opnieuw is opgebouwd.", "cache_settings_image_cache_size": "Grootte afbeeldingscache ({} items)", - "cache_settings_statistics_album": "Bibliotheek thumbnails", + "cache_settings_statistics_album": "Bibliotheekthumbnails", "cache_settings_statistics_assets": "{} items ({})", "cache_settings_statistics_full": "Volledige afbeeldingen", - "cache_settings_statistics_shared": "Gedeeld album thumbnails", + "cache_settings_statistics_shared": "Gedeeld-albumthumbnails", "cache_settings_statistics_thumbnail": "Thumbnails", "cache_settings_statistics_title": "Cachegebruik", "cache_settings_subtitle": "Beheer het cachegedrag van de Immich app", - "cache_settings_thumbnail_size": "Thumbnail cachegrootte ({} items)", - "cache_settings_title": "Cache instellingen", + "cache_settings_thumbnail_size": "Thumbnail-cachegrootte ({} items)", + "cache_settings_title": "Cache-instellingen", "change_password_form_confirm_password": "Bevestig wachtwoord", - "change_password_form_description": "Hallo {firstName} {lastName},\n\nDit is ofwel de eerste keer dat je inlogd of er is een verzoek gedaan om je wachtwoord te wijzigen. Vul hieronder een nieuw wachtwoord in.", + "change_password_form_description": "Hallo {firstName} {lastName},\n\nDit is ofwel de eerste keer dat je inlogt, of er is een verzoek gedaan om je wachtwoord te wijzigen. Vul hieronder een nieuw wachtwoord in.", "change_password_form_new_password": "Nieuw wachtwoord", "change_password_form_password_mismatch": "Wachtwoorden komen niet overeen", "change_password_form_reenter_new_password": "Vul het wachtwoord opnieuw in", - "common_add_to_album": "Toevoegen aan album", + "common_add_to_album": "Aan album toevoegen", "common_change_password": "Wachtwoord wijzigen", - "common_create_new_album": "Maak nieuw album", + "common_create_new_album": "Nieuw album maken", "common_server_error": "Controleer je netwerkverbinding, zorg ervoor dat de server bereikbaar is en de app/server versies compatibel zijn.", "common_shared": "Gedeeld", - "control_bottom_app_bar_add_to_album": "Toevoegen aan album", + "control_bottom_app_bar_add_to_album": "Aan album toevoegen", "control_bottom_app_bar_album_info": "{} items", "control_bottom_app_bar_album_info_shared": "{} items · Gedeeld", "control_bottom_app_bar_archive": "Archiveren", - "control_bottom_app_bar_create_new_album": "Maak nieuw album", + "control_bottom_app_bar_create_new_album": "Nieuw album maken", "control_bottom_app_bar_delete": "Verwijderen", "control_bottom_app_bar_favorite": "Favoriet", "control_bottom_app_bar_share": "Delen", - "control_bottom_app_bar_unarchive": "Unarchive", + "control_bottom_app_bar_unarchive": "Herstellen", "create_album_page_untitled": "Naamloos", "create_shared_album_page_create": "Aanmaken", "create_shared_album_page_share": "Delen", @@ -141,10 +144,10 @@ "exif_bottom_sheet_details": "DETAILS", "exif_bottom_sheet_location": "LOCATIE", "experimental_settings_new_asset_list_subtitle": "Werk in uitvoering", - "experimental_settings_new_asset_list_title": "Experimenteel foto grid inschakelen", + "experimental_settings_new_asset_list_title": "Experimenteel fotoraster inschakelen", "experimental_settings_subtitle": "Gebruik op eigen risico!", "experimental_settings_title": "Experimenteel", - "favorites_page_no_favorites": "No favorite assets found", + "favorites_page_no_favorites": "Geen favoriete items gevonden", "favorites_page_title": "Favorieten", "home_page_add_to_album_conflicts": "{added} items toegevoegd aan album {album}. {failed} items staan al in het album.", "home_page_add_to_album_err_local": "Lokale items kunnen nog niet aan albums worden toegevoegd, overslaan", @@ -166,22 +169,22 @@ "login_form_api_exception": "API fout. Controleer de server URL en probeer opnieuw.", "login_form_button_text": "Inloggen", "login_form_email_hint": "jouwemail@email.com", - "login_form_endpoint_hint": "http://jouw-server-ip:port/api", - "login_form_endpoint_url": "Server URL", + "login_form_endpoint_hint": "http://jouw-server-ip:poort/api", + "login_form_endpoint_url": "Server-URL", "login_form_err_http": "Voer http:// of https:// in", "login_form_err_invalid_email": "Ongeldig e-mailadres", "login_form_err_invalid_url": "Ongeldige URL", "login_form_err_leading_whitespace": "Spatie aan het begin", "login_form_err_trailing_whitespace": "Spatie aan het eind", - "login_form_failed_get_oauth_server_config": "Fout bij inloggen met OAuth, controleer server URL", - "login_form_failed_get_oauth_server_disable": "OAuth functie is niet beschikbaar op deze server", - "login_form_failed_login": "Fout bij inloggen, controleer server URL, e-mailadres en wachtwoord", + "login_form_failed_get_oauth_server_config": "Fout bij inloggen met OAuth, controleer server-URL", + "login_form_failed_get_oauth_server_disable": "OAuth-functie is niet beschikbaar op deze server", + "login_form_failed_login": "Fout bij inloggen; controleer server-URL, e-mailadres en wachtwoord", "login_form_label_email": "E-mailadres", "login_form_label_password": "Wachtwoord", "login_form_next_button": "Volgende", "login_form_password_hint": "wachtwoord", "login_form_save_login": "Ingelogd blijven", - "login_form_server_empty": "Voer een server URL in.", + "login_form_server_empty": "Voer een server-URL in.", "login_form_server_error": "Kan geen verbinding maken met de server.", "monthly_title_text_date_format": "MMMM y", "motion_photos_page_title": "Bewegende foto's", @@ -191,6 +194,15 @@ "notification_permission_list_tile_content": "Geef toestemming om meldingen te versturen.", "notification_permission_list_tile_enable_button": "Meldingen inschakelen", "notification_permission_list_tile_title": "Meldingen toestaan", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", "permission_onboarding_continue_anyway": "Toch doorgaan", "permission_onboarding_get_started": "Aan de slag", "permission_onboarding_go_to_settings": "Ga naar instellingen", @@ -205,12 +217,13 @@ "profile_drawer_settings": "Instellingen", "profile_drawer_sign_out": "Uitloggen", "recently_added_page_title": "Recent toegevoegd", - "search_bar_hint": "Zoeken naar foto's", + "search_bar_hint": "Foto's doorzoeken", "search_page_categories": "Categorieën", "search_page_favorites": "Favorieten", "search_page_motion_photos": "Bewegende foto's", - "search_page_no_objects": "Geen object gegevens beschikbaar", - "search_page_no_places": "Geen locatie gegevens beschikbaar", + "search_page_no_objects": "Geen objectgegevens beschikbaar", + "search_page_no_places": "Geen locatiegegevens beschikbaar", + "search_page_people": "People", "search_page_places": "Plaatsen", "search_page_recently_added": "Recent toegevoegd", "search_page_screenshots": "Screenshots", @@ -225,25 +238,25 @@ "select_additional_user_for_sharing_page_suggestions": "Suggesties", "select_user_for_sharing_page_err_album": "Album aanmaken mislukt", "select_user_for_sharing_page_share_suggestions": "Suggesties", - "server_info_box_app_version": "App versie", - "server_info_box_server_version": "Server versie", + "server_info_box_app_version": "Appversie", + "server_info_box_server_version": "Serverversie", "setting_image_viewer_help": "De gedetailleerde weergave laadt eerst de kleine thumbnail, vervolgens het middelgrote voorbeeld (indien ingeschakeld) en ten slotte het origineel (indien ingeschakeld).", - "setting_image_viewer_original_subtitle": "Inschakelen om de originele afbeelding met volledige resolutie (groot!) te laden. Uitschakelen om datagebruik te verminderen (zowel netwerk- als apparaatcache).", + "setting_image_viewer_original_subtitle": "Schakel in om de originele afbeelding met volledige resolutie (groot!) te laden. Schakel uit om datagebruik te verminderen (zowel netwerk als apparaatcache).", "setting_image_viewer_original_title": "Originele afbeelding laden", "setting_image_viewer_preview_subtitle": "Schakel in om een afbeelding met middelgrote resolutie te laden. Schakel uit om alleen het origineel direct te laden of alleen de thumbnail te gebruiken.", "setting_image_viewer_preview_title": "Voorbeeldafbeelding laden", - "setting_notifications_notify_failures_grace_period": "Meld back-upfouten op de achtergrond: {}", + "setting_notifications_notify_failures_grace_period": "Fouten van back-up op de achtergrond melden: {}", "setting_notifications_notify_hours": "{} uur", "setting_notifications_notify_immediately": "meteen", "setting_notifications_notify_minutes": "{} minuten", "setting_notifications_notify_never": "nooit", "setting_notifications_notify_seconds": "{} seconden", "setting_notifications_single_progress_subtitle": "Gedetaileerde informatie over de uploadvoortgang per item", - "setting_notifications_single_progress_title": "Toon gedetailleerde informatie over back-ups op de achtergrond", + "setting_notifications_single_progress_title": "Gedetailleerde informatie over back-ups op de achtergrond tonen", "setting_notifications_subtitle": "Voorkeuren voor meldingen beheren", "setting_notifications_title": "Meldingen", "setting_notifications_total_progress_subtitle": "Algehele uploadvoortgang (voltooid/totaal aantal items)", - "setting_notifications_total_progress_title": "Toon de totale voortgang van achtergrond back-up", + "setting_notifications_total_progress_title": "Totale voortgang van achtergrondback-up tonen", "setting_pages_app_bar_settings": "Instellingen", "settings_require_restart": "Start Immich opnieuw op om deze instelling toe te passen", "share_add": "Toevoegen", @@ -255,26 +268,26 @@ "sharing_page_album": "Gedeelde albums", "sharing_page_description": "Maak gedeelde albums om foto's en video's te delen met mensen in je netwerk.", "sharing_page_empty_list": "LEGE LIJST", - "sharing_silver_appbar_create_shared_album": "Maak gedeeld album", + "sharing_silver_appbar_create_shared_album": "Gedeeld album maken", "sharing_silver_appbar_share_partner": "Delen met partner", "tab_controller_nav_library": "Bibliotheek", "tab_controller_nav_photos": "Foto's", "tab_controller_nav_search": "Zoeken", "tab_controller_nav_sharing": "Delen", - "theme_setting_asset_list_storage_indicator_title": "Laat ruimte indicator zien bij item tegels", + "theme_setting_asset_list_storage_indicator_title": "Laat ruimte-indicator zien bij itemtegels", "theme_setting_asset_list_tiles_per_row_title": "Aantal items per rij ({})", "theme_setting_dark_mode_switch": "Donkere modus", - "theme_setting_image_viewer_quality_subtitle": "Pas de kwaliteit aan van de gedetailleerde foto weergave", - "theme_setting_image_viewer_quality_title": "Foto weergave kwaliteit", - "theme_setting_system_theme_switch": "Automatisch (volg systeeminstelling)", - "theme_setting_theme_subtitle": "Kies de thema instelling van de app", + "theme_setting_image_viewer_quality_subtitle": "De kwaliteit van de gedetailleerde-fotoweergave aanpassen", + "theme_setting_image_viewer_quality_title": "Fotoweergavekwaliteit", + "theme_setting_system_theme_switch": "Automatisch (systeeminstelling volgen)", + "theme_setting_theme_subtitle": "De thema-instelling van de app kiezen", "theme_setting_theme_title": "Thema", "theme_setting_three_stage_loading_subtitle": "Laden in drie fasen kan de laadprestaties verbeteren, maar veroorzaakt een aanzienlijk hogere netwerkbelasting", - "theme_setting_three_stage_loading_title": "Schakel laden in drie fasen in", + "theme_setting_three_stage_loading_title": "Laden in drie fasen inschakelen", "version_announcement_overlay_ack": "Bevestig", - "version_announcement_overlay_release_notes": "release opmerkingen", - "version_announcement_overlay_text_1": "Er is een nieuwe versie beschikbaar van", + "version_announcement_overlay_release_notes": "releaseopmerkingen", + "version_announcement_overlay_text_1": "Hoi, er is een nieuwe versie beschikbaar van", "version_announcement_overlay_text_2": "neem je tijd en bezoek de ", - "version_announcement_overlay_text_3": " controleer of je docker-compose en .env up-to-date zijn om te voorkomen dat er misconfiguraties zijn, in het bijzonder als je gebruik maakt van WatchTower of een ander mechanisme dat je serverapplicatie automatisch bijwerkt.", + "version_announcement_overlay_text_3": " en controleer of je docker-compose en .env up-to-date zijn, om misconfiguraties te voorkomen, in het bijzonder als je gebruik maakt van WatchTower of een ander mechanisme dat je serverapplicatie automatisch bijwerkt.", "version_announcement_overlay_title": "Nieuwe serverversie beschikbaar \uD83C\uDF89" } \ No newline at end of file diff --git a/mobile/assets/i18n/pl-PL.json b/mobile/assets/i18n/pl-PL.json index fc4fa53ee..d3a630422 100644 --- a/mobile/assets/i18n/pl-PL.json +++ b/mobile/assets/i18n/pl-PL.json @@ -1,6 +1,8 @@ { "add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", "advanced_settings_tile_subtitle": "Advanced user's settings", "advanced_settings_tile_title": "Advanced", "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", @@ -20,6 +22,7 @@ "album_viewer_appbar_share_leave": "Opuść album", "album_viewer_appbar_share_remove": "Usuń z albumu", "album_viewer_page_share_add_users": "Dodaj użytkowników", + "all_people_page_title": "People", "all_videos_page_title": "Videos", "archive_page_no_archived_assets": "No archived assets found", "archive_page_title": "Archive ({})", @@ -191,6 +194,15 @@ "notification_permission_list_tile_content": "Grant permission to enable notifications.", "notification_permission_list_tile_enable_button": "Enable Notifications", "notification_permission_list_tile_title": "Notification Permission", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", "permission_onboarding_continue_anyway": "Continue anyway", "permission_onboarding_get_started": "Get started", "permission_onboarding_go_to_settings": "Go to settings", @@ -211,6 +223,7 @@ "search_page_motion_photos": "Motion Photos", "search_page_no_objects": "Brak informacji o obiektach", "search_page_no_places": "Brak informacji o miejscu", + "search_page_people": "People", "search_page_places": "Miejsca", "search_page_recently_added": "Recently added", "search_page_screenshots": "Screenshots", diff --git a/mobile/assets/i18n/ru-RU.json b/mobile/assets/i18n/ru-RU.json index f71f5d1b2..3cd79f076 100644 --- a/mobile/assets/i18n/ru-RU.json +++ b/mobile/assets/i18n/ru-RU.json @@ -1,6 +1,8 @@ { "add_to_album_bottom_sheet_added": "Добавлено в {album}", "add_to_album_bottom_sheet_already_exists": "Уже в {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", "advanced_settings_tile_subtitle": "Advanced user's settings", "advanced_settings_tile_title": "Advanced", "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", @@ -20,6 +22,7 @@ "album_viewer_appbar_share_leave": "Покинуть альбом", "album_viewer_appbar_share_remove": "Удалить из альбома", "album_viewer_page_share_add_users": "Добавить пользователей", + "all_people_page_title": "People", "all_videos_page_title": "Videos", "archive_page_no_archived_assets": "No archived assets found", "archive_page_title": "Archive ({})", @@ -191,6 +194,15 @@ "notification_permission_list_tile_content": "Предоставьте разрешение на включение уведомлений", "notification_permission_list_tile_enable_button": "Включить уведомления", "notification_permission_list_tile_title": "Разрешение на уведомление", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", "permission_onboarding_continue_anyway": "Continue anyway", "permission_onboarding_get_started": "Get started", "permission_onboarding_go_to_settings": "Go to settings", @@ -211,6 +223,7 @@ "search_page_motion_photos": "Motion Photos", "search_page_no_objects": "Нет доступной информации об объектах", "search_page_no_places": "Информация о местах отсутствует", + "search_page_people": "People", "search_page_places": "Места", "search_page_recently_added": "Recently added", "search_page_screenshots": "Screenshots", diff --git a/mobile/assets/i18n/sk-SK.json b/mobile/assets/i18n/sk-SK.json index 98244b080..49c821971 100644 --- a/mobile/assets/i18n/sk-SK.json +++ b/mobile/assets/i18n/sk-SK.json @@ -1,17 +1,19 @@ { "add_to_album_bottom_sheet_added": "Pridané do {album}", "add_to_album_bottom_sheet_already_exists": "Už v {album}", - "advanced_settings_tile_subtitle": "Advanced user's settings", - "advanced_settings_tile_title": "Advanced", - "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", - "advanced_settings_troubleshooting_title": "Troubleshooting", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_tile_subtitle": "Pokročilé nastavenia používateľa", + "advanced_settings_tile_title": "Pokročilé", + "advanced_settings_troubleshooting_subtitle": "Povoliť ďalšie funkcie pre opravu chýb", + "advanced_settings_troubleshooting_title": "Oprava chýb", "album_info_card_backup_album_excluded": "VYLÚČENÉ", "album_info_card_backup_album_included": "ZAHRNUTÉ", "album_thumbnail_card_item": "1 položka", "album_thumbnail_card_items": "{} položiek", "album_thumbnail_card_shared": "Zdieľané", - "album_thumbnail_owned": "Owned", - "album_thumbnail_shared_by": "Shared by {}", + "album_thumbnail_owned": "Vlastnené", + "album_thumbnail_shared_by": "Zdieľané od {}", "album_viewer_appbar_share_delete": "Odstrániť album", "album_viewer_appbar_share_err_delete": "Nepodarilo sa odstrániť album", "album_viewer_appbar_share_err_leave": "Nepodarilo sa ukončiť album", @@ -20,11 +22,12 @@ "album_viewer_appbar_share_leave": "Opustiť album", "album_viewer_appbar_share_remove": "Odstrániť z albumu", "album_viewer_page_share_add_users": "Pridať používateľov", - "all_videos_page_title": "Videos", - "archive_page_no_archived_assets": "No archived assets found", - "archive_page_title": "Archive ({})", + "all_people_page_title": "People", + "all_videos_page_title": "Videá", + "archive_page_no_archived_assets": "Žiadne archivované médiá", + "archive_page_title": "Archív ({})", "asset_list_layout_settings_dynamic_layout_title": "Dynamické rozloženie", - "asset_list_layout_settings_group_automatically": "Automatic", + "asset_list_layout_settings_group_automatically": "Automaticky", "asset_list_layout_settings_group_by": "Zoskupiť položky podľa", "asset_list_layout_settings_group_by_month": "Mesiac", "asset_list_layout_settings_group_by_month_day": "Mesiac + deň", @@ -110,24 +113,24 @@ "common_add_to_album": "Pridať do albumu", "common_change_password": "Zmeniť heslo", "common_create_new_album": "Vytvoriť nový album", - "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", + "common_server_error": "Skontrolujte svoje sieťové pripojenie, uistite sa, že server je dostupný a verzie aplikácie/server sú kompatibilné.", "common_shared": "Zdieľané", "control_bottom_app_bar_add_to_album": "Pridať do albumu", "control_bottom_app_bar_album_info": "{} položiek", - "control_bottom_app_bar_album_info_shared": "{} položky - zdieľané", - "control_bottom_app_bar_archive": "Archive", + "control_bottom_app_bar_album_info_shared": "{} položiek - zdieľané", + "control_bottom_app_bar_archive": "Archív", "control_bottom_app_bar_create_new_album": "Vytvoriť nový album", "control_bottom_app_bar_delete": "Vymazať", "control_bottom_app_bar_favorite": "Obľúbené", "control_bottom_app_bar_share": "Zdieľať", - "control_bottom_app_bar_unarchive": "Unarchive", + "control_bottom_app_bar_unarchive": "Odarchivovať", "create_album_page_untitled": "Bez názvu", "create_shared_album_page_create": "Vytvoriť", "create_shared_album_page_share": "Zdieľať", - "create_shared_album_page_share_add_assets": "PRIDAŤ", + "create_shared_album_page_share_add_assets": "Pridať položky", "create_shared_album_page_share_select_photos": "Vybrať fotografie", - "curated_location_page_title": "Places", - "curated_object_page_title": "Things", + "curated_location_page_title": "Miesta", + "curated_object_page_title": "Veci", "daily_title_text_date": "EEEE, d. MMMM", "daily_title_text_date_year": "EEEE, d. MMMM y", "date_format": "EEEE, d. MMMM y • H:mm", @@ -135,8 +138,8 @@ "delete_dialog_cancel": "Zrušiť", "delete_dialog_ok": "Vymazať", "delete_dialog_title": "Vymazať natrvalo", - "description_input_hint_text": "Add description...", - "description_input_submit_error": "Error updating description, check the log for more details", + "description_input_hint_text": "Pridať popis...", + "description_input_submit_error": "Chyba pri aktualizovaní popisu, zobrazte log pre viac detailov", "exif_bottom_sheet_description": "Pridať popis...", "exif_bottom_sheet_details": "PODROBNOSTI", "exif_bottom_sheet_location": "LOKALITA", @@ -144,26 +147,26 @@ "experimental_settings_new_asset_list_title": "Povolenie experimentálnej mriežky fotografií", "experimental_settings_subtitle": "Používajte na vlastné riziko!", "experimental_settings_title": "Experimentálne", - "favorites_page_no_favorites": "No favorite assets found", + "favorites_page_no_favorites": "Žiadne obľúbené médiá", "favorites_page_title": "Obľúbené", "home_page_add_to_album_conflicts": "Pridané {added} položiek do albumu {album}. {failed} položiek už je v albume.", "home_page_add_to_album_err_local": "Zatiaľ nie je možné pridať lokálne média do albumov, preskakuje sa", "home_page_add_to_album_success": "Pridané {added} položky do albumu {album}.", - "home_page_archive_err_local": "Can not archive local assets yet, skipping", + "home_page_archive_err_local": "Zatiaľ nemožno archivovať lokálne médiá, preskakuje sa", "home_page_building_timeline": "Vytváranie časovej osi", "home_page_favorite_err_local": "Zatiaľ nie je možné zaradiť lokálne média medzi obľúbené, preskakuje sa", "home_page_first_time_notice": "Ak aplikáciu používate prvý krát, nezabudnite si vybrať zálohované albumy, aby sa na časovej osi mohli nachádzať fotografie a videá z vybraných albumoch.", "image_viewer_page_state_provider_download_error": "Chyba sťahovania", "image_viewer_page_state_provider_download_success": "Sťahovanie bolo úspešné", "library_page_albums": "Albumy", - "library_page_archive": "Archive", - "library_page_device_albums": "Albums on Device", + "library_page_archive": "Archivovať", + "library_page_device_albums": "Albumy v zariadení", "library_page_favorites": "Obľúbené", "library_page_new_album": "Nový album", "library_page_sharing": "Zdieľanie", "library_page_sort_created": "Najnovšie vytvorené", "library_page_sort_title": "Podľa názvu albumu", - "login_form_api_exception": "API exception. Please check the server URL and try again.", + "login_form_api_exception": "Chyba API. Skontrolujte adresu URL servera a skúste to znova.", "login_form_button_text": "Prihlásiť sa", "login_form_email_hint": "tvojmail@email.com", "login_form_endpoint_hint": "http://ip-tvojho-servera:port/api", @@ -178,50 +181,60 @@ "login_form_failed_login": "Chyba prihlásenia, skontrolujte url adresu servera, e-mail a heslo.", "login_form_label_email": "E-mail", "login_form_label_password": "Heslo", - "login_form_next_button": "Next", + "login_form_next_button": "Ďalej", "login_form_password_hint": "heslo", "login_form_save_login": "Zostať prihlásený", - "login_form_server_empty": "Enter a server URL.", - "login_form_server_error": "Could not connect to server.", + "login_form_server_empty": "Zadajte URL servera", + "login_form_server_error": "Nemožno pripojiť na server.", "monthly_title_text_date_format": "LLLL y", - "motion_photos_page_title": "Motion Photos", + "motion_photos_page_title": "Pohyblivé fotky", "notification_permission_dialog_cancel": "Zrušiť", "notification_permission_dialog_content": "Ak chcete povoliť upozornenia, prejdite do Nastavenia a vyberte možnosť Povoliť.", "notification_permission_dialog_settings": "Nastavenia", "notification_permission_list_tile_content": "Udeľte oprávnenie k aktivácii oznámení.", "notification_permission_list_tile_enable_button": "Povoliť upozornenia", "notification_permission_list_tile_title": "Povolenie oznámení", - "permission_onboarding_continue_anyway": "Continue anyway", - "permission_onboarding_get_started": "Get started", - "permission_onboarding_go_to_settings": "Go to settings", - "permission_onboarding_grant_permission": "Grant permission", - "permission_onboarding_log_out": "Log out", - "permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.", - "permission_onboarding_permission_granted": "Permission granted! You are all set.", - "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", - "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", + "permission_onboarding_continue_anyway": "Pokračovať aj tak", + "permission_onboarding_get_started": "Začať", + "permission_onboarding_go_to_settings": "Prejsť do nastavení", + "permission_onboarding_grant_permission": "Udeliť povolenie", + "permission_onboarding_log_out": "Odhlásiť sa", + "permission_onboarding_permission_denied": "Prístup zamietnutý. Ak chcete používať Immich, udeľte v Nastaveniach povolenia na fotografie a videá.", + "permission_onboarding_permission_granted": "Povolenie udelené! Všetko je nastavené.", + "permission_onboarding_permission_limited": "Povolenie obmedzené. Ak chcete, aby Immich zálohoval a spravoval celú vašu zbierku galérie, udeľte v Nastaveniach povolenia na fotografie a videá.", + "permission_onboarding_request": "Immich vyžaduje povolenie na prezeranie vašich fotografií a videí.", "profile_drawer_app_logs": "Logy", "profile_drawer_client_server_up_to_date": "Klient a server sú aktuálne", "profile_drawer_settings": "Nastavenia", "profile_drawer_sign_out": "Odhlásiť sa", - "recently_added_page_title": "Recently Added", + "recently_added_page_title": "Nedávno pridané", "search_bar_hint": "Prehľadajte svoje obrázky", - "search_page_categories": "Categories", - "search_page_favorites": "Favorites", - "search_page_motion_photos": "Motion Photos", + "search_page_categories": "Kategórie", + "search_page_favorites": "Obľúbené", + "search_page_motion_photos": "Pohyblivé fotky", "search_page_no_objects": "Žiadne informácie o objektoch", "search_page_no_places": "Žiadne informácie o mieste", + "search_page_people": "People", "search_page_places": "Miesta", - "search_page_recently_added": "Recently added", - "search_page_screenshots": "Screenshots", + "search_page_recently_added": "Nedávno pridané", + "search_page_screenshots": "Snímky obrazovky", "search_page_selfies": "Selfies", "search_page_things": "Veci", - "search_page_videos": "Videos", - "search_page_view_all_button": "View all", - "search_page_your_activity": "Your activity", + "search_page_videos": "Videá", + "search_page_view_all_button": "Zobraziť všetky", + "search_page_your_activity": "Vaša aktivita", "search_result_page_new_search_hint": "Nové vyhľadávanie", - "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", - "search_suggestion_list_smart_search_hint_2": "m:your-search-term", + "search_suggestion_list_smart_search_hint_1": "Inteligentné vyhľadávanie je predvolene povolené, na vyhľadávanie metadát použite syntax", + "search_suggestion_list_smart_search_hint_2": "m:váš-hľadaný-výraz", "select_additional_user_for_sharing_page_suggestions": "Návrhy", "select_user_for_sharing_page_err_album": "Nepodarilo sa vytvoriť album", "select_user_for_sharing_page_share_suggestions": "Návrhy", @@ -237,7 +250,7 @@ "setting_notifications_notify_immediately": "okamžite", "setting_notifications_notify_minutes": "{} minút", "setting_notifications_notify_never": "nikdy", - "setting_notifications_notify_seconds": "{} sekundy", + "setting_notifications_notify_seconds": "{} sekúnd", "setting_notifications_single_progress_subtitle": "Podrobné informácie o priebehu nahrávania pre položku", "setting_notifications_single_progress_title": "Zobraziť priebeh detailov zálohovania na pozadí", "setting_notifications_subtitle": "Prispôsobenie predvolieb oznámení", @@ -249,10 +262,10 @@ "share_add": "Pridať", "share_add_photos": "Pridať fotografie", "share_add_title": "Pridať názov", - "share_create_album": "Vytvoriť album", + "share_create_album": "Tvorba albumu", "share_dialog_preparing": "Pripravujem...", "share_invite": "Pozvať do albumu", - "sharing_page_album": "Shared albums", + "sharing_page_album": "Zdieľané albumy", "sharing_page_description": "Vytvárajte zdieľané albumy a zdieľajte fotografie a videá s ľuďmi vo vašej sieti.", "sharing_page_empty_list": "Prázdny list", "sharing_silver_appbar_create_shared_album": "Vytvoriť zdieľaný album", diff --git a/mobile/assets/i18n/sv-SE.json b/mobile/assets/i18n/sv-SE.json index 5917c53ad..d4d861cc4 100644 --- a/mobile/assets/i18n/sv-SE.json +++ b/mobile/assets/i18n/sv-SE.json @@ -1,6 +1,8 @@ { "add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", "advanced_settings_tile_subtitle": "Advanced user's settings", "advanced_settings_tile_title": "Advanced", "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", @@ -20,6 +22,7 @@ "album_viewer_appbar_share_leave": "Lämna album", "album_viewer_appbar_share_remove": "Ta bort från album", "album_viewer_page_share_add_users": "Lägg till användare", + "all_people_page_title": "People", "all_videos_page_title": "Videos", "archive_page_no_archived_assets": "No archived assets found", "archive_page_title": "Archive ({})", @@ -191,6 +194,15 @@ "notification_permission_list_tile_content": "Grant permission to enable notifications.", "notification_permission_list_tile_enable_button": "Enable Notifications", "notification_permission_list_tile_title": "Notification Permission", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", "permission_onboarding_continue_anyway": "Continue anyway", "permission_onboarding_get_started": "Get started", "permission_onboarding_go_to_settings": "Gå till inställningar", @@ -211,6 +223,7 @@ "search_page_motion_photos": "Motion Photos", "search_page_no_objects": "Inga objekt är tillgängliga", "search_page_no_places": "Ingen platsinformation finns tillgänglig", + "search_page_people": "People", "search_page_places": "Platser", "search_page_recently_added": "Nyligen tillagda", "search_page_screenshots": "Screenshots", diff --git a/mobile/assets/i18n/zh-CN.json b/mobile/assets/i18n/zh-CN.json index d9182b2dd..12cc258d2 100644 --- a/mobile/assets/i18n/zh-CN.json +++ b/mobile/assets/i18n/zh-CN.json @@ -1,74 +1,77 @@ { - "add_to_album_bottom_sheet_added": "添加到{album}", - "add_to_album_bottom_sheet_already_exists": "已经在{album}中了", - "advanced_settings_tile_subtitle": "Advanced user's settings", - "advanced_settings_tile_title": "Advanced", - "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", - "advanced_settings_troubleshooting_title": "Troubleshooting", - "album_info_card_backup_album_excluded": "排除", - "album_info_card_backup_album_included": "已选", - "album_thumbnail_card_item": "1张", - "album_thumbnail_card_items": "{}张", - "album_thumbnail_card_shared": "已共享", - "album_thumbnail_owned": "Owned", - "album_thumbnail_shared_by": "Shared by {}", + "add_to_album_bottom_sheet_added": "添加到 {album}", + "add_to_album_bottom_sheet_already_exists": "已在 {album} 中", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_tile_subtitle": "高级用户设置", + "advanced_settings_tile_title": "高级", + "advanced_settings_troubleshooting_subtitle": "启用用于故障排除的额外功能", + "advanced_settings_troubleshooting_title": "故障排除", + "album_info_card_backup_album_excluded": "已排除", + "album_info_card_backup_album_included": "已选中", + "album_thumbnail_card_item": "1 项", + "album_thumbnail_card_items": "{} 项", + "album_thumbnail_card_shared": " · 已共享", + "album_thumbnail_owned": "拥有", + "album_thumbnail_shared_by": "由 {} 共享", "album_viewer_appbar_share_delete": "删除相册", "album_viewer_appbar_share_err_delete": "删除相册失败", - "album_viewer_appbar_share_err_leave": "退出相册失败", - "album_viewer_appbar_share_err_remove": "从相册移除时出现错误", + "album_viewer_appbar_share_err_leave": "退出共享失败", + "album_viewer_appbar_share_err_remove": "从相册中移除时出现错误", "album_viewer_appbar_share_err_title": "修改相册标题失败", - "album_viewer_appbar_share_leave": "退出相册", + "album_viewer_appbar_share_leave": "退出共享", "album_viewer_appbar_share_remove": "从相册中移除", - "album_viewer_page_share_add_users": "新增用户", - "all_videos_page_title": "Videos", - "archive_page_no_archived_assets": "No archived assets found", - "archive_page_title": "Archive ({})", + "album_viewer_page_share_add_users": "创建用户", + "all_people_page_title": "People", + "all_videos_page_title": "视频", + "archive_page_no_archived_assets": "未找到归档项目", + "archive_page_title": "归档({})", "asset_list_layout_settings_dynamic_layout_title": "动态布局", - "asset_list_layout_settings_group_automatically": "Automatic", - "asset_list_layout_settings_group_by": "分组照片或视频由", + "asset_list_layout_settings_group_automatically": "自动", + "asset_list_layout_settings_group_by": "项目分组方式", "asset_list_layout_settings_group_by_month": "月", "asset_list_layout_settings_group_by_month_day": "月和日", - "asset_list_settings_subtitle": "照片预览设置", - "asset_list_settings_title": "照片预览", - "backup_album_selection_page_albums_device": "设备上的相册({})", + "asset_list_settings_subtitle": "照片网格布局设置", + "asset_list_settings_title": "照片网格", + "backup_album_selection_page_albums_device": "设备上的相册({})", "backup_album_selection_page_albums_tap": "单击选中, 双击排除", - "backup_album_selection_page_assets_scatter": "可以从多个相册中选择数据。因此, 可以在备份过程中选中或者排除相册", + "backup_album_selection_page_assets_scatter": "项目会分散在多个相册中。因此,可以在备份过程中包含或排除相册。", "backup_album_selection_page_select_albums": "选择相册", "backup_album_selection_page_selection_info": "选择信息", - "backup_album_selection_page_total_assets": "合计", - "backup_all": "所有", - "backup_background_service_backup_failed_message": "备份失败。重试中…", - "backup_background_service_connection_failed_message": "连接时服务器失败。重试中…", + "backup_album_selection_page_total_assets": "总计", + "backup_all": "全部", + "backup_background_service_backup_failed_message": "备份失败。正在重试…", + "backup_background_service_connection_failed_message": "连接服务器失败。正在重试…", "backup_background_service_current_upload_notification": "正在上传 {}", - "backup_background_service_default_notification": "正在检查新数据…", + "backup_background_service_default_notification": "正在检查新项目…", "backup_background_service_error_title": "备份失败", "backup_background_service_in_progress_notification": "正在备份…", "backup_background_service_upload_failure_notification": "上传失败 {}", "backup_controller_page_albums": "备份相册", - "backup_controller_page_background_app_refresh_disabled_content": "在“设置”>“常规”>“后台应用程序刷新”中启用后台应用程序刷新,以便使用后台备份功能。", - "backup_controller_page_background_app_refresh_disabled_title": "禁用后台应用程序刷新", + "backup_controller_page_background_app_refresh_disabled_content": "要使用后台备份功能,请在“设置”>“常规”>“后台应用刷新”中启用后台应用程序刷新。", + "backup_controller_page_background_app_refresh_disabled_title": "后台应用刷新已禁用", "backup_controller_page_background_app_refresh_enable_button_text": "前往设置", "backup_controller_page_background_battery_info_link": "怎么做", - "backup_controller_page_background_battery_info_message": "为了获得最佳的后台备份体验,请禁用任何限制 Immich 后台活动的电池优化。\n\n由于这是设备相关的,因此请查找设备制造商所需的信息。", + "backup_controller_page_background_battery_info_message": "为了获得最佳的后台备份体验,请禁用任何限制 Immich 后台活动的电池优化。\n\n由于这是设备相关的,因此请查找设备制造商提供的信息进行操作。", "backup_controller_page_background_battery_info_ok": "我知道了", "backup_controller_page_background_battery_info_title": "电池优化", "backup_controller_page_background_charging": "仅充电时", "backup_controller_page_background_configure_error": "配置后台服务失败", - "backup_controller_page_background_delay": "延迟{}后备份", - "backup_controller_page_background_description": "打开后台运行功能,不用打开应用你就能自动备份数据", + "backup_controller_page_background_delay": "延迟 {} 后备份", + "backup_controller_page_background_description": "打开后台服务以自动备份任何新项目,且无需打开应用", "backup_controller_page_background_is_off": "后台自动备份已关闭", "backup_controller_page_background_is_on": "后台自动备份已开启", - "backup_controller_page_background_turn_off": "关闭后台备份", - "backup_controller_page_background_turn_on": "开启后台备份", - "backup_controller_page_background_wifi": "仅WiFi", + "backup_controller_page_background_turn_off": "关闭后台服务", + "backup_controller_page_background_turn_on": "开启后台服务", + "backup_controller_page_background_wifi": "仅 WiFi", "backup_controller_page_backup": "备份", - "backup_controller_page_backup_selected": "已选中:", + "backup_controller_page_backup_selected": "已选中:", "backup_controller_page_backup_sub": "已备份的照片和视频", "backup_controller_page_cancel": "取消", "backup_controller_page_created": "创建时间: {}", - "backup_controller_page_desc_backup": "打开前台备份,程序运行时可以自动备份数据", - "backup_controller_page_excluded": "已排除:", - "backup_controller_page_failed": "失败 ({})", + "backup_controller_page_desc_backup": "打开前台备份,以在程序运行时自动备份。", + "backup_controller_page_excluded": "已排除:", + "backup_controller_page_failed": "失败({})", "backup_controller_page_filename": "文件名称: {} [{}]", "backup_controller_page_id": "ID: {}", "backup_controller_page_info": "备份信息", @@ -82,199 +85,209 @@ "backup_controller_page_status_on": "前台自动备份已开启", "backup_controller_page_storage_format": "{}/{} 已使用", "backup_controller_page_to_backup": "要备份的相册", - "backup_controller_page_total": "合计", - "backup_controller_page_total_sub": "选中相册中的所有不重复的视频和图片", + "backup_controller_page_total": "总计", + "backup_controller_page_total_sub": "选中相册中的所有不重复的视频和图像", "backup_controller_page_turn_off": "关闭前台备份", "backup_controller_page_turn_on": "开启前台备份", "backup_controller_page_uploading_file_info": "正在上传文件信息", - "backup_err_only_album": "不能移除惟一的一个相册", + "backup_err_only_album": "不能移除唯一的一个相册", "backup_info_card_assets": "张", - "cache_settings_album_thumbnails": "图库缩略图({}张)", + "cache_settings_album_thumbnails": "图库缩略图({} 张)", "cache_settings_clear_cache_button": "清除缓存", - "cache_settings_clear_cache_button_title": "清除应用程序的缓存。在重新生成缓存之前,这将显著影响应用的性能。", - "cache_settings_image_cache_size": "图片缓存大小({}张)", + "cache_settings_clear_cache_button_title": "清除应用缓存。在重新生成缓存之前,将显著影响应用的性能。", + "cache_settings_image_cache_size": "图像缓存大小({} 张)", "cache_settings_statistics_album": "图库缩略图", - "cache_settings_statistics_assets": "{} 张 ({})", - "cache_settings_statistics_full": "完整图片", + "cache_settings_statistics_assets": "{} 张({})", + "cache_settings_statistics_full": "完整图像", "cache_settings_statistics_shared": "共享相册缩略图", "cache_settings_statistics_thumbnail": "缩略图", "cache_settings_statistics_title": "缓存使用情况", - "cache_settings_subtitle": "控制 Immich的缓存表现", - "cache_settings_thumbnail_size": "缩略图缓存大小({}张)", + "cache_settings_subtitle": "控制 Immich 的缓存行为", + "cache_settings_thumbnail_size": "缩略图缓存大小({} 张)", "cache_settings_title": "缓存设置", "change_password_form_confirm_password": "确认密码", - "change_password_form_description": "嗨 {firstName} {lastName},\n\n这是您第一次登录系统,或者管理员要求您更改密码。 请在下面输入新密码。", + "change_password_form_description": "{firstName} {lastName} 您好,\n\n这是您首次登录系统,或被管理员要求更改密码。\n请在下方输入新密码。", "change_password_form_new_password": "新密码", "change_password_form_password_mismatch": "密码不匹配", "change_password_form_reenter_new_password": "重新输入新的密码", "common_add_to_album": "添加到相册", "common_change_password": "更改密码", - "common_create_new_album": "创建新相册", - "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", + "common_create_new_album": "新建相册", + "common_server_error": "请检查您的网络连接,确保服务器可访问且该应用程序或服务器版本兼容。", "common_shared": "共享", "control_bottom_app_bar_add_to_album": "添加到相册", - "control_bottom_app_bar_album_info": "{}张", - "control_bottom_app_bar_album_info_shared": "{} 张已分享", - "control_bottom_app_bar_archive": "Archive", + "control_bottom_app_bar_album_info": "{} 项", + "control_bottom_app_bar_album_info_shared": "{} 项 · 已共享", + "control_bottom_app_bar_archive": "归档", "control_bottom_app_bar_create_new_album": "新建相册", "control_bottom_app_bar_delete": "删除", "control_bottom_app_bar_favorite": "收藏", - "control_bottom_app_bar_share": "分享", - "control_bottom_app_bar_unarchive": "Unarchive", + "control_bottom_app_bar_share": "共享", + "control_bottom_app_bar_unarchive": "取消归档", "create_album_page_untitled": "未命名", - "create_shared_album_page_create": "新建", - "create_shared_album_page_share": "分享", - "create_shared_album_page_share_add_assets": "新增照片", - "create_shared_album_page_share_select_photos": "选择照片", - "curated_location_page_title": "Places", - "curated_object_page_title": "Things", + "create_shared_album_page_create": "创建", + "create_shared_album_page_share": "共享", + "create_shared_album_page_share_add_assets": "添加项目", + "create_shared_album_page_share_select_photos": "选择项目", + "curated_location_page_title": "地点", + "curated_object_page_title": "事物", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", - "delete_dialog_alert": "这些数据将会永久性的从Immich和你的设备上删除", + "delete_dialog_alert": "这些项目将从 Immich 和您的设备中永久删除", "delete_dialog_cancel": "取消", "delete_dialog_ok": "删除", "delete_dialog_title": "永久删除", - "description_input_hint_text": "Add description...", - "description_input_submit_error": "Error updating description, check the log for more details", - "exif_bottom_sheet_description": "增加描述...", + "description_input_hint_text": "添加描述...", + "description_input_submit_error": "更新描述时出错,请检查日志以获取更多详细信息", + "exif_bottom_sheet_description": "添加描述...", "exif_bottom_sheet_details": "详情", "exif_bottom_sheet_location": "位置", - "experimental_settings_new_asset_list_subtitle": "正在努力处理中", - "experimental_settings_new_asset_list_title": "启用实验性的照片宫格", + "experimental_settings_new_asset_list_subtitle": "正在处理", + "experimental_settings_new_asset_list_title": "启用实验性照片网格", "experimental_settings_subtitle": "使用风险自负!", - "experimental_settings_title": "实验功能", - "favorites_page_no_favorites": "No favorite assets found", + "experimental_settings_title": "实验性功能", + "favorites_page_no_favorites": "未找到收藏项目", "favorites_page_title": "收藏", - "home_page_add_to_album_conflicts": "添加{added}张到相册{album}。{failed} 项已经处于该相册中。", - "home_page_add_to_album_err_local": "无法在相册中收藏本地的照片或视频,跳过", - "home_page_add_to_album_success": "添加了{added}张到相册{album}。", - "home_page_archive_err_local": "Can not archive local assets yet, skipping", - "home_page_building_timeline": "生成时间线", - "home_page_favorite_err_local": "还不能收藏本地的照片或视频,跳过", - "home_page_first_time_notice": "如果这是您第一次使用该应用程序,请确保选择一个想要备份的本地相册,以便可以在时间线中预览该相册中的照片和视频。", + "home_page_add_to_album_conflicts": "已向相册 {album} 中添加 {added} 项。\n其中 {failed} 项在相册中已存在。", + "home_page_add_to_album_err_local": "暂不能将本地项目添加到相册中,跳过", + "home_page_add_to_album_success": "已向相册 {album} 中添加 {added} 项。", + "home_page_archive_err_local": "暂无法归档本地项目,跳过", + "home_page_building_timeline": "正在生成时间线", + "home_page_favorite_err_local": "暂不能收藏本地项目,跳过", + "home_page_first_time_notice": "如果这是您第一次使用该应用程序,请确保选择一个要备份的本地相册,以便可以在时间线中预览该相册中的照片和视频。", "image_viewer_page_state_provider_download_error": "下载出现错误", "image_viewer_page_state_provider_download_success": "下载成功", "library_page_albums": "相册", - "library_page_archive": "Archive", - "library_page_device_albums": "Albums on Device", + "library_page_archive": "归档", + "library_page_device_albums": "设备上的相册", "library_page_favorites": "收藏", "library_page_new_album": "新建相册", "library_page_sharing": "共享", "library_page_sort_created": "最近创建的", "library_page_sort_title": "相册标题", - "login_form_api_exception": "API exception. Please check the server URL and try again.", + "login_form_api_exception": "API 异常,请检查服务器地址并重试。", "login_form_button_text": "登录", "login_form_email_hint": "youremail@email.com", - "login_form_endpoint_hint": "http://your-server-ip:port/api", - "login_form_endpoint_url": "服务器地址", - "login_form_err_http": "请检查http://或https://", - "login_form_err_invalid_email": "请输入正确的邮箱", - "login_form_err_invalid_url": "无效的URL", - "login_form_err_leading_whitespace": "前面空格", - "login_form_err_trailing_whitespace": "后面空格", - "login_form_failed_get_oauth_server_config": "使用 OAuth 时出错,请检查服务器 地址", + "login_form_endpoint_hint": "http(s)://你的服务器地址:端口/api", + "login_form_endpoint_url": "服务器终结点地址", + "login_form_err_http": "请注明 http:// 或 https://", + "login_form_err_invalid_email": "无效的电子邮件", + "login_form_err_invalid_url": "无效的地址", + "login_form_err_leading_whitespace": "带有前导空格", + "login_form_err_trailing_whitespace": "带有尾随空格", + "login_form_failed_get_oauth_server_config": "使用 OAuth 登录时错误,请检查服务器地址", "login_form_failed_get_oauth_server_disable": "OAuth 功能在此服务器上不可用", - "login_form_failed_login": "登录失败, 请检查邮箱、密码和服务器地址", + "login_form_failed_login": "登录失败, 请检查服务器地址、邮箱和密码", "login_form_label_email": "邮箱", "login_form_label_password": "密码", - "login_form_next_button": "Next", + "login_form_next_button": "下一个", "login_form_password_hint": "密码", "login_form_save_login": "保持登录", - "login_form_server_empty": "Enter a server URL.", - "login_form_server_error": "Could not connect to server.", + "login_form_server_empty": "输入服务器地址。", + "login_form_server_error": "无法连接到服务器。", "monthly_title_text_date_format": "MMMM y", - "motion_photos_page_title": "Motion Photos", + "motion_photos_page_title": "动图", "notification_permission_dialog_cancel": "取消", - "notification_permission_dialog_content": "要启用通知,请转到“设置”并选择“允许”。", + "notification_permission_dialog_content": "要启用通知,请转到“设置”,并选择“允许”。", "notification_permission_dialog_settings": "设置", "notification_permission_list_tile_content": "授予启用通知的权限。", - "notification_permission_list_tile_enable_button": "允许通知", + "notification_permission_list_tile_enable_button": "启用通知", "notification_permission_list_tile_title": "通知权限", - "permission_onboarding_continue_anyway": "Continue anyway", - "permission_onboarding_get_started": "Get started", - "permission_onboarding_go_to_settings": "Go to settings", - "permission_onboarding_grant_permission": "Grant permission", - "permission_onboarding_log_out": "Log out", - "permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.", - "permission_onboarding_permission_granted": "Permission granted! You are all set.", - "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", - "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", + "permission_onboarding_continue_anyway": "仍然继续", + "permission_onboarding_get_started": "开始使用", + "permission_onboarding_go_to_settings": "转到设置", + "permission_onboarding_grant_permission": "授予权限", + "permission_onboarding_log_out": "注销", + "permission_onboarding_permission_denied": "权限被拒:要使用 Immich,请在“设置”中授予照片和视频权限。", + "permission_onboarding_permission_granted": "已授权!一切就绪。", + "permission_onboarding_permission_limited": "权限有限:要让 Immich 备份和管理您的整个图库收藏,请在“设置”中授予照片和视频权限。", + "permission_onboarding_request": "Immich 需要权限才能查看您的照片和视频。", "profile_drawer_app_logs": "日志", "profile_drawer_client_server_up_to_date": "客户端和服务端都是最新的", "profile_drawer_settings": "设置", "profile_drawer_sign_out": "退出登录", - "recently_added_page_title": "Recently Added", + "recently_added_page_title": "最近添加", "search_bar_hint": "搜索照片", - "search_page_categories": "Categories", - "search_page_favorites": "Favorites", - "search_page_motion_photos": "Motion Photos", + "search_page_categories": "类别", + "search_page_favorites": "收藏", + "search_page_motion_photos": "动图", "search_page_no_objects": "没有事物信息", "search_page_no_places": "地点信息不存在", + "search_page_people": "People", "search_page_places": "地点", - "search_page_recently_added": "Recently added", - "search_page_screenshots": "Screenshots", - "search_page_selfies": "Selfies", + "search_page_recently_added": "最近添加", + "search_page_screenshots": "屏幕截图", + "search_page_selfies": "自拍", "search_page_things": "事物", - "search_page_videos": "Videos", - "search_page_view_all_button": "View all", - "search_page_your_activity": "Your activity", + "search_page_videos": "视频", + "search_page_view_all_button": "查看全部", + "search_page_your_activity": "您的活动", "search_result_page_new_search_hint": "搜索新的", - "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", - "search_suggestion_list_smart_search_hint_2": "m:your-search-term", + "search_suggestion_list_smart_search_hint_1": "默认情况下启用智能搜索;要搜索元数据,请使用相关语法", + "search_suggestion_list_smart_search_hint_2": "m:你的搜索关键词", "select_additional_user_for_sharing_page_suggestions": "建议", "select_user_for_sharing_page_err_album": "创建相册失败", "select_user_for_sharing_page_share_suggestions": "建议", "server_info_box_app_version": "App 版本", "server_info_box_server_version": "服务器版本", - "setting_image_viewer_help": "查看大图时会首先加载缩略图,然后加载中等质量的图片(如果启用),最后加载原始质量的图片(如果启用)。", - "setting_image_viewer_original_subtitle": "开启将会加载原图。关闭将会减少内存和网络占用。", + "setting_image_viewer_help": "详细信息查看器首先加载小缩略图,然后加载中等大小的预览图(若启用),最后加载原始图像。", + "setting_image_viewer_original_subtitle": "启用以加载原图,禁用以减少数据使用量(网络和设备缓存)。", "setting_image_viewer_original_title": "加载原图", - "setting_image_viewer_preview_subtitle": "开启将会加载中等质量的图片,关闭后会加载原图或预览图。", - "setting_image_viewer_preview_title": "加载中等质量图片", - "setting_notifications_notify_failures_grace_period": "后台备份失败通知: {}", - "setting_notifications_notify_hours": "{}小时", + "setting_image_viewer_preview_subtitle": "启用以加载中等质量的图像,禁用以加载原图或缩略图。", + "setting_image_viewer_preview_title": "加载预览图", + "setting_notifications_notify_failures_grace_period": "后台备份失败通知:{}", + "setting_notifications_notify_hours": "{} 小时", "setting_notifications_notify_immediately": "立即", - "setting_notifications_notify_minutes": "{}分钟", + "setting_notifications_notify_minutes": "{} 分钟", "setting_notifications_notify_never": "从不", "setting_notifications_notify_seconds": "{} 秒", - "setting_notifications_single_progress_subtitle": "每张图片的详细备份进度", - "setting_notifications_single_progress_title": "总体上传进度(已完成/所有内容)", - "setting_notifications_subtitle": "调整您的通知偏好", + "setting_notifications_single_progress_subtitle": "每项的详细上传进度信息", + "setting_notifications_single_progress_title": "显示后台备份详细进度", + "setting_notifications_subtitle": "调整通知首选项", "setting_notifications_title": "通知", - "setting_notifications_total_progress_subtitle": "总体上传进度(已完成/所有内容)", - "setting_notifications_total_progress_title": "展示后台整体备份进度", + "setting_notifications_total_progress_subtitle": "总体上传进度(已完成/总计)", + "setting_notifications_total_progress_title": "显示后台备份总进度", "setting_pages_app_bar_settings": "设置", - "settings_require_restart": "请重启Immich使配置生效", - "share_add": "新增", - "share_add_photos": "新增照片", - "share_add_title": "新增标题", - "share_create_album": "新建相册", - "share_dialog_preparing": "准备中...", - "share_invite": "邀请共享相册", + "settings_require_restart": "请重启 Immich 以使设置生效", + "share_add": "添加", + "share_add_photos": "添加项目", + "share_add_title": "添加标题", + "share_create_album": "创建相册", + "share_dialog_preparing": "这种准备...", + "share_invite": "邀请相册共享", "sharing_page_album": "共享相册", - "sharing_page_description": "新建共享相册以分享图片和视频给你的网络中的其他人。", + "sharing_page_description": "创建共享相册以与网络中的人共享照片和视频。", "sharing_page_empty_list": "空", "sharing_silver_appbar_create_shared_album": "创建共享相册", - "sharing_silver_appbar_share_partner": "分享给同伴", + "sharing_silver_appbar_share_partner": "共享给同伴", "tab_controller_nav_library": "图库", "tab_controller_nav_photos": "照片", "tab_controller_nav_search": "搜索", - "tab_controller_nav_sharing": "分享", - "theme_setting_asset_list_storage_indicator_title": "在图片标题展示存储占用", - "theme_setting_asset_list_tiles_per_row_title": "每行展示({})张图片", - "theme_setting_dark_mode_switch": "黑暗模式", - "theme_setting_image_viewer_quality_subtitle": "查看大图时的图片质量", - "theme_setting_image_viewer_quality_title": "图片质量", - "theme_setting_system_theme_switch": "自动 (跟随系统)", - "theme_setting_theme_subtitle": "选择应用的主题", + "tab_controller_nav_sharing": "共享", + "theme_setting_asset_list_storage_indicator_title": "在项目标题上显示存储占用", + "theme_setting_asset_list_tiles_per_row_title": "每行展示 {} 项", + "theme_setting_dark_mode_switch": "暗黑模式", + "theme_setting_image_viewer_quality_subtitle": "调整查看大图时的图像质量", + "theme_setting_image_viewer_quality_title": "图像质量", + "theme_setting_system_theme_switch": "自动(跟随系统设置)", + "theme_setting_theme_subtitle": "选择应用主题", "theme_setting_theme_title": "主题", - "theme_setting_three_stage_loading_subtitle": "三段式加载可能会提升加载速度,但是可能会造成更高的网络负载", + "theme_setting_three_stage_loading_subtitle": "三段式加载可能会提升加载性能,但可能会导致更高的网络负载", "theme_setting_three_stage_loading_title": "启用三段式加载", - "version_announcement_overlay_ack": "我知道啦", + "version_announcement_overlay_ack": "我知道了", "version_announcement_overlay_release_notes": "发行说明", - "version_announcement_overlay_text_1": "号外号外,", - "version_announcement_overlay_text_2": "发布新版本啦!为避免缺少配置,请您抽出时间访问", - "version_announcement_overlay_text_3": "并检查您的docker-compose和.env是否为最新的。如果您使用WatchTower或者其他自动更新程序方式,您需要更加细致的检查。", + "version_announcement_overlay_text_1": "号外号外,有新版本的", + "version_announcement_overlay_text_2": "请花点时间访问", + "version_announcement_overlay_text_3": "并检查您的 docker-compose 和 .env 是否为最新且正确的配置,特别是您在使用 WatchTower 或者其他自动更新的程序时,您需要更加细致的检查。", "version_announcement_overlay_title": "服务端有新版本啦 \uD83C\uDF89" } \ No newline at end of file From d590dec159c47df82cdcb898a237c5abd411aac4 Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Sat, 8 Jul 2023 22:03:54 +0200 Subject: [PATCH 03/38] Revert "feat(mobile): reduce UI rebuilds (#3129)" (#3159) This reverts commit fe2330ebf607874df66349dd5a9cf42068933427. --- .../home/ui/asset_grid/immich_asset_grid.dart | 109 ++++++++++++------ 1 file changed, 73 insertions(+), 36 deletions(-) diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart index f9f018350..21a33b51c 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart @@ -50,49 +50,86 @@ class ImmichAssetGrid extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final settings = ref.watch(appSettingsServiceProvider); + var settings = ref.watch(appSettingsServiceProvider); + + // Needs to suppress hero animations when navigating to this widget + final enableHeroAnimations = useState(false); + final transitionDuration = ModalRoute.of(context)?.transitionDuration; + final perRow = useState( assetsPerRow ?? settings.getSetting(AppSettingsEnum.tilesPerRow)!, ); final scaleFactor = useState(7.0 - perRow.value); final baseScaleFactor = useState(7.0 - perRow.value); - Widget buildAssetGridView(RenderList renderList) { - return RawGestureDetector( - gestures: { - CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers< - CustomScaleGestureRecognizer>( - () => CustomScaleGestureRecognizer(), - (CustomScaleGestureRecognizer scale) { - scale.onStart = (details) { - baseScaleFactor.value = scaleFactor.value; - }; + useEffect( + () { + // Wait for transition to complete, then re-enable + if (transitionDuration == null) { + // No route transition found, maybe we opened this up first + enableHeroAnimations.value = true; + } else { + // Unfortunately, using the transition animation itself didn't + // seem to work reliably. So instead, wait until the duration of the + // animation has elapsed to re-enable the hero animations + Future.delayed(transitionDuration).then((_) { + enableHeroAnimations.value = true; + }); + } + return null; + }, + [], + ); - scale.onUpdate = (details) { - scaleFactor.value = - max(min(5.0, baseScaleFactor.value * details.scale), 1.0); - if (7 - scaleFactor.value.toInt() != perRow.value) { - perRow.value = 7 - scaleFactor.value.toInt(); - } - }; - }) - }, - child: ImmichAssetGridView( - onRefresh: onRefresh, - assetsPerRow: perRow.value, - listener: listener, - showStorageIndicator: showStorageIndicator ?? - settings.getSetting(AppSettingsEnum.storageIndicator), - renderList: renderList, - margin: margin, - selectionActive: selectionActive, - preselectedAssets: preselectedAssets, - canDeselect: canDeselect, - dynamicLayout: dynamicLayout ?? - settings.getSetting(AppSettingsEnum.dynamicLayout), - showMultiSelectIndicator: showMultiSelectIndicator, - visibleItemsListener: visibleItemsListener, - topWidget: topWidget, + Future onWillPop() async { + enableHeroAnimations.value = false; + return true; + } + + Widget buildAssetGridView(RenderList renderList) { + return WillPopScope( + onWillPop: onWillPop, + child: HeroMode( + enabled: enableHeroAnimations.value, + child: RawGestureDetector( + gestures: { + CustomScaleGestureRecognizer: + GestureRecognizerFactoryWithHandlers< + CustomScaleGestureRecognizer>( + () => CustomScaleGestureRecognizer(), + (CustomScaleGestureRecognizer scale) { + scale.onStart = (details) { + baseScaleFactor.value = scaleFactor.value; + }; + + scale.onUpdate = (details) { + scaleFactor.value = + max(min(5.0, baseScaleFactor.value * details.scale), 1.0); + if (7 - scaleFactor.value.toInt() != perRow.value) { + perRow.value = 7 - scaleFactor.value.toInt(); + } + }; + scale.onEnd = (details) {}; + }) + }, + child: ImmichAssetGridView( + onRefresh: onRefresh, + assetsPerRow: perRow.value, + listener: listener, + showStorageIndicator: showStorageIndicator ?? + settings.getSetting(AppSettingsEnum.storageIndicator), + renderList: renderList, + margin: margin, + selectionActive: selectionActive, + preselectedAssets: preselectedAssets, + canDeselect: canDeselect, + dynamicLayout: dynamicLayout ?? + settings.getSetting(AppSettingsEnum.dynamicLayout), + showMultiSelectIndicator: showMultiSelectIndicator, + visibleItemsListener: visibleItemsListener, + topWidget: topWidget, + ), + ), ), ); } From a5cc40846938e713cf3c7147c58738fc722f7f55 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Sat, 8 Jul 2023 16:07:56 -0400 Subject: [PATCH 04/38] fix(server): thumbnail content type not being passed to stream handle (#3137) * asset mimetype instead of application/octet-stream * use thumbnail mimetype instead * narrowed openapi spec * thumbnail format validation * JPEG fallback, `getThumbnailPath` returns format * return content type in `getThumbnailPath` * moved `format` validation to dto * removed unused import * moved fallback warning * added `ApiOkResponse` --- mobile/openapi/doc/AssetApi.md | 2 +- mobile/openapi/doc/PersonApi.md | 2 +- server/immich-openapi-specs.json | 10 ++++++++-- server/src/immich/api-v1/asset/asset.controller.ts | 13 +++++++++++-- server/src/immich/api-v1/asset/asset.service.ts | 13 +++++++------ .../api-v1/asset/dto/get-asset-thumbnail.dto.ts | 3 ++- server/src/immich/controllers/person.controller.ts | 6 +++++- 7 files changed, 35 insertions(+), 14 deletions(-) diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 319deb2ac..c71b4151d 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -822,7 +822,7 @@ Name | Type | Description | Notes ### HTTP request headers - **Content-Type**: Not defined - - **Accept**: application/octet-stream + - **Accept**: image/jpeg, image/webp [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/PersonApi.md b/mobile/openapi/doc/PersonApi.md index dd1c0eb8e..aa37a294e 100644 --- a/mobile/openapi/doc/PersonApi.md +++ b/mobile/openapi/doc/PersonApi.md @@ -228,7 +228,7 @@ Name | Type | Description | Notes ### HTTP request headers - **Content-Type**: Not defined - - **Accept**: application/octet-stream + - **Accept**: image/jpeg [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index caae449e2..5f664730f 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -1673,7 +1673,13 @@ "responses": { "200": { "content": { - "application/octet-stream": { + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/webp": { "schema": { "type": "string", "format": "binary" @@ -2704,7 +2710,7 @@ "responses": { "200": { "content": { - "application/octet-stream": { + "image/jpeg": { "schema": { "type": "string", "format": "binary" diff --git a/server/src/immich/api-v1/asset/asset.controller.ts b/server/src/immich/api-v1/asset/asset.controller.ts index c2d3e6b39..99f6d02ab 100644 --- a/server/src/immich/api-v1/asset/asset.controller.ts +++ b/server/src/immich/api-v1/asset/asset.controller.ts @@ -122,7 +122,11 @@ export class AssetController { @SharedLinkRoute() @Get('/file/:id') @Header('Cache-Control', 'private, max-age=86400, no-transform') - @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) + @ApiOkResponse({ + content: { + 'application/octet-stream': { schema: { type: 'string', format: 'binary' } }, + }, + }) serveFile( @AuthUser() authUser: AuthUserDto, @Headers() headers: Record, @@ -136,7 +140,12 @@ export class AssetController { @SharedLinkRoute() @Get('/thumbnail/:id') @Header('Cache-Control', 'private, max-age=86400, no-transform') - @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) + @ApiOkResponse({ + content: { + 'image/jpeg': { schema: { type: 'string', format: 'binary' } }, + 'image/webp': { schema: { type: 'string', format: 'binary' } }, + }, + }) getAssetThumbnail( @AuthUser() authUser: AuthUserDto, @Headers() headers: Record, diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index 0a1ee8e0e..26c0ca7bb 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -256,8 +256,8 @@ export class AssetService { } try { - const thumbnailPath = this.getThumbnailPath(asset, query.format); - return this.streamFile(thumbnailPath, res, headers); + const [thumbnailPath, contentType] = this.getThumbnailPath(asset, query.format); + return this.streamFile(thumbnailPath, res, headers, contentType); } catch (e) { res.header('Cache-Control', 'none'); this.logger.error(`Cannot create read stream for asset ${asset.id}`, 'getAssetThumbnail'); @@ -522,16 +522,17 @@ export class AssetService { private getThumbnailPath(asset: AssetEntity, format: GetAssetThumbnailFormatEnum) { switch (format) { case GetAssetThumbnailFormatEnum.WEBP: - if (asset.webpPath && asset.webpPath.length > 0) { - return asset.webpPath; + if (asset.webpPath) { + return [asset.webpPath, 'image/webp']; } + this.logger.warn(`WebP thumbnail requested but not found for asset ${asset.id}, falling back to JPEG`); case GetAssetThumbnailFormatEnum.JPEG: default: if (!asset.resizePath) { - throw new NotFoundException('resizePath not set'); + throw new NotFoundException(`No thumbnail found for asset ${asset.id}`); } - return asset.resizePath; + return [asset.resizePath, 'image/jpeg']; } } diff --git a/server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts b/server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts index 5a8dc0687..ad0e755d6 100644 --- a/server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts +++ b/server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsOptional } from 'class-validator'; +import { IsEnum, IsOptional } from 'class-validator'; export enum GetAssetThumbnailFormatEnum { JPEG = 'JPEG', @@ -8,6 +8,7 @@ export enum GetAssetThumbnailFormatEnum { export class GetAssetThumbnailDto { @IsOptional() + @IsEnum(GetAssetThumbnailFormatEnum) @ApiProperty({ type: String, enum: GetAssetThumbnailFormatEnum, diff --git a/server/src/immich/controllers/person.controller.ts b/server/src/immich/controllers/person.controller.ts index 575230459..6eb58844f 100644 --- a/server/src/immich/controllers/person.controller.ts +++ b/server/src/immich/controllers/person.controller.ts @@ -43,7 +43,11 @@ export class PersonController { } @Get(':id/thumbnail') - @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) + @ApiOkResponse({ + content: { + 'image/jpeg': { schema: { type: 'string', format: 'binary' } }, + }, + }) getPersonThumbnail(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) { return this.service.getThumbnail(authUser, id).then(asStreamableFile); } From 64697235d632d513d98151451742aabb38274698 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 8 Jul 2023 15:26:26 -0500 Subject: [PATCH 05/38] feat(mobile): adding additional languages (#3161) * update locale * localizely * Update info.plist --------- Co-authored-by: Alex Tran --- localizely.yml | 68 ++++++++++++++++++++++--------- mobile/ios/Runner/Info.plist | 34 +++++++++++----- mobile/lib/constants/locales.dart | 32 +++++++++++---- 3 files changed, 95 insertions(+), 39 deletions(-) diff --git a/localizely.yml b/localizely.yml index c2b76f392..a8b9c89db 100644 --- a/localizely.yml +++ b/localizely.yml @@ -12,37 +12,65 @@ download: files: - file: mobile/assets/i18n/en-US.json locale_code: en-US - - file: mobile/assets/i18n/da-DK.json - locale_code: da-DK - file: mobile/assets/i18n/de-DE.json locale_code: de-DE - - file: mobile/assets/i18n/fr-FR.json - locale_code: fr-FR + - file: mobile/assets/i18n/da-DK.json + locale_code: da-DK - file: mobile/assets/i18n/it-IT.json locale_code: it-IT - - file: mobile/assets/i18n/nl-NL.json - locale_code: nl-NL - - file: mobile/assets/i18n/ko-KR.json - locale_code: ko-KR - file: mobile/assets/i18n/es-ES.json locale_code: es-ES - - file: mobile/assets/i18n/fi-FI.json - locale_code: fi-FI + - file: mobile/assets/i18n/vi-VN.json + locale_code: vi-VN + - file: mobile/assets/i18n/fr-FR.json + locale_code: fr-FR - file: mobile/assets/i18n/ja-JP.json locale_code: ja-JP - - file: mobile/assets/i18n/pt-BR.json - locale_code: pt-BR - file: mobile/assets/i18n/pl-PL.json locale_code: pl-PL - - file: mobile/assets/i18n/sv-SE.json - locale_code: sv-SE - - file: mobile/assets/i18n/sk-SK.json - locale_code: sk-SK - - file: mobile/assets/i18n/zh-CN.json - locale_code: zh-CN - - file: mobile/assets/i18n/ru-RU.json - locale_code: ru-RU + - file: mobile/assets/i18n/fi-FI.json + locale_code: fi-FI + - file: mobile/assets/i18n/pt-BR.json + locale_code: pt-BR - file: mobile/assets/i18n/cs-CZ.json locale_code: cs-CZ + - file: mobile/assets/i18n/uk-UA.json + locale_code: uk-UA + - file: mobile/assets/i18n/ru-RU.json + locale_code: ru-RU + - file: mobile/assets/i18n/zh-CN.json + locale_code: zh-CN + - file: mobile/assets/i18n/sk-SK.json + locale_code: sk-SK + - file: mobile/assets/i18n/nl-NL.json + locale_code: nl-NL - file: mobile/assets/i18n/nb-NO.json locale_code: nb-NO + - file: mobile/assets/i18n/sv-SE.json + locale_code: sv-SE + - file: mobile/assets/i18n/mn.json + locale_code: mn + - file: mobile/assets/i18n/ko-KR.json + locale_code: ko-KR + - file: mobile/assets/i18n/sr-Latn.json + locale_code: sr-Latn + - file: mobile/assets/i18n/sr-Cyrl.json + locale_code: sr-Cyrl + - file: mobile/assets/i18n/hi-IN.json + locale_code: hi-IN + - file: mobile/assets/i18n/es-PE.json + locale_code: es-PE + - file: mobile/assets/i18n/es-MX.json + locale_code: es-MX + - file: mobile/assets/i18n/sv-FI.json + locale_code: sv-FI + - file: mobile/assets/i18n/ca.json + locale_code: ca + - file: mobile/assets/i18n/hu-HU.json + locale_code: hu-HU + - file: mobile/assets/i18n/lv-LV.json + locale_code: lv-LV + - file: mobile/assets/i18n/zh-Hans.json + locale_code: zh-Hans + - file: mobile/assets/i18n/th-TH.json + locale_code: th-TH diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 044a04688..508bebe2f 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -21,24 +21,38 @@ 6.0 CFBundleLocalizations - cs - da - de en - es - fi - fr + de + da it + es + vi + fr ja - ko - nl pl + fi pt + cs + uk ru - se - sk zh + sk + nl nb + sv + mn + ko + sr + sr + hi + es + es + sv + ca + hu + lv + zh + th CFBundleName immich_mobile diff --git a/mobile/lib/constants/locales.dart b/mobile/lib/constants/locales.dart index 16e15191e..452203d51 100644 --- a/mobile/lib/constants/locales.dart +++ b/mobile/lib/constants/locales.dart @@ -4,23 +4,37 @@ const List locales = [ // Default locale Locale('en', 'US'), // Additional locales - Locale('cs', 'CZ'), - Locale('da', 'DK'), Locale('de', 'DE'), - Locale('es', 'ES'), - Locale('fi', 'FI'), - Locale('fr', 'FR'), + Locale('da', 'DK'), Locale('it', 'IT'), + Locale('es', 'ES'), + Locale('vi', 'VN'), + Locale('fr', 'FR'), Locale('ja', 'JP'), - Locale('ko', 'KR'), - Locale('nl', 'NL'), Locale('pl', 'PL'), + Locale('fi', 'FI'), Locale('pt', 'PR'), + Locale('cs', 'CZ'), + Locale('uk', 'UA'), Locale('ru', 'RU'), - Locale('sv', 'SE'), - Locale('sk', 'SK'), Locale('zh', 'CN'), + Locale('sk', 'SK'), + Locale('nl', 'NL'), Locale('nb', 'NO'), + Locale('sv', 'SE'), + Locale('mn', 'MN'), + Locale('ko', 'KR'), + Locale('sr', 'Latn'), + Locale('sr', 'Cyrl'), + Locale('hi', 'IN'), + Locale('es', 'PE'), + Locale('es', 'MX'), + Locale('sv', 'FI'), + Locale('ca', 'CA'), + Locale('hu', 'HU'), + Locale('lv', 'LV'), + Locale('zh', 'Hans'), + Locale('th', 'TH'), ]; const String translationsPath = 'assets/i18n'; From 73e82303e7db6f4192eb74ae0aa60ac7255c93e0 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 8 Jul 2023 15:27:24 -0500 Subject: [PATCH 06/38] [Localizely] Translations update (#3162) --- mobile/assets/i18n/ca.json | 293 ++++++++++++++++++++++++++++++++ mobile/assets/i18n/da-DK.json | 24 +-- mobile/assets/i18n/es-MX.json | 293 ++++++++++++++++++++++++++++++++ mobile/assets/i18n/es-PE.json | 293 ++++++++++++++++++++++++++++++++ mobile/assets/i18n/hi-IN.json | 293 ++++++++++++++++++++++++++++++++ mobile/assets/i18n/hu-HU.json | 293 ++++++++++++++++++++++++++++++++ mobile/assets/i18n/lv-LV.json | 293 ++++++++++++++++++++++++++++++++ mobile/assets/i18n/mn.json | 293 ++++++++++++++++++++++++++++++++ mobile/assets/i18n/nl-NL.json | 24 +-- mobile/assets/i18n/sr-Cyrl.json | 293 ++++++++++++++++++++++++++++++++ mobile/assets/i18n/sr-Latn.json | 293 ++++++++++++++++++++++++++++++++ mobile/assets/i18n/sv-FI.json | 293 ++++++++++++++++++++++++++++++++ mobile/assets/i18n/th-TH.json | 293 ++++++++++++++++++++++++++++++++ mobile/assets/i18n/uk-UA.json | 293 ++++++++++++++++++++++++++++++++ mobile/assets/i18n/vi-VN.json | 293 ++++++++++++++++++++++++++++++++ mobile/assets/i18n/zh-CN.json | 26 +-- mobile/assets/i18n/zh-Hans.json | 293 ++++++++++++++++++++++++++++++++ 17 files changed, 4139 insertions(+), 37 deletions(-) create mode 100644 mobile/assets/i18n/ca.json create mode 100644 mobile/assets/i18n/es-MX.json create mode 100644 mobile/assets/i18n/es-PE.json create mode 100644 mobile/assets/i18n/hi-IN.json create mode 100644 mobile/assets/i18n/hu-HU.json create mode 100644 mobile/assets/i18n/lv-LV.json create mode 100644 mobile/assets/i18n/mn.json create mode 100644 mobile/assets/i18n/sr-Cyrl.json create mode 100644 mobile/assets/i18n/sr-Latn.json create mode 100644 mobile/assets/i18n/sv-FI.json create mode 100644 mobile/assets/i18n/th-TH.json create mode 100644 mobile/assets/i18n/uk-UA.json create mode 100644 mobile/assets/i18n/vi-VN.json create mode 100644 mobile/assets/i18n/zh-Hans.json diff --git a/mobile/assets/i18n/ca.json b/mobile/assets/i18n/ca.json new file mode 100644 index 000000000..d0d689205 --- /dev/null +++ b/mobile/assets/i18n/ca.json @@ -0,0 +1,293 @@ +{ + "add_to_album_bottom_sheet_added": "Added to {album}", + "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_tile_subtitle": "Advanced user's settings", + "advanced_settings_tile_title": "Avançat", + "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", + "advanced_settings_troubleshooting_title": "Resolució de problemes", + "album_info_card_backup_album_excluded": "EXCLUDED", + "album_info_card_backup_album_included": "INCLUDED", + "album_thumbnail_card_item": "1 item", + "album_thumbnail_card_items": "{} items", + "album_thumbnail_card_shared": " · Shared", + "album_thumbnail_owned": "Owned", + "album_thumbnail_shared_by": "Compartit per {}", + "album_viewer_appbar_share_delete": "Delete album", + "album_viewer_appbar_share_err_delete": "Failed to delete album", + "album_viewer_appbar_share_err_leave": "Failed to leave album", + "album_viewer_appbar_share_err_remove": "There are problems in removing assets from album", + "album_viewer_appbar_share_err_title": "Failed to change album title", + "album_viewer_appbar_share_leave": "Leave album", + "album_viewer_appbar_share_remove": "Remove from album", + "album_viewer_page_share_add_users": "Add users", + "all_people_page_title": "People", + "all_videos_page_title": "Vídeos", + "archive_page_no_archived_assets": "No s'ha trobat res arxivat", + "archive_page_title": "Arxiu({})", + "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout", + "asset_list_layout_settings_group_automatically": "Automatic", + "asset_list_layout_settings_group_by": "Group assets by", + "asset_list_layout_settings_group_by_month": "Month", + "asset_list_layout_settings_group_by_month_day": "Month + day", + "asset_list_settings_subtitle": "Photo grid layout settings", + "asset_list_settings_title": "Photo Grid", + "backup_album_selection_page_albums_device": "Albums on device ({})", + "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", + "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", + "backup_album_selection_page_select_albums": "Select albums", + "backup_album_selection_page_selection_info": "Selection Info", + "backup_album_selection_page_total_assets": "Total unique assets", + "backup_all": "All", + "backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…", + "backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…", + "backup_background_service_current_upload_notification": "Uploading {}", + "backup_background_service_default_notification": "Checking for new assets…", + "backup_background_service_error_title": "Backup error", + "backup_background_service_in_progress_notification": "Backing up your assets…", + "backup_background_service_upload_failure_notification": "Failed to upload {}", + "backup_controller_page_albums": "Backup Albums", + "backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.", + "backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled", + "backup_controller_page_background_app_refresh_enable_button_text": "Go to settings", + "backup_controller_page_background_battery_info_link": "Show me how", + "backup_controller_page_background_battery_info_message": "For the best background backup experience, please disable any battery optimizations restricting background activity for Immich.\n\nSince this is device-specific, please lookup the required information for your device manufacturer.", + "backup_controller_page_background_battery_info_ok": "OK", + "backup_controller_page_background_battery_info_title": "Battery optimizations", + "backup_controller_page_background_charging": "Only while charging", + "backup_controller_page_background_configure_error": "Failed to configure the background service", + "backup_controller_page_background_delay": "Delay new assets backup: {}", + "backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app", + "backup_controller_page_background_is_off": "Automatic background backup is off", + "backup_controller_page_background_is_on": "Automatic background backup is on", + "backup_controller_page_background_turn_off": "Turn off background service", + "backup_controller_page_background_turn_on": "Turn on background service", + "backup_controller_page_background_wifi": "Only on WiFi", + "backup_controller_page_backup": "Backup", + "backup_controller_page_backup_selected": "Selected: ", + "backup_controller_page_backup_sub": "Backed up photos and videos", + "backup_controller_page_cancel": "Cancel", + "backup_controller_page_created": "Created on: {}", + "backup_controller_page_desc_backup": "Turn on foreground backup to automatically upload new assets to the server when opening the app.", + "backup_controller_page_excluded": "Excluded: ", + "backup_controller_page_failed": "Failed ({})", + "backup_controller_page_filename": "File name: {} [{}]", + "backup_controller_page_id": "ID: {}", + "backup_controller_page_info": "Backup Information", + "backup_controller_page_none_selected": "None selected", + "backup_controller_page_remainder": "Remainder", + "backup_controller_page_remainder_sub": "Remaining photos and videos to back up from selection", + "backup_controller_page_select": "Select", + "backup_controller_page_server_storage": "Server Storage", + "backup_controller_page_start_backup": "Start Backup", + "backup_controller_page_status_off": "Automatic foreground backup is off", + "backup_controller_page_status_on": "Automatic foreground backup is on", + "backup_controller_page_storage_format": "{} of {} used", + "backup_controller_page_to_backup": "Albums to be backup", + "backup_controller_page_total": "Total", + "backup_controller_page_total_sub": "All unique photos and videos from selected albums", + "backup_controller_page_turn_off": "Turn off foreground backup", + "backup_controller_page_turn_on": "Turn on foreground backup", + "backup_controller_page_uploading_file_info": "Uploading file info", + "backup_err_only_album": "Cannot remove the only album", + "backup_info_card_assets": "assets", + "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", + "cache_settings_clear_cache_button": "Clear cache", + "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", + "cache_settings_image_cache_size": "Image cache size ({} assets)", + "cache_settings_statistics_album": "Library thumbnails", + "cache_settings_statistics_assets": "{} assets ({})", + "cache_settings_statistics_full": "Full images", + "cache_settings_statistics_shared": "Shared album thumbnails", + "cache_settings_statistics_thumbnail": "Thumbnails", + "cache_settings_statistics_title": "Cache usage", + "cache_settings_subtitle": "Control the caching behaviour of the Immich mobile application", + "cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)", + "cache_settings_title": "Caching Settings", + "change_password_form_confirm_password": "Confirm Password", + "change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_new_password": "New Password", + "change_password_form_password_mismatch": "Passwords do not match", + "change_password_form_reenter_new_password": "Re-enter New Password", + "common_add_to_album": "Add to album", + "common_change_password": "Change Password", + "common_create_new_album": "Create new album", + "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", + "common_shared": "Compartit", + "control_bottom_app_bar_add_to_album": "Add to album", + "control_bottom_app_bar_album_info": "{} items", + "control_bottom_app_bar_album_info_shared": "{} items · Shared", + "control_bottom_app_bar_archive": "Arxiu", + "control_bottom_app_bar_create_new_album": "Create new album", + "control_bottom_app_bar_delete": "Delete", + "control_bottom_app_bar_favorite": "Favorite", + "control_bottom_app_bar_share": "Share", + "control_bottom_app_bar_unarchive": "Unarchive", + "create_album_page_untitled": "Untitled", + "create_shared_album_page_create": "Create", + "create_shared_album_page_share": "Share", + "create_shared_album_page_share_add_assets": "ADD ASSETS", + "create_shared_album_page_share_select_photos": "Select Photos", + "curated_location_page_title": "Localitzacions", + "curated_object_page_title": "Coses", + "daily_title_text_date": "E, MMM dd", + "daily_title_text_date_year": "E, MMM dd, yyyy", + "date_format": "E, LLL d, y • h:mm a", + "delete_dialog_alert": "These items will be permanently deleted from Immich and from your device", + "delete_dialog_cancel": "Cancel", + "delete_dialog_ok": "Delete", + "delete_dialog_title": "Delete Permanently", + "description_input_hint_text": "Afegeix descripció...", + "description_input_submit_error": "Error updating description, check the log for more details", + "exif_bottom_sheet_description": "Add Description...", + "exif_bottom_sheet_details": "DETAILS", + "exif_bottom_sheet_location": "LOCATION", + "experimental_settings_new_asset_list_subtitle": "Work in progress", + "experimental_settings_new_asset_list_title": "Enable experimental photo grid", + "experimental_settings_subtitle": "Use at your own risk!", + "experimental_settings_title": "Experimental", + "favorites_page_no_favorites": "No s'han trobat preferits", + "favorites_page_title": "Favorites", + "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", + "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", + "home_page_add_to_album_success": "Added {added} assets to album {album}.", + "home_page_archive_err_local": "Can not archive local assets yet, skipping", + "home_page_building_timeline": "Building the timeline", + "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", + "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", + "image_viewer_page_state_provider_download_error": "Download Error", + "image_viewer_page_state_provider_download_success": "Download Success", + "library_page_albums": "Albums", + "library_page_archive": "Arxiu", + "library_page_device_albums": "Àlbums al Dispositiu", + "library_page_favorites": "Favorites", + "library_page_new_album": "New album", + "library_page_sharing": "Sharing", + "library_page_sort_created": "Most recently created", + "library_page_sort_title": "Album title", + "login_form_api_exception": "API exception. Please check the server URL and try again.", + "login_form_button_text": "Login", + "login_form_email_hint": "youremail@email.com", + "login_form_endpoint_hint": "http://your-server-ip:port/api", + "login_form_endpoint_url": "Server Endpoint URL", + "login_form_err_http": "Please specify http:// or https://", + "login_form_err_invalid_email": "Invalid Email", + "login_form_err_invalid_url": "Invalid URL", + "login_form_err_leading_whitespace": "Leading whitespace", + "login_form_err_trailing_whitespace": "Trailing whitespace", + "login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL", + "login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server", + "login_form_failed_login": "Error logging you in, check server URL, email and password", + "login_form_label_email": "Email", + "login_form_label_password": "Password", + "login_form_next_button": "Següent", + "login_form_password_hint": "password", + "login_form_save_login": "Stay logged in", + "login_form_server_empty": "Enter a server URL.", + "login_form_server_error": "Could not connect to server.", + "monthly_title_text_date_format": "MMMM y", + "motion_photos_page_title": "Motion Photos", + "notification_permission_dialog_cancel": "Cancel·la", + "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", + "notification_permission_dialog_settings": "Configuració", + "notification_permission_list_tile_content": "Grant permission to enable notifications.", + "notification_permission_list_tile_enable_button": "Activa les notificacions", + "notification_permission_list_tile_title": "Notification Permission", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", + "permission_onboarding_continue_anyway": "Continue anyway", + "permission_onboarding_get_started": "Get started", + "permission_onboarding_go_to_settings": "Go to settings", + "permission_onboarding_grant_permission": "Dona permisos", + "permission_onboarding_log_out": "Log out", + "permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.", + "permission_onboarding_permission_granted": "Permission granted! You are all set.", + "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", + "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "profile_drawer_app_logs": "Logs", + "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date", + "profile_drawer_settings": "Settings", + "profile_drawer_sign_out": "Sign Out", + "recently_added_page_title": "Recently Added", + "search_bar_hint": "Search your photos", + "search_page_categories": "Categories", + "search_page_favorites": "Preferides", + "search_page_motion_photos": "Fotografies animades", + "search_page_no_objects": "No Objects Info Available", + "search_page_no_places": "No Places Info Available", + "search_page_people": "People", + "search_page_places": "Places", + "search_page_recently_added": "Afegit recentment", + "search_page_screenshots": "Captures de pantalla", + "search_page_selfies": "Autofotos", + "search_page_things": "Things", + "search_page_videos": "Videos", + "search_page_view_all_button": "Veure tot", + "search_page_your_activity": "Your activity", + "search_result_page_new_search_hint": "New Search", + "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", + "search_suggestion_list_smart_search_hint_2": "m:your-search-term", + "select_additional_user_for_sharing_page_suggestions": "Suggestions", + "select_user_for_sharing_page_err_album": "Failed to create album", + "select_user_for_sharing_page_share_suggestions": "Suggestions", + "server_info_box_app_version": "Versió de l'aplicació", + "server_info_box_server_version": "Versió del servidor", + "setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).", + "setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).", + "setting_image_viewer_original_title": "Load original image", + "setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.", + "setting_image_viewer_preview_title": "Load preview image", + "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", + "setting_notifications_notify_hours": "{} hours", + "setting_notifications_notify_immediately": "immediately", + "setting_notifications_notify_minutes": "{} minutes", + "setting_notifications_notify_never": "never", + "setting_notifications_notify_seconds": "{} seconds", + "setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset", + "setting_notifications_single_progress_title": "Show background backup detail progress", + "setting_notifications_subtitle": "Adjust your notification preferences", + "setting_notifications_title": "Notifications", + "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", + "setting_notifications_total_progress_title": "Show background backup total progress", + "setting_pages_app_bar_settings": "Settings", + "settings_require_restart": "Please restart Immich to apply this setting", + "share_add": "Add", + "share_add_photos": "Add photos", + "share_add_title": "Add a title", + "share_create_album": "Create album", + "share_dialog_preparing": "Preparing...", + "share_invite": "Invite to album", + "sharing_page_album": "Shared albums", + "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", + "sharing_page_empty_list": "EMPTY LIST", + "sharing_silver_appbar_create_shared_album": "Create shared album", + "sharing_silver_appbar_share_partner": "Share with partner", + "tab_controller_nav_library": "Library", + "tab_controller_nav_photos": "Photos", + "tab_controller_nav_search": "Search", + "tab_controller_nav_sharing": "Sharing", + "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", + "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_dark_mode_switch": "Dark mode", + "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", + "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_system_theme_switch": "Automatic (Follow system setting)", + "theme_setting_theme_subtitle": "Choose the app's theme setting", + "theme_setting_theme_title": "Theme", + "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", + "theme_setting_three_stage_loading_title": "Enable three-stage loading", + "version_announcement_overlay_ack": "Acknowledge", + "version_announcement_overlay_release_notes": "release notes", + "version_announcement_overlay_text_1": "Hi friend, there is a new release of", + "version_announcement_overlay_text_2": "please take your time to visit the ", + "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", + "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89" +} \ No newline at end of file diff --git a/mobile/assets/i18n/da-DK.json b/mobile/assets/i18n/da-DK.json index e656578b3..7967e52e8 100644 --- a/mobile/assets/i18n/da-DK.json +++ b/mobile/assets/i18n/da-DK.json @@ -1,8 +1,8 @@ { "add_to_album_bottom_sheet_added": "Tilføjet til {album}", "add_to_album_bottom_sheet_already_exists": "Allerede i {album}", - "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", - "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_prefer_remote_subtitle": "Nogle enheder tager meget lang tid om at indlæse miniaturebilleder af elementer på enheden. Aktiver denne indstilling for i stedetat indlæse elementer fra serveren.", + "advanced_settings_prefer_remote_title": "Foretræk elementer på serveren", "advanced_settings_tile_subtitle": "Avancerede brugerindstillinger", "advanced_settings_tile_title": "Arkivér", "advanced_settings_troubleshooting_subtitle": "Slå ekstra funktioner for fejlsøgning til", @@ -22,7 +22,7 @@ "album_viewer_appbar_share_leave": "Forlad album", "album_viewer_appbar_share_remove": "Fjern fra album", "album_viewer_page_share_add_users": "Tilføj brugere", - "all_people_page_title": "People", + "all_people_page_title": "Personer", "all_videos_page_title": "Videoer", "archive_page_no_archived_assets": "Ingen arkiverede elementer blev fundet", "archive_page_title": "Arkivér ({})", @@ -194,14 +194,14 @@ "notification_permission_list_tile_content": "Tillad at bruge notifikationer.", "notification_permission_list_tile_enable_button": "Slå notifikationer til", "notification_permission_list_tile_title": "Notifikationstilladelser", - "partner_page_add_partner": "Add partner", - "partner_page_empty_message": "Your photos are not yet shared with any partner.", - "partner_page_no_more_users": "No more users to add", - "partner_page_partner_add_failed": "Failed to add partner", - "partner_page_select_partner": "Select partner", - "partner_page_shared_to_title": "Shared to", - "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", - "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_add_partner": "Tilføj partner", + "partner_page_empty_message": "Dine billeder er endnu ikke delt med en partner.", + "partner_page_no_more_users": "Der er ikke flere brugere at tilføje", + "partner_page_partner_add_failed": "Kunne ikke tilføje en partner", + "partner_page_select_partner": "Vælg partner", + "partner_page_shared_to_title": "Delt til", + "partner_page_stop_sharing_content": "{} vil ikke længere have adgang til dine billeder.", + "partner_page_stop_sharing_title": "Stop med at dele dine billeder?", "partner_page_title": "Partner", "permission_onboarding_continue_anyway": "Fortsæt alligevel", "permission_onboarding_get_started": "Kom i gang", @@ -223,7 +223,7 @@ "search_page_motion_photos": "Bevægelsesbilleder", "search_page_no_objects": "Ingen elementer er tilgængelige", "search_page_no_places": "Ingen placeringsinformation er tilgængelig", - "search_page_people": "People", + "search_page_people": "Personer", "search_page_places": "Steder", "search_page_recently_added": "Nyligt tilføjet", "search_page_screenshots": "Skærmbilleder", diff --git a/mobile/assets/i18n/es-MX.json b/mobile/assets/i18n/es-MX.json new file mode 100644 index 000000000..7e4339b8d --- /dev/null +++ b/mobile/assets/i18n/es-MX.json @@ -0,0 +1,293 @@ +{ + "add_to_album_bottom_sheet_added": "Added to {album}", + "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_tile_subtitle": "Advanced user's settings", + "advanced_settings_tile_title": "Advanced", + "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", + "advanced_settings_troubleshooting_title": "Troubleshooting", + "album_info_card_backup_album_excluded": "EXCLUIDOS", + "album_info_card_backup_album_included": "INCLUIDOS", + "album_thumbnail_card_item": "1 elemento", + "album_thumbnail_card_items": "{} elementos", + "album_thumbnail_card_shared": " · Compartido", + "album_thumbnail_owned": "Owned", + "album_thumbnail_shared_by": "Shared by {}", + "album_viewer_appbar_share_delete": "Eliminar álbum", + "album_viewer_appbar_share_err_delete": "No se ha podido eliminar el álbum", + "album_viewer_appbar_share_err_leave": "No se ha podido abandonar el álbum", + "album_viewer_appbar_share_err_remove": "Hay problemas para eliminar recursos del álbum", + "album_viewer_appbar_share_err_title": "Error al cambiar el título del álbum", + "album_viewer_appbar_share_leave": "Abandonar álbum ", + "album_viewer_appbar_share_remove": "Eliminar del álbum", + "album_viewer_page_share_add_users": "Añadir usuarios", + "all_people_page_title": "People", + "all_videos_page_title": "Videos", + "archive_page_no_archived_assets": "No archived assets found", + "archive_page_title": "Archive ({})", + "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout", + "asset_list_layout_settings_group_automatically": "Automatic", + "asset_list_layout_settings_group_by": "Group assets by", + "asset_list_layout_settings_group_by_month": "Month", + "asset_list_layout_settings_group_by_month_day": "Month + day", + "asset_list_settings_subtitle": "Photo grid layout settings", + "asset_list_settings_title": "Photo Grid", + "backup_album_selection_page_albums_device": "Álbumes en el dispositivo ({})", + "backup_album_selection_page_albums_tap": "Pulsar para incluir, pulsar dos veces para excluir", + "backup_album_selection_page_assets_scatter": "Los recursos pueden dispersarse entre varios álbumes. Por lo tanto, los álbumes pueden incluirse o excluirse durante el proceso de respaldo.", + "backup_album_selection_page_select_albums": "Seleccionar álbumes", + "backup_album_selection_page_selection_info": "Información de la selección", + "backup_album_selection_page_total_assets": "Total de recursos únicos", + "backup_all": "Todos", + "backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…", + "backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…", + "backup_background_service_current_upload_notification": "Cargando {}", + "backup_background_service_default_notification": "Comprobando por nuevos recursos...", + "backup_background_service_error_title": "Error al respaldar", + "backup_background_service_in_progress_notification": "Respaldando tus recursos...", + "backup_background_service_upload_failure_notification": "Error al cargar {}", + "backup_controller_page_albums": "Álbumes de respaldo", + "backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.", + "backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled", + "backup_controller_page_background_app_refresh_enable_button_text": "Go to settings", + "backup_controller_page_background_battery_info_link": "Show me how", + "backup_controller_page_background_battery_info_message": "For the best background backup experience, please disable any battery optimizations restricting background activity for Immich.\n\nSince this is device-specific, please lookup the required information for your device manufacturer.", + "backup_controller_page_background_battery_info_ok": "OK", + "backup_controller_page_background_battery_info_title": "Battery optimizations", + "backup_controller_page_background_charging": "Only while charging", + "backup_controller_page_background_configure_error": "Failed to configure the background service", + "backup_controller_page_background_delay": "Delay new assets backup: {}", + "backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app", + "backup_controller_page_background_is_off": "Automatic background backup is off", + "backup_controller_page_background_is_on": "Automatic background backup is on", + "backup_controller_page_background_turn_off": "Turn off background service", + "backup_controller_page_background_turn_on": "Turn on background service", + "backup_controller_page_background_wifi": "Only on WiFi", + "backup_controller_page_backup": "Respaldo", + "backup_controller_page_backup_selected": "Seleccionado:", + "backup_controller_page_backup_sub": "Fotos y videos respaldados", + "backup_controller_page_cancel": "Cancelar", + "backup_controller_page_created": "Creado el: {}", + "backup_controller_page_desc_backup": "Activa la copia de seguridad en primer plano para cargar automáticamente nuevos recursos al servidor al abrir la aplicación.", + "backup_controller_page_excluded": "Excluido:", + "backup_controller_page_failed": "Fallidos ({})", + "backup_controller_page_filename": "Nombre: {} [{}]", + "backup_controller_page_id": "ID: {}", + "backup_controller_page_info": "Información del respaldo", + "backup_controller_page_none_selected": "Ninguno seleccionado", + "backup_controller_page_remainder": "Restante", + "backup_controller_page_remainder_sub": "Fotos y videos restantes de la selección a los que realizar un respaldo", + "backup_controller_page_select": "Seleccionar", + "backup_controller_page_server_storage": "Almacenamiento del servidor", + "backup_controller_page_start_backup": "Iniciar respaldo", + "backup_controller_page_status_off": "La copia de seguridad automática en primer plano está desactivada", + "backup_controller_page_status_on": "La copia de seguridad automática en primer plano está activada", + "backup_controller_page_storage_format": "{} de {} usadas", + "backup_controller_page_to_backup": "Álbumes a respaldar", + "backup_controller_page_total": "Total", + "backup_controller_page_total_sub": "Todas las fotos y videos únicos de los álbumes seleccionados", + "backup_controller_page_turn_off": "Desactivar la copia de seguridad en primer plano", + "backup_controller_page_turn_on": "Activar la copia de seguridad en primer plano", + "backup_controller_page_uploading_file_info": "Info de carga de archivo", + "backup_err_only_album": "No se puede eliminar el único álbum", + "backup_info_card_assets": "recursos", + "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", + "cache_settings_clear_cache_button": "Clear cache", + "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", + "cache_settings_image_cache_size": "Image cache size ({} assets)", + "cache_settings_statistics_album": "Library thumbnails", + "cache_settings_statistics_assets": "{} assets ({})", + "cache_settings_statistics_full": "Full images", + "cache_settings_statistics_shared": "Shared album thumbnails", + "cache_settings_statistics_thumbnail": "Thumbnails", + "cache_settings_statistics_title": "Cache usage", + "cache_settings_subtitle": "Control the caching behaviour of the Immich mobile application", + "cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)", + "cache_settings_title": "Caching Settings", + "change_password_form_confirm_password": "Confirm Password", + "change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_new_password": "New Password", + "change_password_form_password_mismatch": "Passwords do not match", + "change_password_form_reenter_new_password": "Re-enter New Password", + "common_add_to_album": "Add to album", + "common_change_password": "Change Password", + "common_create_new_album": "Create new album", + "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", + "common_shared": "Shared", + "control_bottom_app_bar_add_to_album": "Add to album", + "control_bottom_app_bar_album_info": "{} items", + "control_bottom_app_bar_album_info_shared": "{} items · Shared", + "control_bottom_app_bar_archive": "Archive", + "control_bottom_app_bar_create_new_album": "Create new album", + "control_bottom_app_bar_delete": "Eliminar", + "control_bottom_app_bar_favorite": "Favorite", + "control_bottom_app_bar_share": "Compartir", + "control_bottom_app_bar_unarchive": "Unarchive", + "create_album_page_untitled": "Sin título", + "create_shared_album_page_create": "Crear", + "create_shared_album_page_share": "Compartir", + "create_shared_album_page_share_add_assets": "AÑADIR RECURSOS", + "create_shared_album_page_share_select_photos": "Seleccionar fotos", + "curated_location_page_title": "Places", + "curated_object_page_title": "Things", + "daily_title_text_date": "E, dd MMM", + "daily_title_text_date_year": "E, dd de MMM de yyyy", + "date_format": "E d, LLL y • h:mm a", + "delete_dialog_alert": "Estos elementos se eliminarán permanentemente de Immich y de tu dispositivo", + "delete_dialog_cancel": "Cancelar", + "delete_dialog_ok": "Eliminar", + "delete_dialog_title": "Eliminar permanentemente", + "description_input_hint_text": "Add description...", + "description_input_submit_error": "Error updating description, check the log for more details", + "exif_bottom_sheet_description": "Añadir descripción...", + "exif_bottom_sheet_details": "DETALLES", + "exif_bottom_sheet_location": "UBICACIÓN", + "experimental_settings_new_asset_list_subtitle": "Work in progress", + "experimental_settings_new_asset_list_title": "Enable experimental photo grid", + "experimental_settings_subtitle": "Use at your own risk!", + "experimental_settings_title": "Experimental", + "favorites_page_no_favorites": "No favorite assets found", + "favorites_page_title": "Favorites", + "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", + "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", + "home_page_add_to_album_success": "Added {added} assets to album {album}.", + "home_page_archive_err_local": "Can not archive local assets yet, skipping", + "home_page_building_timeline": "Building the timeline", + "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", + "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", + "image_viewer_page_state_provider_download_error": "Download Error", + "image_viewer_page_state_provider_download_success": "Download Success", + "library_page_albums": "Álbumes", + "library_page_archive": "Archive", + "library_page_device_albums": "Albums on Device", + "library_page_favorites": "Favorites", + "library_page_new_album": "Nuevo álbum", + "library_page_sharing": "Sharing", + "library_page_sort_created": "Most recently created", + "library_page_sort_title": "Album title", + "login_form_api_exception": "API exception. Please check the server URL and try again.", + "login_form_button_text": "Iniciar sesión", + "login_form_email_hint": "tucorreo@correo.com", + "login_form_endpoint_hint": "http://la-ip-de-tu-servidor:puerto/api", + "login_form_endpoint_url": "URL del servidor", + "login_form_err_http": "Por favor, especifique http:// o https://", + "login_form_err_invalid_email": "Correo electrónico inválido", + "login_form_err_invalid_url": "Invalid URL", + "login_form_err_leading_whitespace": "Espacio en blanco inicial", + "login_form_err_trailing_whitespace": "Espacio en blanco al final", + "login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL", + "login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server", + "login_form_failed_login": "Error al iniciar sesión, comprueba la URL del servidor, el correo electrónico y la contraseña", + "login_form_label_email": "Correo electrónico", + "login_form_label_password": "Contraseña", + "login_form_next_button": "Next", + "login_form_password_hint": "contraseña", + "login_form_save_login": "Permanecer conectado", + "login_form_server_empty": "Enter a server URL.", + "login_form_server_error": "Could not connect to server.", + "monthly_title_text_date_format": "MMMM y", + "motion_photos_page_title": "Motion Photos", + "notification_permission_dialog_cancel": "Cancel", + "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", + "notification_permission_dialog_settings": "Settings", + "notification_permission_list_tile_content": "Grant permission to enable notifications.", + "notification_permission_list_tile_enable_button": "Enable Notifications", + "notification_permission_list_tile_title": "Notification Permission", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", + "permission_onboarding_continue_anyway": "Continue anyway", + "permission_onboarding_get_started": "Get started", + "permission_onboarding_go_to_settings": "Go to settings", + "permission_onboarding_grant_permission": "Grant permission", + "permission_onboarding_log_out": "Log out", + "permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.", + "permission_onboarding_permission_granted": "Permission granted! You are all set.", + "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", + "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "profile_drawer_app_logs": "Logs", + "profile_drawer_client_server_up_to_date": "El cliente y el servidor están actualizados", + "profile_drawer_settings": "Configuración", + "profile_drawer_sign_out": "Cerrar sesión", + "recently_added_page_title": "Recently Added", + "search_bar_hint": "Busca tus fotos", + "search_page_categories": "Categories", + "search_page_favorites": "Favorites", + "search_page_motion_photos": "Motion Photos", + "search_page_no_objects": "No hay información de objetos disponible", + "search_page_no_places": "No hay información de lugares disponible", + "search_page_people": "People", + "search_page_places": "Lugares", + "search_page_recently_added": "Recently added", + "search_page_screenshots": "Screenshots", + "search_page_selfies": "Selfies", + "search_page_things": "Cosas", + "search_page_videos": "Videos", + "search_page_view_all_button": "View all", + "search_page_your_activity": "Your activity", + "search_result_page_new_search_hint": "Nueva búsqueda", + "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", + "search_suggestion_list_smart_search_hint_2": "m:your-search-term", + "select_additional_user_for_sharing_page_suggestions": "Sugerencias", + "select_user_for_sharing_page_err_album": "Error al crear álbum", + "select_user_for_sharing_page_share_suggestions": "Sugerencias", + "server_info_box_app_version": "App Version", + "server_info_box_server_version": "Server Version", + "setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).", + "setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).", + "setting_image_viewer_original_title": "Load original image", + "setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.", + "setting_image_viewer_preview_title": "Load preview image", + "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", + "setting_notifications_notify_hours": "{} hours", + "setting_notifications_notify_immediately": "immediately", + "setting_notifications_notify_minutes": "{} minutes", + "setting_notifications_notify_never": "never", + "setting_notifications_notify_seconds": "{} seconds", + "setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset", + "setting_notifications_single_progress_title": "Show background backup detail progress", + "setting_notifications_subtitle": "Adjust your notification preferences", + "setting_notifications_title": "Notifications", + "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", + "setting_notifications_total_progress_title": "Show background backup total progress", + "setting_pages_app_bar_settings": "Settings", + "settings_require_restart": "Please restart Immich to apply this setting", + "share_add": "Añadir", + "share_add_photos": "Añadir fotos", + "share_add_title": "Añadir un título", + "share_create_album": "Crear álbum", + "share_dialog_preparing": "Preparando...", + "share_invite": "Invitar al álbum", + "sharing_page_album": "Álbumes compartidos", + "sharing_page_description": "Crea álbumes compartidos para compartir fotos y videos con personas de tu red.", + "sharing_page_empty_list": "LISTA VACIA", + "sharing_silver_appbar_create_shared_album": "Crear álbum compartido", + "sharing_silver_appbar_share_partner": "Compartir con compañero", + "tab_controller_nav_library": "Biblioteca", + "tab_controller_nav_photos": "Fotos", + "tab_controller_nav_search": "Buscar", + "tab_controller_nav_sharing": "Compartiendo", + "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", + "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_dark_mode_switch": "Dark mode", + "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", + "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_system_theme_switch": "Automatic (Follow system setting)", + "theme_setting_theme_subtitle": "Choose the app's theme setting", + "theme_setting_theme_title": "Theme", + "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", + "theme_setting_three_stage_loading_title": "Enable three-stage loading", + "version_announcement_overlay_ack": "Aceptar", + "version_announcement_overlay_release_notes": "notas de la versión", + "version_announcement_overlay_text_1": "Hola, amigo, hay una nueva versión de", + "version_announcement_overlay_text_2": "por favor, tómese su tiempo para visitar las", + "version_announcement_overlay_text_3": "y asegúrate de que tu configuración de docker-compose y .env está actualizada para evitar cualquier error de configuración, especialmente si utilizas WatchTower o cualquier mecanismo que se encargue de actualizar tu aplicación de servidor automáticamente.", + "version_announcement_overlay_title": "Nueva versión del servidor disponible \uD83C\uDF89" +} \ No newline at end of file diff --git a/mobile/assets/i18n/es-PE.json b/mobile/assets/i18n/es-PE.json new file mode 100644 index 000000000..7e4339b8d --- /dev/null +++ b/mobile/assets/i18n/es-PE.json @@ -0,0 +1,293 @@ +{ + "add_to_album_bottom_sheet_added": "Added to {album}", + "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_tile_subtitle": "Advanced user's settings", + "advanced_settings_tile_title": "Advanced", + "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", + "advanced_settings_troubleshooting_title": "Troubleshooting", + "album_info_card_backup_album_excluded": "EXCLUIDOS", + "album_info_card_backup_album_included": "INCLUIDOS", + "album_thumbnail_card_item": "1 elemento", + "album_thumbnail_card_items": "{} elementos", + "album_thumbnail_card_shared": " · Compartido", + "album_thumbnail_owned": "Owned", + "album_thumbnail_shared_by": "Shared by {}", + "album_viewer_appbar_share_delete": "Eliminar álbum", + "album_viewer_appbar_share_err_delete": "No se ha podido eliminar el álbum", + "album_viewer_appbar_share_err_leave": "No se ha podido abandonar el álbum", + "album_viewer_appbar_share_err_remove": "Hay problemas para eliminar recursos del álbum", + "album_viewer_appbar_share_err_title": "Error al cambiar el título del álbum", + "album_viewer_appbar_share_leave": "Abandonar álbum ", + "album_viewer_appbar_share_remove": "Eliminar del álbum", + "album_viewer_page_share_add_users": "Añadir usuarios", + "all_people_page_title": "People", + "all_videos_page_title": "Videos", + "archive_page_no_archived_assets": "No archived assets found", + "archive_page_title": "Archive ({})", + "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout", + "asset_list_layout_settings_group_automatically": "Automatic", + "asset_list_layout_settings_group_by": "Group assets by", + "asset_list_layout_settings_group_by_month": "Month", + "asset_list_layout_settings_group_by_month_day": "Month + day", + "asset_list_settings_subtitle": "Photo grid layout settings", + "asset_list_settings_title": "Photo Grid", + "backup_album_selection_page_albums_device": "Álbumes en el dispositivo ({})", + "backup_album_selection_page_albums_tap": "Pulsar para incluir, pulsar dos veces para excluir", + "backup_album_selection_page_assets_scatter": "Los recursos pueden dispersarse entre varios álbumes. Por lo tanto, los álbumes pueden incluirse o excluirse durante el proceso de respaldo.", + "backup_album_selection_page_select_albums": "Seleccionar álbumes", + "backup_album_selection_page_selection_info": "Información de la selección", + "backup_album_selection_page_total_assets": "Total de recursos únicos", + "backup_all": "Todos", + "backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…", + "backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…", + "backup_background_service_current_upload_notification": "Cargando {}", + "backup_background_service_default_notification": "Comprobando por nuevos recursos...", + "backup_background_service_error_title": "Error al respaldar", + "backup_background_service_in_progress_notification": "Respaldando tus recursos...", + "backup_background_service_upload_failure_notification": "Error al cargar {}", + "backup_controller_page_albums": "Álbumes de respaldo", + "backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.", + "backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled", + "backup_controller_page_background_app_refresh_enable_button_text": "Go to settings", + "backup_controller_page_background_battery_info_link": "Show me how", + "backup_controller_page_background_battery_info_message": "For the best background backup experience, please disable any battery optimizations restricting background activity for Immich.\n\nSince this is device-specific, please lookup the required information for your device manufacturer.", + "backup_controller_page_background_battery_info_ok": "OK", + "backup_controller_page_background_battery_info_title": "Battery optimizations", + "backup_controller_page_background_charging": "Only while charging", + "backup_controller_page_background_configure_error": "Failed to configure the background service", + "backup_controller_page_background_delay": "Delay new assets backup: {}", + "backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app", + "backup_controller_page_background_is_off": "Automatic background backup is off", + "backup_controller_page_background_is_on": "Automatic background backup is on", + "backup_controller_page_background_turn_off": "Turn off background service", + "backup_controller_page_background_turn_on": "Turn on background service", + "backup_controller_page_background_wifi": "Only on WiFi", + "backup_controller_page_backup": "Respaldo", + "backup_controller_page_backup_selected": "Seleccionado:", + "backup_controller_page_backup_sub": "Fotos y videos respaldados", + "backup_controller_page_cancel": "Cancelar", + "backup_controller_page_created": "Creado el: {}", + "backup_controller_page_desc_backup": "Activa la copia de seguridad en primer plano para cargar automáticamente nuevos recursos al servidor al abrir la aplicación.", + "backup_controller_page_excluded": "Excluido:", + "backup_controller_page_failed": "Fallidos ({})", + "backup_controller_page_filename": "Nombre: {} [{}]", + "backup_controller_page_id": "ID: {}", + "backup_controller_page_info": "Información del respaldo", + "backup_controller_page_none_selected": "Ninguno seleccionado", + "backup_controller_page_remainder": "Restante", + "backup_controller_page_remainder_sub": "Fotos y videos restantes de la selección a los que realizar un respaldo", + "backup_controller_page_select": "Seleccionar", + "backup_controller_page_server_storage": "Almacenamiento del servidor", + "backup_controller_page_start_backup": "Iniciar respaldo", + "backup_controller_page_status_off": "La copia de seguridad automática en primer plano está desactivada", + "backup_controller_page_status_on": "La copia de seguridad automática en primer plano está activada", + "backup_controller_page_storage_format": "{} de {} usadas", + "backup_controller_page_to_backup": "Álbumes a respaldar", + "backup_controller_page_total": "Total", + "backup_controller_page_total_sub": "Todas las fotos y videos únicos de los álbumes seleccionados", + "backup_controller_page_turn_off": "Desactivar la copia de seguridad en primer plano", + "backup_controller_page_turn_on": "Activar la copia de seguridad en primer plano", + "backup_controller_page_uploading_file_info": "Info de carga de archivo", + "backup_err_only_album": "No se puede eliminar el único álbum", + "backup_info_card_assets": "recursos", + "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", + "cache_settings_clear_cache_button": "Clear cache", + "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", + "cache_settings_image_cache_size": "Image cache size ({} assets)", + "cache_settings_statistics_album": "Library thumbnails", + "cache_settings_statistics_assets": "{} assets ({})", + "cache_settings_statistics_full": "Full images", + "cache_settings_statistics_shared": "Shared album thumbnails", + "cache_settings_statistics_thumbnail": "Thumbnails", + "cache_settings_statistics_title": "Cache usage", + "cache_settings_subtitle": "Control the caching behaviour of the Immich mobile application", + "cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)", + "cache_settings_title": "Caching Settings", + "change_password_form_confirm_password": "Confirm Password", + "change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_new_password": "New Password", + "change_password_form_password_mismatch": "Passwords do not match", + "change_password_form_reenter_new_password": "Re-enter New Password", + "common_add_to_album": "Add to album", + "common_change_password": "Change Password", + "common_create_new_album": "Create new album", + "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", + "common_shared": "Shared", + "control_bottom_app_bar_add_to_album": "Add to album", + "control_bottom_app_bar_album_info": "{} items", + "control_bottom_app_bar_album_info_shared": "{} items · Shared", + "control_bottom_app_bar_archive": "Archive", + "control_bottom_app_bar_create_new_album": "Create new album", + "control_bottom_app_bar_delete": "Eliminar", + "control_bottom_app_bar_favorite": "Favorite", + "control_bottom_app_bar_share": "Compartir", + "control_bottom_app_bar_unarchive": "Unarchive", + "create_album_page_untitled": "Sin título", + "create_shared_album_page_create": "Crear", + "create_shared_album_page_share": "Compartir", + "create_shared_album_page_share_add_assets": "AÑADIR RECURSOS", + "create_shared_album_page_share_select_photos": "Seleccionar fotos", + "curated_location_page_title": "Places", + "curated_object_page_title": "Things", + "daily_title_text_date": "E, dd MMM", + "daily_title_text_date_year": "E, dd de MMM de yyyy", + "date_format": "E d, LLL y • h:mm a", + "delete_dialog_alert": "Estos elementos se eliminarán permanentemente de Immich y de tu dispositivo", + "delete_dialog_cancel": "Cancelar", + "delete_dialog_ok": "Eliminar", + "delete_dialog_title": "Eliminar permanentemente", + "description_input_hint_text": "Add description...", + "description_input_submit_error": "Error updating description, check the log for more details", + "exif_bottom_sheet_description": "Añadir descripción...", + "exif_bottom_sheet_details": "DETALLES", + "exif_bottom_sheet_location": "UBICACIÓN", + "experimental_settings_new_asset_list_subtitle": "Work in progress", + "experimental_settings_new_asset_list_title": "Enable experimental photo grid", + "experimental_settings_subtitle": "Use at your own risk!", + "experimental_settings_title": "Experimental", + "favorites_page_no_favorites": "No favorite assets found", + "favorites_page_title": "Favorites", + "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", + "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", + "home_page_add_to_album_success": "Added {added} assets to album {album}.", + "home_page_archive_err_local": "Can not archive local assets yet, skipping", + "home_page_building_timeline": "Building the timeline", + "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", + "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", + "image_viewer_page_state_provider_download_error": "Download Error", + "image_viewer_page_state_provider_download_success": "Download Success", + "library_page_albums": "Álbumes", + "library_page_archive": "Archive", + "library_page_device_albums": "Albums on Device", + "library_page_favorites": "Favorites", + "library_page_new_album": "Nuevo álbum", + "library_page_sharing": "Sharing", + "library_page_sort_created": "Most recently created", + "library_page_sort_title": "Album title", + "login_form_api_exception": "API exception. Please check the server URL and try again.", + "login_form_button_text": "Iniciar sesión", + "login_form_email_hint": "tucorreo@correo.com", + "login_form_endpoint_hint": "http://la-ip-de-tu-servidor:puerto/api", + "login_form_endpoint_url": "URL del servidor", + "login_form_err_http": "Por favor, especifique http:// o https://", + "login_form_err_invalid_email": "Correo electrónico inválido", + "login_form_err_invalid_url": "Invalid URL", + "login_form_err_leading_whitespace": "Espacio en blanco inicial", + "login_form_err_trailing_whitespace": "Espacio en blanco al final", + "login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL", + "login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server", + "login_form_failed_login": "Error al iniciar sesión, comprueba la URL del servidor, el correo electrónico y la contraseña", + "login_form_label_email": "Correo electrónico", + "login_form_label_password": "Contraseña", + "login_form_next_button": "Next", + "login_form_password_hint": "contraseña", + "login_form_save_login": "Permanecer conectado", + "login_form_server_empty": "Enter a server URL.", + "login_form_server_error": "Could not connect to server.", + "monthly_title_text_date_format": "MMMM y", + "motion_photos_page_title": "Motion Photos", + "notification_permission_dialog_cancel": "Cancel", + "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", + "notification_permission_dialog_settings": "Settings", + "notification_permission_list_tile_content": "Grant permission to enable notifications.", + "notification_permission_list_tile_enable_button": "Enable Notifications", + "notification_permission_list_tile_title": "Notification Permission", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", + "permission_onboarding_continue_anyway": "Continue anyway", + "permission_onboarding_get_started": "Get started", + "permission_onboarding_go_to_settings": "Go to settings", + "permission_onboarding_grant_permission": "Grant permission", + "permission_onboarding_log_out": "Log out", + "permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.", + "permission_onboarding_permission_granted": "Permission granted! You are all set.", + "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", + "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "profile_drawer_app_logs": "Logs", + "profile_drawer_client_server_up_to_date": "El cliente y el servidor están actualizados", + "profile_drawer_settings": "Configuración", + "profile_drawer_sign_out": "Cerrar sesión", + "recently_added_page_title": "Recently Added", + "search_bar_hint": "Busca tus fotos", + "search_page_categories": "Categories", + "search_page_favorites": "Favorites", + "search_page_motion_photos": "Motion Photos", + "search_page_no_objects": "No hay información de objetos disponible", + "search_page_no_places": "No hay información de lugares disponible", + "search_page_people": "People", + "search_page_places": "Lugares", + "search_page_recently_added": "Recently added", + "search_page_screenshots": "Screenshots", + "search_page_selfies": "Selfies", + "search_page_things": "Cosas", + "search_page_videos": "Videos", + "search_page_view_all_button": "View all", + "search_page_your_activity": "Your activity", + "search_result_page_new_search_hint": "Nueva búsqueda", + "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", + "search_suggestion_list_smart_search_hint_2": "m:your-search-term", + "select_additional_user_for_sharing_page_suggestions": "Sugerencias", + "select_user_for_sharing_page_err_album": "Error al crear álbum", + "select_user_for_sharing_page_share_suggestions": "Sugerencias", + "server_info_box_app_version": "App Version", + "server_info_box_server_version": "Server Version", + "setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).", + "setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).", + "setting_image_viewer_original_title": "Load original image", + "setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.", + "setting_image_viewer_preview_title": "Load preview image", + "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", + "setting_notifications_notify_hours": "{} hours", + "setting_notifications_notify_immediately": "immediately", + "setting_notifications_notify_minutes": "{} minutes", + "setting_notifications_notify_never": "never", + "setting_notifications_notify_seconds": "{} seconds", + "setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset", + "setting_notifications_single_progress_title": "Show background backup detail progress", + "setting_notifications_subtitle": "Adjust your notification preferences", + "setting_notifications_title": "Notifications", + "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", + "setting_notifications_total_progress_title": "Show background backup total progress", + "setting_pages_app_bar_settings": "Settings", + "settings_require_restart": "Please restart Immich to apply this setting", + "share_add": "Añadir", + "share_add_photos": "Añadir fotos", + "share_add_title": "Añadir un título", + "share_create_album": "Crear álbum", + "share_dialog_preparing": "Preparando...", + "share_invite": "Invitar al álbum", + "sharing_page_album": "Álbumes compartidos", + "sharing_page_description": "Crea álbumes compartidos para compartir fotos y videos con personas de tu red.", + "sharing_page_empty_list": "LISTA VACIA", + "sharing_silver_appbar_create_shared_album": "Crear álbum compartido", + "sharing_silver_appbar_share_partner": "Compartir con compañero", + "tab_controller_nav_library": "Biblioteca", + "tab_controller_nav_photos": "Fotos", + "tab_controller_nav_search": "Buscar", + "tab_controller_nav_sharing": "Compartiendo", + "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", + "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_dark_mode_switch": "Dark mode", + "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", + "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_system_theme_switch": "Automatic (Follow system setting)", + "theme_setting_theme_subtitle": "Choose the app's theme setting", + "theme_setting_theme_title": "Theme", + "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", + "theme_setting_three_stage_loading_title": "Enable three-stage loading", + "version_announcement_overlay_ack": "Aceptar", + "version_announcement_overlay_release_notes": "notas de la versión", + "version_announcement_overlay_text_1": "Hola, amigo, hay una nueva versión de", + "version_announcement_overlay_text_2": "por favor, tómese su tiempo para visitar las", + "version_announcement_overlay_text_3": "y asegúrate de que tu configuración de docker-compose y .env está actualizada para evitar cualquier error de configuración, especialmente si utilizas WatchTower o cualquier mecanismo que se encargue de actualizar tu aplicación de servidor automáticamente.", + "version_announcement_overlay_title": "Nueva versión del servidor disponible \uD83C\uDF89" +} \ No newline at end of file diff --git a/mobile/assets/i18n/hi-IN.json b/mobile/assets/i18n/hi-IN.json new file mode 100644 index 000000000..47e60789c --- /dev/null +++ b/mobile/assets/i18n/hi-IN.json @@ -0,0 +1,293 @@ +{ + "add_to_album_bottom_sheet_added": "Added to {album}", + "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_tile_subtitle": "Advanced user's settings", + "advanced_settings_tile_title": "Advanced", + "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", + "advanced_settings_troubleshooting_title": "Troubleshooting", + "album_info_card_backup_album_excluded": "EXCLUDED", + "album_info_card_backup_album_included": "INCLUDED", + "album_thumbnail_card_item": "1 item", + "album_thumbnail_card_items": "{} items", + "album_thumbnail_card_shared": " · Shared", + "album_thumbnail_owned": "Owned", + "album_thumbnail_shared_by": "Shared by {}", + "album_viewer_appbar_share_delete": "Delete album", + "album_viewer_appbar_share_err_delete": "Failed to delete album", + "album_viewer_appbar_share_err_leave": "Failed to leave album", + "album_viewer_appbar_share_err_remove": "There are problems in removing assets from album", + "album_viewer_appbar_share_err_title": "Failed to change album title", + "album_viewer_appbar_share_leave": "Leave album", + "album_viewer_appbar_share_remove": "Remove from album", + "album_viewer_page_share_add_users": "Add users", + "all_people_page_title": "People", + "all_videos_page_title": "Videos", + "archive_page_no_archived_assets": "No archived assets found", + "archive_page_title": "Archive ({})", + "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout", + "asset_list_layout_settings_group_automatically": "Automatic", + "asset_list_layout_settings_group_by": "Group assets by", + "asset_list_layout_settings_group_by_month": "Month", + "asset_list_layout_settings_group_by_month_day": "Month + day", + "asset_list_settings_subtitle": "Photo grid layout settings", + "asset_list_settings_title": "Photo Grid", + "backup_album_selection_page_albums_device": "Albums on device ({})", + "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", + "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", + "backup_album_selection_page_select_albums": "Select albums", + "backup_album_selection_page_selection_info": "Selection Info", + "backup_album_selection_page_total_assets": "Total unique assets", + "backup_all": "All", + "backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…", + "backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…", + "backup_background_service_current_upload_notification": "Uploading {}", + "backup_background_service_default_notification": "Checking for new assets…", + "backup_background_service_error_title": "Backup error", + "backup_background_service_in_progress_notification": "Backing up your assets…", + "backup_background_service_upload_failure_notification": "Failed to upload {}", + "backup_controller_page_albums": "Backup Albums", + "backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.", + "backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled", + "backup_controller_page_background_app_refresh_enable_button_text": "Go to settings", + "backup_controller_page_background_battery_info_link": "Show me how", + "backup_controller_page_background_battery_info_message": "For the best background backup experience, please disable any battery optimizations restricting background activity for Immich.\n\nSince this is device-specific, please lookup the required information for your device manufacturer.", + "backup_controller_page_background_battery_info_ok": "OK", + "backup_controller_page_background_battery_info_title": "Battery optimizations", + "backup_controller_page_background_charging": "Only while charging", + "backup_controller_page_background_configure_error": "Failed to configure the background service", + "backup_controller_page_background_delay": "Delay new assets backup: {}", + "backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app", + "backup_controller_page_background_is_off": "Automatic background backup is off", + "backup_controller_page_background_is_on": "Automatic background backup is on", + "backup_controller_page_background_turn_off": "Turn off background service", + "backup_controller_page_background_turn_on": "Turn on background service", + "backup_controller_page_background_wifi": "Only on WiFi", + "backup_controller_page_backup": "Backup", + "backup_controller_page_backup_selected": "Selected: ", + "backup_controller_page_backup_sub": "Backed up photos and videos", + "backup_controller_page_cancel": "Cancel", + "backup_controller_page_created": "Created on: {}", + "backup_controller_page_desc_backup": "Turn on foreground backup to automatically upload new assets to the server when opening the app.", + "backup_controller_page_excluded": "Excluded: ", + "backup_controller_page_failed": "Failed ({})", + "backup_controller_page_filename": "File name: {} [{}]", + "backup_controller_page_id": "ID: {}", + "backup_controller_page_info": "Backup Information", + "backup_controller_page_none_selected": "None selected", + "backup_controller_page_remainder": "Remainder", + "backup_controller_page_remainder_sub": "Remaining photos and videos to back up from selection", + "backup_controller_page_select": "Select", + "backup_controller_page_server_storage": "Server Storage", + "backup_controller_page_start_backup": "Start Backup", + "backup_controller_page_status_off": "Automatic foreground backup is off", + "backup_controller_page_status_on": "Automatic foreground backup is on", + "backup_controller_page_storage_format": "{} of {} used", + "backup_controller_page_to_backup": "Albums to be backup", + "backup_controller_page_total": "Total", + "backup_controller_page_total_sub": "All unique photos and videos from selected albums", + "backup_controller_page_turn_off": "Turn off foreground backup", + "backup_controller_page_turn_on": "Turn on foreground backup", + "backup_controller_page_uploading_file_info": "Uploading file info", + "backup_err_only_album": "Cannot remove the only album", + "backup_info_card_assets": "assets", + "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", + "cache_settings_clear_cache_button": "Clear cache", + "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", + "cache_settings_image_cache_size": "Image cache size ({} assets)", + "cache_settings_statistics_album": "Library thumbnails", + "cache_settings_statistics_assets": "{} assets ({})", + "cache_settings_statistics_full": "Full images", + "cache_settings_statistics_shared": "Shared album thumbnails", + "cache_settings_statistics_thumbnail": "Thumbnails", + "cache_settings_statistics_title": "Cache usage", + "cache_settings_subtitle": "Control the caching behaviour of the Immich mobile application", + "cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)", + "cache_settings_title": "Caching Settings", + "change_password_form_confirm_password": "Confirm Password", + "change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_new_password": "New Password", + "change_password_form_password_mismatch": "Passwords do not match", + "change_password_form_reenter_new_password": "Re-enter New Password", + "common_add_to_album": "Add to album", + "common_change_password": "Change Password", + "common_create_new_album": "Create new album", + "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", + "common_shared": "Shared", + "control_bottom_app_bar_add_to_album": "Add to album", + "control_bottom_app_bar_album_info": "{} items", + "control_bottom_app_bar_album_info_shared": "{} items · Shared", + "control_bottom_app_bar_archive": "Archive", + "control_bottom_app_bar_create_new_album": "Create new album", + "control_bottom_app_bar_delete": "Delete", + "control_bottom_app_bar_favorite": "Favorite", + "control_bottom_app_bar_share": "Share", + "control_bottom_app_bar_unarchive": "Unarchive", + "create_album_page_untitled": "Untitled", + "create_shared_album_page_create": "Create", + "create_shared_album_page_share": "Share", + "create_shared_album_page_share_add_assets": "ADD ASSETS", + "create_shared_album_page_share_select_photos": "Select Photos", + "curated_location_page_title": "Places", + "curated_object_page_title": "Things", + "daily_title_text_date": "E, MMM dd", + "daily_title_text_date_year": "E, MMM dd, yyyy", + "date_format": "E, LLL d, y • h:mm a", + "delete_dialog_alert": "These items will be permanently deleted from Immich and from your device", + "delete_dialog_cancel": "Cancel", + "delete_dialog_ok": "Delete", + "delete_dialog_title": "Delete Permanently", + "description_input_hint_text": "Add description...", + "description_input_submit_error": "Error updating description, check the log for more details", + "exif_bottom_sheet_description": "Add Description...", + "exif_bottom_sheet_details": "DETAILS", + "exif_bottom_sheet_location": "LOCATION", + "experimental_settings_new_asset_list_subtitle": "Work in progress", + "experimental_settings_new_asset_list_title": "Enable experimental photo grid", + "experimental_settings_subtitle": "Use at your own risk!", + "experimental_settings_title": "Experimental", + "favorites_page_no_favorites": "No favorite assets found", + "favorites_page_title": "Favorites", + "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", + "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", + "home_page_add_to_album_success": "Added {added} assets to album {album}.", + "home_page_archive_err_local": "Can not archive local assets yet, skipping", + "home_page_building_timeline": "Building the timeline", + "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", + "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", + "image_viewer_page_state_provider_download_error": "Download Error", + "image_viewer_page_state_provider_download_success": "Download Success", + "library_page_albums": "Albums", + "library_page_archive": "Archive", + "library_page_device_albums": "Albums on Device", + "library_page_favorites": "Favorites", + "library_page_new_album": "New album", + "library_page_sharing": "Sharing", + "library_page_sort_created": "Most recently created", + "library_page_sort_title": "Album title", + "login_form_api_exception": "API exception. Please check the server URL and try again.", + "login_form_button_text": "Login", + "login_form_email_hint": "youremail@email.com", + "login_form_endpoint_hint": "http://your-server-ip:port/api", + "login_form_endpoint_url": "Server Endpoint URL", + "login_form_err_http": "Please specify http:// or https://", + "login_form_err_invalid_email": "Invalid Email", + "login_form_err_invalid_url": "Invalid URL", + "login_form_err_leading_whitespace": "Leading whitespace", + "login_form_err_trailing_whitespace": "Trailing whitespace", + "login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL", + "login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server", + "login_form_failed_login": "Error logging you in, check server URL, email and password", + "login_form_label_email": "Email", + "login_form_label_password": "Password", + "login_form_next_button": "Next", + "login_form_password_hint": "password", + "login_form_save_login": "Stay logged in", + "login_form_server_empty": "Enter a server URL.", + "login_form_server_error": "Could not connect to server.", + "monthly_title_text_date_format": "MMMM y", + "motion_photos_page_title": "Motion Photos", + "notification_permission_dialog_cancel": "Cancel", + "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", + "notification_permission_dialog_settings": "Settings", + "notification_permission_list_tile_content": "Grant permission to enable notifications.", + "notification_permission_list_tile_enable_button": "Enable Notifications", + "notification_permission_list_tile_title": "Notification Permission", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", + "permission_onboarding_continue_anyway": "Continue anyway", + "permission_onboarding_get_started": "Get started", + "permission_onboarding_go_to_settings": "Go to settings", + "permission_onboarding_grant_permission": "Grant permission", + "permission_onboarding_log_out": "Log out", + "permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.", + "permission_onboarding_permission_granted": "Permission granted! You are all set.", + "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", + "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "profile_drawer_app_logs": "Logs", + "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date", + "profile_drawer_settings": "Settings", + "profile_drawer_sign_out": "Sign Out", + "recently_added_page_title": "Recently Added", + "search_bar_hint": "Search your photos", + "search_page_categories": "Categories", + "search_page_favorites": "Favorites", + "search_page_motion_photos": "Motion Photos", + "search_page_no_objects": "No Objects Info Available", + "search_page_no_places": "No Places Info Available", + "search_page_people": "People", + "search_page_places": "Places", + "search_page_recently_added": "Recently added", + "search_page_screenshots": "Screenshots", + "search_page_selfies": "Selfies", + "search_page_things": "Things", + "search_page_videos": "Videos", + "search_page_view_all_button": "View all", + "search_page_your_activity": "Your activity", + "search_result_page_new_search_hint": "New Search", + "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", + "search_suggestion_list_smart_search_hint_2": "m:your-search-term", + "select_additional_user_for_sharing_page_suggestions": "Suggestions", + "select_user_for_sharing_page_err_album": "Failed to create album", + "select_user_for_sharing_page_share_suggestions": "Suggestions", + "server_info_box_app_version": "App Version", + "server_info_box_server_version": "Server Version", + "setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).", + "setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).", + "setting_image_viewer_original_title": "Load original image", + "setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.", + "setting_image_viewer_preview_title": "Load preview image", + "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", + "setting_notifications_notify_hours": "{} hours", + "setting_notifications_notify_immediately": "immediately", + "setting_notifications_notify_minutes": "{} minutes", + "setting_notifications_notify_never": "never", + "setting_notifications_notify_seconds": "{} seconds", + "setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset", + "setting_notifications_single_progress_title": "Show background backup detail progress", + "setting_notifications_subtitle": "Adjust your notification preferences", + "setting_notifications_title": "Notifications", + "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", + "setting_notifications_total_progress_title": "Show background backup total progress", + "setting_pages_app_bar_settings": "Settings", + "settings_require_restart": "Please restart Immich to apply this setting", + "share_add": "Add", + "share_add_photos": "Add photos", + "share_add_title": "Add a title", + "share_create_album": "Create album", + "share_dialog_preparing": "Preparing...", + "share_invite": "Invite to album", + "sharing_page_album": "Shared albums", + "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", + "sharing_page_empty_list": "EMPTY LIST", + "sharing_silver_appbar_create_shared_album": "Create shared album", + "sharing_silver_appbar_share_partner": "Share with partner", + "tab_controller_nav_library": "Library", + "tab_controller_nav_photos": "Photos", + "tab_controller_nav_search": "Search", + "tab_controller_nav_sharing": "Sharing", + "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", + "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_dark_mode_switch": "Dark mode", + "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", + "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_system_theme_switch": "Automatic (Follow system setting)", + "theme_setting_theme_subtitle": "Choose the app's theme setting", + "theme_setting_theme_title": "Theme", + "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", + "theme_setting_three_stage_loading_title": "Enable three-stage loading", + "version_announcement_overlay_ack": "Acknowledge", + "version_announcement_overlay_release_notes": "release notes", + "version_announcement_overlay_text_1": "Hi friend, there is a new release of", + "version_announcement_overlay_text_2": "please take your time to visit the ", + "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", + "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89" +} \ No newline at end of file diff --git a/mobile/assets/i18n/hu-HU.json b/mobile/assets/i18n/hu-HU.json new file mode 100644 index 000000000..0f28f5c5f --- /dev/null +++ b/mobile/assets/i18n/hu-HU.json @@ -0,0 +1,293 @@ +{ + "add_to_album_bottom_sheet_added": "Added to {album}", + "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_tile_subtitle": "Haladó felhasználói beállítások", + "advanced_settings_tile_title": "Haladó", + "advanced_settings_troubleshooting_subtitle": "További funkciók engedélyezése hibaelhárítás céljából", + "advanced_settings_troubleshooting_title": "Hibaelhárítás", + "album_info_card_backup_album_excluded": "EXCLUDED", + "album_info_card_backup_album_included": "INCLUDED", + "album_thumbnail_card_item": "1 item", + "album_thumbnail_card_items": "{} items", + "album_thumbnail_card_shared": " · Shared", + "album_thumbnail_owned": "Tulajdonos", + "album_thumbnail_shared_by": "Megosztotta: {}", + "album_viewer_appbar_share_delete": "Delete album", + "album_viewer_appbar_share_err_delete": "Failed to delete album", + "album_viewer_appbar_share_err_leave": "Failed to leave album", + "album_viewer_appbar_share_err_remove": "There are problems in removing assets from album", + "album_viewer_appbar_share_err_title": "Failed to change album title", + "album_viewer_appbar_share_leave": "Leave album", + "album_viewer_appbar_share_remove": "Remove from album", + "album_viewer_page_share_add_users": "Add users", + "all_people_page_title": "People", + "all_videos_page_title": "Videók", + "archive_page_no_archived_assets": "Nem található archivált média", + "archive_page_title": "Archívum ({})", + "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout", + "asset_list_layout_settings_group_automatically": "Automatikus", + "asset_list_layout_settings_group_by": "Group assets by", + "asset_list_layout_settings_group_by_month": "Month", + "asset_list_layout_settings_group_by_month_day": "Month + day", + "asset_list_settings_subtitle": "Photo grid layout settings", + "asset_list_settings_title": "Photo Grid", + "backup_album_selection_page_albums_device": "Albums on device ({})", + "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", + "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", + "backup_album_selection_page_select_albums": "Select albums", + "backup_album_selection_page_selection_info": "Selection Info", + "backup_album_selection_page_total_assets": "Total unique assets", + "backup_all": "All", + "backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…", + "backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…", + "backup_background_service_current_upload_notification": "Uploading {}", + "backup_background_service_default_notification": "Checking for new assets…", + "backup_background_service_error_title": "Backup error", + "backup_background_service_in_progress_notification": "Backing up your assets…", + "backup_background_service_upload_failure_notification": "Failed to upload {}", + "backup_controller_page_albums": "Backup Albums", + "backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.", + "backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled", + "backup_controller_page_background_app_refresh_enable_button_text": "Beállítások megnyitása", + "backup_controller_page_background_battery_info_link": "Show me how", + "backup_controller_page_background_battery_info_message": "For the best background backup experience, please disable any battery optimizations restricting background activity for Immich.\n\nSince this is device-specific, please lookup the required information for your device manufacturer.", + "backup_controller_page_background_battery_info_ok": "OK", + "backup_controller_page_background_battery_info_title": "Battery optimizations", + "backup_controller_page_background_charging": "Only while charging", + "backup_controller_page_background_configure_error": "Failed to configure the background service", + "backup_controller_page_background_delay": "Delay new assets backup: {}", + "backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app", + "backup_controller_page_background_is_off": "Automatic background backup is off", + "backup_controller_page_background_is_on": "Automatic background backup is on", + "backup_controller_page_background_turn_off": "Turn off background service", + "backup_controller_page_background_turn_on": "Turn on background service", + "backup_controller_page_background_wifi": "Only on WiFi", + "backup_controller_page_backup": "Backup", + "backup_controller_page_backup_selected": "Selected: ", + "backup_controller_page_backup_sub": "Backed up photos and videos", + "backup_controller_page_cancel": "Cancel", + "backup_controller_page_created": "Created on: {}", + "backup_controller_page_desc_backup": "Turn on foreground backup to automatically upload new assets to the server when opening the app.", + "backup_controller_page_excluded": "Excluded: ", + "backup_controller_page_failed": "Failed ({})", + "backup_controller_page_filename": "File name: {} [{}]", + "backup_controller_page_id": "ID: {}", + "backup_controller_page_info": "Backup Information", + "backup_controller_page_none_selected": "None selected", + "backup_controller_page_remainder": "Remainder", + "backup_controller_page_remainder_sub": "Remaining photos and videos to back up from selection", + "backup_controller_page_select": "Select", + "backup_controller_page_server_storage": "Server Storage", + "backup_controller_page_start_backup": "Start Backup", + "backup_controller_page_status_off": "Automatic foreground backup is off", + "backup_controller_page_status_on": "Automatic foreground backup is on", + "backup_controller_page_storage_format": "{} of {} used", + "backup_controller_page_to_backup": "Albums to be backup", + "backup_controller_page_total": "Total", + "backup_controller_page_total_sub": "All unique photos and videos from selected albums", + "backup_controller_page_turn_off": "Turn off foreground backup", + "backup_controller_page_turn_on": "Turn on foreground backup", + "backup_controller_page_uploading_file_info": "Uploading file info", + "backup_err_only_album": "Cannot remove the only album", + "backup_info_card_assets": "assets", + "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", + "cache_settings_clear_cache_button": "Clear cache", + "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", + "cache_settings_image_cache_size": "Image cache size ({} assets)", + "cache_settings_statistics_album": "Library thumbnails", + "cache_settings_statistics_assets": "{} assets ({})", + "cache_settings_statistics_full": "Full images", + "cache_settings_statistics_shared": "Shared album thumbnails", + "cache_settings_statistics_thumbnail": "Thumbnails", + "cache_settings_statistics_title": "Cache usage", + "cache_settings_subtitle": "Control the caching behaviour of the Immich mobile application", + "cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)", + "cache_settings_title": "Caching Settings", + "change_password_form_confirm_password": "Jelszó Megerősítése", + "change_password_form_description": "Kedves {lastName} {firstName}!\n\nMost jelentkezel be először a rendszerbe vagy más okból szükséfes a jelszavad meváltoztatása. Kérjük, add meg új jelszavad.", + "change_password_form_new_password": "Új Jelszó", + "change_password_form_password_mismatch": "A két beírt jelszó nem egyezik", + "change_password_form_reenter_new_password": "Jelszó (még egyszer)", + "common_add_to_album": "Albumhoz ad", + "common_change_password": "Jelszócsere", + "common_create_new_album": "Új album létrehozása", + "common_server_error": "Kérjük, ellenőrid a hálózati kapcsolatot, gondoskodj róla, hogy a szerver elérhető legyen, valamint az app és a szerver kompatibilis verziójú legyen.", + "common_shared": "Megosztva", + "control_bottom_app_bar_add_to_album": "Add to album", + "control_bottom_app_bar_album_info": "{} items", + "control_bottom_app_bar_album_info_shared": "{} items · Shared", + "control_bottom_app_bar_archive": "Archivál", + "control_bottom_app_bar_create_new_album": "Create new album", + "control_bottom_app_bar_delete": "Delete", + "control_bottom_app_bar_favorite": "Kedvenc", + "control_bottom_app_bar_share": "Share", + "control_bottom_app_bar_unarchive": "Archiválás megszüntetése", + "create_album_page_untitled": "Untitled", + "create_shared_album_page_create": "Create", + "create_shared_album_page_share": "Share", + "create_shared_album_page_share_add_assets": "ADD ASSETS", + "create_shared_album_page_share_select_photos": "Select Photos", + "curated_location_page_title": "Helyek", + "curated_object_page_title": "Dolgok", + "daily_title_text_date": "E, MMM dd", + "daily_title_text_date_year": "E, MMM dd, yyyy", + "date_format": "E, LLL d, y • h:mm a", + "delete_dialog_alert": "These items will be permanently deleted from Immich and from your device", + "delete_dialog_cancel": "Cancel", + "delete_dialog_ok": "Delete", + "delete_dialog_title": "Delete Permanently", + "description_input_hint_text": "Leírás hozzáadása...", + "description_input_submit_error": "Nem sikerült frissíteni a leírást. További információért kérjük, nézd meg az eseménynaplót", + "exif_bottom_sheet_description": "Add Description...", + "exif_bottom_sheet_details": "DETAILS", + "exif_bottom_sheet_location": "LOCATION", + "experimental_settings_new_asset_list_subtitle": "Work in progress", + "experimental_settings_new_asset_list_title": "Enable experimental photo grid", + "experimental_settings_subtitle": "Use at your own risk!", + "experimental_settings_title": "Experimental", + "favorites_page_no_favorites": "Nem található kedvencnek jelölt média", + "favorites_page_title": "Favorites", + "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", + "home_page_add_to_album_err_local": "Helyi médiát még nem lehet albumba tenni. Kihagyjuk.", + "home_page_add_to_album_success": "Added {added} assets to album {album}.", + "home_page_archive_err_local": "Helyi média archiválása még nem támogatott, úgyhogy kihagyjuk", + "home_page_building_timeline": "Building the timeline", + "home_page_favorite_err_local": "Helyi médiát még nem lehet a kedvencek közé tenni. Kihagyjuk.", + "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", + "image_viewer_page_state_provider_download_error": "Letöltési Hiba", + "image_viewer_page_state_provider_download_success": "Letöltés Sikeres", + "library_page_albums": "Albums", + "library_page_archive": "Archívum", + "library_page_device_albums": "Albumok az Eszközön", + "library_page_favorites": "Favorites", + "library_page_new_album": "New album", + "library_page_sharing": "Sharing", + "library_page_sort_created": "Most recently created", + "library_page_sort_title": "Album title", + "login_form_api_exception": "API hiba. Kérljük, ellenőrid a szerver címét, majd próbáld újra.", + "login_form_button_text": "Login", + "login_form_email_hint": "youremail@email.com", + "login_form_endpoint_hint": "http://your-server-ip:port/api", + "login_form_endpoint_url": "Server Endpoint URL", + "login_form_err_http": "Please specify http:// or https://", + "login_form_err_invalid_email": "Invalid Email", + "login_form_err_invalid_url": "Invalid URL", + "login_form_err_leading_whitespace": "Leading whitespace", + "login_form_err_trailing_whitespace": "Trailing whitespace", + "login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL", + "login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server", + "login_form_failed_login": "Error logging you in, check server URL, email and password", + "login_form_label_email": "Email", + "login_form_label_password": "Password", + "login_form_next_button": "Következő", + "login_form_password_hint": "password", + "login_form_save_login": "Stay logged in", + "login_form_server_empty": "Add meg a szerver címét.", + "login_form_server_error": "Nem sikerült kapcsolódni a szerverhez.", + "monthly_title_text_date_format": "MMMM y", + "motion_photos_page_title": "Mozgó Fotók", + "notification_permission_dialog_cancel": "Mégsem", + "notification_permission_dialog_content": "Az értesítések bakapcsolásához a Beállítások menüben válaszd ki az Engedélyezés-t.", + "notification_permission_dialog_settings": "Beállítások", + "notification_permission_list_tile_content": "Értesítések engedélyezése", + "notification_permission_list_tile_enable_button": "Értesítések Bekapcsolása", + "notification_permission_list_tile_title": "Engedély az Értesítésekhez", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", + "permission_onboarding_continue_anyway": "Folytatás mindenképp", + "permission_onboarding_get_started": "Kezdjük el", + "permission_onboarding_go_to_settings": "Beállítások megnyitása", + "permission_onboarding_grant_permission": "Engedélyezés", + "permission_onboarding_log_out": "Kijelentkezés", + "permission_onboarding_permission_denied": "Hozzáférés megtagadva. Az Immich használatához enedélyezni kell a fotó és videó hozzáférést a Beállításokban.", + "permission_onboarding_permission_granted": "Hozzáférés engedélyezve! Minden készen áll.", + "permission_onboarding_permission_limited": "Korlátozott hozzáférés. Ha szeretnéd, hogy az Immich a teljes galéria gyűjteményedet mentse és kezelje, akkor a Beállításokban engedélyezd a fotó és videó jogosultságokat.", + "permission_onboarding_request": "Engedélyezni kell, hogy az Immich hozzáférjen a képekhez és videókhoz", + "profile_drawer_app_logs": "Logs", + "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date", + "profile_drawer_settings": "Settings", + "profile_drawer_sign_out": "Sign Out", + "recently_added_page_title": "Nemrég Hozzáadott", + "search_bar_hint": "Search your photos", + "search_page_categories": "Kategóriák", + "search_page_favorites": "Kedvencek", + "search_page_motion_photos": "Mozgó Fotók", + "search_page_no_objects": "No Objects Info Available", + "search_page_no_places": "No Places Info Available", + "search_page_people": "People", + "search_page_places": "Places", + "search_page_recently_added": "Nemrég hozzáadott", + "search_page_screenshots": "Képernyőképek", + "search_page_selfies": "Szelfik", + "search_page_things": "Things", + "search_page_videos": "Videók", + "search_page_view_all_button": "Összes mutatása", + "search_page_your_activity": "Tevékenységeid", + "search_result_page_new_search_hint": "New Search", + "search_suggestion_list_smart_search_hint_1": "Az intelligens keresés alapértelmezetten be van kapcsolva, metaadatokat így kereshetsz", + "search_suggestion_list_smart_search_hint_2": "m:keresési-kifejezés", + "select_additional_user_for_sharing_page_suggestions": "Suggestions", + "select_user_for_sharing_page_err_album": "Failed to create album", + "select_user_for_sharing_page_share_suggestions": "Suggestions", + "server_info_box_app_version": "Alkalmazás Verzió", + "server_info_box_server_version": "Szerver Verzió", + "setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).", + "setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).", + "setting_image_viewer_original_title": "Load original image", + "setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.", + "setting_image_viewer_preview_title": "Load preview image", + "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", + "setting_notifications_notify_hours": "{} hours", + "setting_notifications_notify_immediately": "immediately", + "setting_notifications_notify_minutes": "{} minutes", + "setting_notifications_notify_never": "never", + "setting_notifications_notify_seconds": "{} seconds", + "setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset", + "setting_notifications_single_progress_title": "Show background backup detail progress", + "setting_notifications_subtitle": "Adjust your notification preferences", + "setting_notifications_title": "Notifications", + "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", + "setting_notifications_total_progress_title": "Show background backup total progress", + "setting_pages_app_bar_settings": "Settings", + "settings_require_restart": "Please restart Immich to apply this setting", + "share_add": "Add", + "share_add_photos": "Add photos", + "share_add_title": "Add a title", + "share_create_album": "Create album", + "share_dialog_preparing": "Preparing...", + "share_invite": "Invite to album", + "sharing_page_album": "Shared albums", + "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", + "sharing_page_empty_list": "EMPTY LIST", + "sharing_silver_appbar_create_shared_album": "Create shared album", + "sharing_silver_appbar_share_partner": "Share with partner", + "tab_controller_nav_library": "Library", + "tab_controller_nav_photos": "Photos", + "tab_controller_nav_search": "Search", + "tab_controller_nav_sharing": "Sharing", + "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", + "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_dark_mode_switch": "Dark mode", + "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", + "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_system_theme_switch": "Automatic (Follow system setting)", + "theme_setting_theme_subtitle": "Choose the app's theme setting", + "theme_setting_theme_title": "Theme", + "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", + "theme_setting_three_stage_loading_title": "Enable three-stage loading", + "version_announcement_overlay_ack": "Acknowledge", + "version_announcement_overlay_release_notes": "release notes", + "version_announcement_overlay_text_1": "Hi friend, there is a new release of", + "version_announcement_overlay_text_2": "please take your time to visit the ", + "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", + "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89" +} \ No newline at end of file diff --git a/mobile/assets/i18n/lv-LV.json b/mobile/assets/i18n/lv-LV.json new file mode 100644 index 000000000..d5f31ba25 --- /dev/null +++ b/mobile/assets/i18n/lv-LV.json @@ -0,0 +1,293 @@ +{ + "add_to_album_bottom_sheet_added": "Pievienots {album}", + "add_to_album_bottom_sheet_already_exists": "Jau pievienots {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_tile_subtitle": "Lietotāja papildu iestatījumi", + "advanced_settings_tile_title": "Papildu", + "advanced_settings_troubleshooting_subtitle": "Iespējot papildu aktīvus problēmu novēršanai", + "advanced_settings_troubleshooting_title": "Problēmas novēršana", + "album_info_card_backup_album_excluded": "NEIEKĻAUTS", + "album_info_card_backup_album_included": "IEKĻAUTS", + "album_thumbnail_card_item": "1 vienums", + "album_thumbnail_card_items": "{} vienumi", + "album_thumbnail_card_shared": "· Koplietots", + "album_thumbnail_owned": "Īpašumā", + "album_thumbnail_shared_by": "Kopīgoja {}", + "album_viewer_appbar_share_delete": "Dzēst albumu", + "album_viewer_appbar_share_err_delete": "Neizdevās izdzēst albumu", + "album_viewer_appbar_share_err_leave": "Neizdevās pamest albumu", + "album_viewer_appbar_share_err_remove": "Ir problēmas ar aktīvu noņemšanu no albuma", + "album_viewer_appbar_share_err_title": "Neizdevās mainīt albuma nosaukumu", + "album_viewer_appbar_share_leave": "Pamest albumu", + "album_viewer_appbar_share_remove": "Noņemt no albuma", + "album_viewer_page_share_add_users": "Pievienot lietotājus", + "all_people_page_title": "People", + "all_videos_page_title": "Videoklipi", + "archive_page_no_archived_assets": "Nav atrasts neviens arhivēts aktīvs", + "archive_page_title": "Arhīvs ({})", + "asset_list_layout_settings_dynamic_layout_title": "Dinamiskais izkārtojums", + "asset_list_layout_settings_group_automatically": "Automātiski", + "asset_list_layout_settings_group_by": "Grupēt aktīvus pēc", + "asset_list_layout_settings_group_by_month": "Mēnesis", + "asset_list_layout_settings_group_by_month_day": "Mēnesis + diena", + "asset_list_settings_subtitle": "Fotorežģa izkārtojuma iestatījumi", + "asset_list_settings_title": "Fotorežģis", + "backup_album_selection_page_albums_device": "Albumi ierīcē ({})", + "backup_album_selection_page_albums_tap": "Pieskarieties, lai iekļautu, veiciet dubultskārienu, lai izslēgtu", + "backup_album_selection_page_assets_scatter": "Aktīvi var būt izmētāti pa vairākiem albumiem. Tādējādi dublēšanas procesā albumus var iekļaut vai neiekļaut.", + "backup_album_selection_page_select_albums": "Atlasīt albumus", + "backup_album_selection_page_selection_info": "Atlases informācija", + "backup_album_selection_page_total_assets": "Kopā unikālie aktīvi", + "backup_all": "Viss", + "backup_background_service_backup_failed_message": "Neizdevās dublēt līdzekļus. Notiek atkārtota mēģināšana…", + "backup_background_service_connection_failed_message": "Neizdevās izveidot savienojumu ar serveri. Notiek atkārtota mēģināšana…", + "backup_background_service_current_upload_notification": "Notiek {} augšupielāde", + "backup_background_service_default_notification": "Notiek jaunu aktīvu meklēšana…", + "backup_background_service_error_title": "Dublēšanas kļūda", + "backup_background_service_in_progress_notification": "Notiek aktīvu dublēšana…", + "backup_background_service_upload_failure_notification": "Neizdevās augšupielādēt {}", + "backup_controller_page_albums": "Dublējuma Albumi", + "backup_controller_page_background_app_refresh_disabled_content": "Iespējojiet fona aplikācijas atsvaidzināšanu sadaļā Iestatījumi > Vispārīgi > Fona Aplikācijas Atsvaidzināšana, lai izmantotu fona dublēšanu.", + "backup_controller_page_background_app_refresh_disabled_title": "Fona aplikācijas atsvaidzināšana atspējota", + "backup_controller_page_background_app_refresh_enable_button_text": "Doties uz iestatījumiem", + "backup_controller_page_background_battery_info_link": "Parādīt, kā", + "backup_controller_page_background_battery_info_message": "Lai iegūtu vislabāko fona dublēšanas pieredzi, lūdzu, atspējojiet visas akumulatora optimizācijas, kas ierobežo Immich fona aktivitāti.\n\nTā kā katrai ierīcei iestatījumi ir citādāki, lūdzu, meklējiet nepieciešamo informāciju pie ierīces ražotāja.", + "backup_controller_page_background_battery_info_ok": "OK", + "backup_controller_page_background_battery_info_title": "Akumulatora optimizācija", + "backup_controller_page_background_charging": "Tikai uzlādes laikā", + "backup_controller_page_background_configure_error": "Neizdevās konfigurēt fona pakalpojumu", + "backup_controller_page_background_delay": "Aizkavēt jaunu līdzekļu dublēšanu: {}", + "backup_controller_page_background_description": "Ieslēdziet fona pakalpojumu, lai automātiski dublētu visus jaunos aktīvus, neatverot programmu", + "backup_controller_page_background_is_off": "Automātiskā fona dublēšana ir izslēgta", + "backup_controller_page_background_is_on": "Automātiskā fona dublēšana ir ieslēgta", + "backup_controller_page_background_turn_off": "Izslēgt fona pakalpojumu", + "backup_controller_page_background_turn_on": "Ieslēgt fona pakalpojumu", + "backup_controller_page_background_wifi": "Tikai WiFi tīklā", + "backup_controller_page_backup": "Dublēšana", + "backup_controller_page_backup_selected": "Atlasīts:", + "backup_controller_page_backup_sub": "Dublētie Fotoattēli un videoklipi", + "backup_controller_page_cancel": "Atcelt", + "backup_controller_page_created": "Izveidots: {}", + "backup_controller_page_desc_backup": "Ieslēdziet priekšplāna dublēšanu, lai, atverot programmu, serverī automātiski augšupielādētu jaunus aktīvus.", + "backup_controller_page_excluded": "Izņemot:", + "backup_controller_page_failed": "Neizdevās ({})", + "backup_controller_page_filename": "Faila nosaukums: {} [{}]", + "backup_controller_page_id": "ID: {}", + "backup_controller_page_info": "Dublējuma Informācija", + "backup_controller_page_none_selected": "Neviens nav atlasīts", + "backup_controller_page_remainder": "Atlikums", + "backup_controller_page_remainder_sub": "Atlikušie fotoattēli un videoklipi, kurus dublēt no atlases", + "backup_controller_page_select": "Atlasīt", + "backup_controller_page_server_storage": "Servera krātuve", + "backup_controller_page_start_backup": "Sākt Dublēšanu", + "backup_controller_page_status_off": "Automātiskā priekšplāna dublēšana ir izslēgta", + "backup_controller_page_status_on": "Automātiskā priekšplāna dublēšana ir ieslēgta", + "backup_controller_page_storage_format": "{} no {} tiek izmantots", + "backup_controller_page_to_backup": "Dublējamie albumi", + "backup_controller_page_total": "Kopā", + "backup_controller_page_total_sub": "Visi unikālie fotoattēli un videoklipi no izvēlētajiem albumiem", + "backup_controller_page_turn_off": "Izslēgt priekšplāna dublēšanu", + "backup_controller_page_turn_on": "Ieslēgt priekšplāna dublēšanu", + "backup_controller_page_uploading_file_info": "Faila informācijas augšupielāde", + "backup_err_only_album": "Nevar noņemt vienīgo albumu", + "backup_info_card_assets": "aktīvi", + "cache_settings_album_thumbnails": "Bibliotēkas lapu sīktēli ({} aktīvi)", + "cache_settings_clear_cache_button": "Iztīrīt kešatmiņu", + "cache_settings_clear_cache_button_title": "Iztīra aplikācijas kešatmiņu. Tas būtiski ietekmēs lietotnes veiktspēju, līdz kešatmiņa būs pārbūvēta.", + "cache_settings_image_cache_size": "Attēlu kešatmiņas lielums ({} aktīvi)", + "cache_settings_statistics_album": "Bibliotēkas sīktēli", + "cache_settings_statistics_assets": "{} aktīvi ({})", + "cache_settings_statistics_full": "Pilni attēli", + "cache_settings_statistics_shared": "Koplietojamo albumu sīktēli", + "cache_settings_statistics_thumbnail": "Sīktēli", + "cache_settings_statistics_title": "Kešatmiņas lietojums", + "cache_settings_subtitle": "Kontrolēt Immich mobilās lietotnes kešdarbi", + "cache_settings_thumbnail_size": "Sīktēlu keša lielums ({} aktīvi)", + "cache_settings_title": "Kešdarbes iestatījumi", + "change_password_form_confirm_password": "Apstiprināt Paroli", + "change_password_form_description": "Sveiki {FirstName} {LastName},\n\nŠī ir pirmā reize, kad pierakstāties sistēmā, vai arī ir iesniegts pieprasījums mainīt paroli. Lūdzu, zemāk ievadiet jauno paroli.", + "change_password_form_new_password": "Jauna Parole", + "change_password_form_password_mismatch": "Paroles nesakrīt", + "change_password_form_reenter_new_password": "Atkārtoti ievadīt jaunu paroli", + "common_add_to_album": "Pievienot albumam", + "common_change_password": "Nomainīt Paroli", + "common_create_new_album": "Izveidot jaunu albumu", + "common_server_error": "Lūdzu, pārbaudiet tīkla savienojumu, pārliecinieties, vai serveris ir sasniedzams un aplikācijas/servera versijas ir saderīgas.", + "common_shared": "Kopīgots", + "control_bottom_app_bar_add_to_album": "Pievienot albumam", + "control_bottom_app_bar_album_info": "{} vienumi", + "control_bottom_app_bar_album_info_shared": "{} vienumi · Koplietoti", + "control_bottom_app_bar_archive": "Arhīvs", + "control_bottom_app_bar_create_new_album": "Izveidot jaunu albumu", + "control_bottom_app_bar_delete": "Dzēst", + "control_bottom_app_bar_favorite": "Izlase", + "control_bottom_app_bar_share": "Kopīgot", + "control_bottom_app_bar_unarchive": "Atarhivēt", + "create_album_page_untitled": "Bez nosaukuma", + "create_shared_album_page_create": "Izveidot", + "create_shared_album_page_share": "Kopīgot", + "create_shared_album_page_share_add_assets": "PIEVIENOT AKTĪVUS", + "create_shared_album_page_share_select_photos": "Fotoattēlu Izvēle", + "curated_location_page_title": "Vietas", + "curated_object_page_title": "Lietas", + "daily_title_text_date": "E, MMM dd", + "daily_title_text_date_year": "E, MMM dd, gggg", + "date_format": "E, LLL d, g • h:mm a", + "delete_dialog_alert": "Šie vienumi tiks neatgriezeniski dzēsti no Immich un jūsu ierīces", + "delete_dialog_cancel": "Atcelt", + "delete_dialog_ok": "Dzēst", + "delete_dialog_title": "Neatgriezeniski Dzēst", + "description_input_hint_text": "Pievienot aprakstu...", + "description_input_submit_error": "Atjauninot aprakstu, radās kļūda; papildinformāciju skatiet žurnālā", + "exif_bottom_sheet_description": "Pievienot Aprakstu...", + "exif_bottom_sheet_details": "INFORMĀCIJA", + "exif_bottom_sheet_location": "ATRAŠANĀS VIETA", + "experimental_settings_new_asset_list_subtitle": "Izstrādes posmā", + "experimental_settings_new_asset_list_title": "Iespējot eksperimentālo fotorežģi", + "experimental_settings_subtitle": "Izmanto uzņemoties risku!", + "experimental_settings_title": "Eksperimentāls", + "favorites_page_no_favorites": "Nav atrasti iecienītākie aktīvi", + "favorites_page_title": "Izlase", + "home_page_add_to_album_conflicts": "Pievienoja {added} aktīvus albumam {album}. {failed} aktīvi jau ir albumā.", + "home_page_add_to_album_err_local": "Albumiem vēl nevar pievienot lokālos aktīvus, notiek izlaišana", + "home_page_add_to_album_success": "Pievienoja {added} aktīvus albumam {album}.", + "home_page_archive_err_local": "Vēl nevar arhivēt lokālos aktīvus, notiek izlaišana", + "home_page_building_timeline": "Tiek izveidota laika skala", + "home_page_favorite_err_local": "Vēl nevar pievienot izlaisei vietējos aktīvus, notiek izlaišana", + "home_page_first_time_notice": "Ja šī ir pirmā reize, kad izmantojat aplikāciju, lūdzu, izvēlieties dublējuma albumu(s), lai laika skala varētu aizpildīt fotoattēlus un videoklipus albumā(os).", + "image_viewer_page_state_provider_download_error": "Lejupielādes Kļūda", + "image_viewer_page_state_provider_download_success": "Lejupielāde Izdevās", + "library_page_albums": "Albums", + "library_page_archive": "Arhīvs", + "library_page_device_albums": "Albumi ierīcē", + "library_page_favorites": "Izlase", + "library_page_new_album": "Jauns albums", + "library_page_sharing": "Kopīgošana", + "library_page_sort_created": "Jaunākais izveidotais", + "library_page_sort_title": "Albuma virsraksts", + "login_form_api_exception": "API izņēmums. Lūdzu, pārbaudiet servera URL un mēģiniet vēlreiz.", + "login_form_button_text": "Pieteikties", + "login_form_email_hint": "jūsuepasts@email.com", + "login_form_endpoint_hint": "http://jūsu-servera-ip:ports/api", + "login_form_endpoint_url": "Servera Galapunkta URL", + "login_form_err_http": "Lūdzu norādiet http:// vai https://", + "login_form_err_invalid_email": "Nederīgs e-pasts", + "login_form_err_invalid_url": "Nederīgs URL", + "login_form_err_leading_whitespace": "Priekšējā baltstarpa", + "login_form_err_trailing_whitespace": "Beigu baltstarpa", + "login_form_failed_get_oauth_server_config": "Pieslēdzoties, izmantojot OAuth, radās kļūda; pārbaudiet servera URL", + "login_form_failed_get_oauth_server_disable": "OAuth līdzeklis šajā serverī nav pieejams", + "login_form_failed_login": "Radās kļūda, piesakoties, pārbaudiet servera URL, e-pastu un paroli", + "login_form_label_email": "E-pasts", + "login_form_label_password": "Parole", + "login_form_next_button": "Nākošais", + "login_form_password_hint": "parole", + "login_form_save_login": "Palikt pieteiktam", + "login_form_server_empty": "Ieraksties servera URL.", + "login_form_server_error": "Nevarēja izveidot savienojumu ar serveri.", + "monthly_title_text_date_format": "MMMM g", + "motion_photos_page_title": "Kustību Fotoattēli", + "notification_permission_dialog_cancel": "Atcelt", + "notification_permission_dialog_content": "Lai iespējotu paziņojumus, atveriet Iestatījumi un atlasiet Atļaut.", + "notification_permission_dialog_settings": "Iestatījumi", + "notification_permission_list_tile_content": "Piešķirt atļauju, lai iespējotu paziņojumus.", + "notification_permission_list_tile_enable_button": "Iespējot Paziņojumus", + "notification_permission_list_tile_title": "Paziņojumu Atļaujas", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", + "permission_onboarding_continue_anyway": "Tomēr turpināt", + "permission_onboarding_get_started": "Darba sākšana", + "permission_onboarding_go_to_settings": "Doties uz iestatījumiem", + "permission_onboarding_grant_permission": "Piešķirt atļauju", + "permission_onboarding_log_out": "Izrakstīties", + "permission_onboarding_permission_denied": "Atļauja liegta. Lai izmantotu Immich, sadaļā Iestatījumi piešķiriet fotoattēlu un video atļaujas.", + "permission_onboarding_permission_granted": "Atļauja piešķirta! Jūs esat gatavi darbam.", + "permission_onboarding_permission_limited": "Atļauja ierobežota. Lai atļautu Immich dublēšanu un varētu pārvaldīt visu galeriju kolekciju, sadaļā Iestatījumi piešķiriet fotoattēlu un video atļaujas.", + "permission_onboarding_request": "Immich nepieciešama atļauja skatīt jūsu fotoattēlus un videoklipus.", + "profile_drawer_app_logs": "Žurnāli", + "profile_drawer_client_server_up_to_date": "Klients un serveris ir atjaunināti", + "profile_drawer_settings": "Iestatījumi", + "profile_drawer_sign_out": "Izrakstīties", + "recently_added_page_title": "Nesen Pievienotais", + "search_bar_hint": "Meklēt Jūsu fotoattēlus", + "search_page_categories": "Kategorijas", + "search_page_favorites": "Izlase", + "search_page_motion_photos": "Kustību Fotoattēli", + "search_page_no_objects": "Informācija par Objektiem nav pieejama", + "search_page_no_places": "Nav pieejama Informācija par Vietām", + "search_page_people": "People", + "search_page_places": "Vietas", + "search_page_recently_added": "Nesen Pievienotais", + "search_page_screenshots": "Ekrānuzņēmumi", + "search_page_selfies": "Selfiji", + "search_page_things": "Lietas", + "search_page_videos": "Videoklipi", + "search_page_view_all_button": "Apskatīt visu", + "search_page_your_activity": "Jūsu aktivitāte", + "search_result_page_new_search_hint": "Jauns Meklējums", + "search_suggestion_list_smart_search_hint_1": "Viedā meklēšana ir iespējota pēc noklusējuma, lai meklētu metadatus, izmantojiet sintaksi", + "search_suggestion_list_smart_search_hint_2": "m:jūsu-meklēšanas-frāze", + "select_additional_user_for_sharing_page_suggestions": "Ieteikumi", + "select_user_for_sharing_page_err_album": "Neizdevās izveidot albumu", + "select_user_for_sharing_page_share_suggestions": "Ieteikumi", + "server_info_box_app_version": "Aplikācijas Versija", + "server_info_box_server_version": "Servera Versija", + "setting_image_viewer_help": "Detaļu skatītājs vispirms ielādē mazo sīktēlu, pēc tam ielādē vidēja lieluma priekšskatījumu (ja iespējots), visbeidzot ielādē oriģinālu (ja iespējots).", + "setting_image_viewer_original_subtitle": "Iespējojiet sākotnējā pilnas izšķirtspējas attēla (liels!) ielādi. Atspējot lai samazinātu datu lietojumu (gan tīklā, gan ierīces kešatmiņā).", + "setting_image_viewer_original_title": "Ielādēt oriģinālo attēlu", + "setting_image_viewer_preview_subtitle": "Iespējojiet vidējas izšķirtspējas attēla ielādēšanu. Atspējojiet vai nu tiešu oriģināla ielādi, vai izmantojiet tikai sīktēlu.", + "setting_image_viewer_preview_title": "Ielādēt priekšskatījuma attēlu", + "setting_notifications_notify_failures_grace_period": "Paziņot par fona dublēšanas kļūmēm: {}", + "setting_notifications_notify_hours": "{} stundas", + "setting_notifications_notify_immediately": "nekavējoties", + "setting_notifications_notify_minutes": "{} minūtes", + "setting_notifications_notify_never": "nekad", + "setting_notifications_notify_seconds": "{} sekundes", + "setting_notifications_single_progress_subtitle": "Detalizēta augšupielādes progresa informācija par katru aktīvu", + "setting_notifications_single_progress_title": "Rādīt fona dublējuma detalizēto progresu", + "setting_notifications_subtitle": "Paziņojumu preferenču pielāgošana", + "setting_notifications_title": "Paziņojumi", + "setting_notifications_total_progress_subtitle": "Kopējais augšupielādes progress (pabeigti/kopējie aktīvi)", + "setting_notifications_total_progress_title": "Rādīt fona dublējuma kopējo progresu", + "setting_pages_app_bar_settings": "Iestatījumi", + "settings_require_restart": "Lūdzu, restartējiet Immich, lai lietotu šo iestatījumu", + "share_add": "Pievienot", + "share_add_photos": "Pievienot fotoattēlus", + "share_add_title": "Pievienot virsrakstu", + "share_create_album": "Izveidot albumu", + "share_dialog_preparing": "Notiek sagatavošana...", + "share_invite": "Uzaicināt albumā", + "sharing_page_album": "Kopīgotie albumi", + "sharing_page_description": "Izveidojiet koplietojamus albumus, lai kopīgotu fotoattēlus un videoklipus ar Jūsu tīkla lietotājiem.", + "sharing_page_empty_list": "TUKŠS SARAKSTS", + "sharing_silver_appbar_create_shared_album": "Izveidot kopīgotu albumu", + "sharing_silver_appbar_share_partner": "Dalīties ar partneri", + "tab_controller_nav_library": "Bibliotēka", + "tab_controller_nav_photos": "Fotoattēli", + "tab_controller_nav_search": "Meklēt", + "tab_controller_nav_sharing": "Kopīgošana", + "theme_setting_asset_list_storage_indicator_title": "Rādīt krātuves indikatoru uz aktīvu elementiem", + "theme_setting_asset_list_tiles_per_row_title": "Aktīvu skaits rindā ({})", + "theme_setting_dark_mode_switch": "Tumšais režīms", + "theme_setting_image_viewer_quality_subtitle": "Attēlu skatītāja detaļu kvalitātes pielāgošana", + "theme_setting_image_viewer_quality_title": "Attēlu skatītāja kvalitāte", + "theme_setting_system_theme_switch": "Automātisks (sekot sistēmas iestatījumiem)", + "theme_setting_theme_subtitle": "Izvēlieties programmas dizaina iestatījumu", + "theme_setting_theme_title": "Dizains", + "theme_setting_three_stage_loading_subtitle": "Trīspakāpju ielāde var palielināt ielādēšanas veiktspēju, bet izraisa ievērojami lielāku tīkla noslodzi", + "theme_setting_three_stage_loading_title": "Iespējot trīspakāpju ielādi", + "version_announcement_overlay_ack": "Atzīt", + "version_announcement_overlay_release_notes": "informācija par laidienu", + "version_announcement_overlay_text_1": "Sveiks draugs, ir jauns izlaidums no", + "version_announcement_overlay_text_2": "lūdzu, veltiet laiku, lai apmeklētu", + "version_announcement_overlay_text_3": " un pārliecinieties, vai docker-compose un .env iestatījumi ir atjaunināti, lai novērstu jebkādas nepareizas konfigurācijas, īpaši, ja izmantojat WatchTower vai mehānismu, kas automātiski veic servera lietojumprogrammas atjaunināšanu.", + "version_announcement_overlay_title": "Pieejama jauna servera versija \uD83C\uDF89" +} \ No newline at end of file diff --git a/mobile/assets/i18n/mn.json b/mobile/assets/i18n/mn.json new file mode 100644 index 000000000..6fcd72a48 --- /dev/null +++ b/mobile/assets/i18n/mn.json @@ -0,0 +1,293 @@ +{ + "add_to_album_bottom_sheet_added": "Added to {album}", + "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_tile_subtitle": "Advanced user's settings", + "advanced_settings_tile_title": "Advanced", + "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", + "advanced_settings_troubleshooting_title": "Troubleshooting", + "album_info_card_backup_album_excluded": "EXCLUDED", + "album_info_card_backup_album_included": "INCLUDED", + "album_thumbnail_card_item": "1 item", + "album_thumbnail_card_items": "{} items", + "album_thumbnail_card_shared": " · Shared", + "album_thumbnail_owned": "Owned", + "album_thumbnail_shared_by": "Shared by {}", + "album_viewer_appbar_share_delete": "Delete album", + "album_viewer_appbar_share_err_delete": "Failed to delete album", + "album_viewer_appbar_share_err_leave": "Failed to leave album", + "album_viewer_appbar_share_err_remove": "There are problems in removing assets from album", + "album_viewer_appbar_share_err_title": "Failed to change album title", + "album_viewer_appbar_share_leave": "Leave album", + "album_viewer_appbar_share_remove": "Remove from album", + "album_viewer_page_share_add_users": "Add users", + "all_people_page_title": "People", + "all_videos_page_title": "Videos", + "archive_page_no_archived_assets": "No archived assets found", + "archive_page_title": "Archive ({})", + "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout", + "asset_list_layout_settings_group_automatically": "Automatic", + "asset_list_layout_settings_group_by": "Group assets by", + "asset_list_layout_settings_group_by_month": "Month", + "asset_list_layout_settings_group_by_month_day": "Month + day", + "asset_list_settings_subtitle": "Photo grid layout settings", + "asset_list_settings_title": "Photo Grid", + "backup_album_selection_page_albums_device": "Albums on device ({})", + "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", + "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", + "backup_album_selection_page_select_albums": "Select albums", + "backup_album_selection_page_selection_info": "Selection Info", + "backup_album_selection_page_total_assets": "Total unique assets", + "backup_all": "All", + "backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…", + "backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…", + "backup_background_service_current_upload_notification": "Uploading {}", + "backup_background_service_default_notification": "Checking for new assets…", + "backup_background_service_error_title": "Backup error", + "backup_background_service_in_progress_notification": "Backing up your assets…", + "backup_background_service_upload_failure_notification": "Failed to upload {}", + "backup_controller_page_albums": "Backup Albums", + "backup_controller_page_background_app_refresh_disabled_content": "Апп нээгээгүй байх үед нөөцлөлт хийх бол Settings > General > Background App Refresh хандаж идэвхижүүлнэ үү.", + "backup_controller_page_background_app_refresh_disabled_title": "Апп нээгээгүй байх үед нөөцлөлт идэвхигүй.", + "backup_controller_page_background_app_refresh_enable_button_text": "Тохиргоо хэсэгт очих", + "backup_controller_page_background_battery_info_link": "Show me how", + "backup_controller_page_background_battery_info_message": "For the best background backup experience, please disable any battery optimizations restricting background activity for Immich.\n\nSince this is device-specific, please lookup the required information for your device manufacturer.", + "backup_controller_page_background_battery_info_ok": "OK", + "backup_controller_page_background_battery_info_title": "Battery optimizations", + "backup_controller_page_background_charging": "Only while charging", + "backup_controller_page_background_configure_error": "Failed to configure the background service", + "backup_controller_page_background_delay": "Delay new assets backup: {}", + "backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app", + "backup_controller_page_background_is_off": "Automatic background backup is off", + "backup_controller_page_background_is_on": "Automatic background backup is on", + "backup_controller_page_background_turn_off": "Turn off background service", + "backup_controller_page_background_turn_on": "Turn on background service", + "backup_controller_page_background_wifi": "Only on WiFi", + "backup_controller_page_backup": "Backup", + "backup_controller_page_backup_selected": "Selected: ", + "backup_controller_page_backup_sub": "Backed up photos and videos", + "backup_controller_page_cancel": "Cancel", + "backup_controller_page_created": "Created on: {}", + "backup_controller_page_desc_backup": "Turn on foreground backup to automatically upload new assets to the server when opening the app.", + "backup_controller_page_excluded": "Excluded: ", + "backup_controller_page_failed": "Failed ({})", + "backup_controller_page_filename": "File name: {} [{}]", + "backup_controller_page_id": "ID: {}", + "backup_controller_page_info": "Backup Information", + "backup_controller_page_none_selected": "None selected", + "backup_controller_page_remainder": "Remainder", + "backup_controller_page_remainder_sub": "Remaining photos and videos to back up from selection", + "backup_controller_page_select": "Select", + "backup_controller_page_server_storage": "Server Storage", + "backup_controller_page_start_backup": "Start Backup", + "backup_controller_page_status_off": "Automatic foreground backup is off", + "backup_controller_page_status_on": "Automatic foreground backup is on", + "backup_controller_page_storage_format": "{} of {} used", + "backup_controller_page_to_backup": "Albums to be backup", + "backup_controller_page_total": "Total", + "backup_controller_page_total_sub": "All unique photos and videos from selected albums", + "backup_controller_page_turn_off": "Turn off foreground backup", + "backup_controller_page_turn_on": "Turn on foreground backup", + "backup_controller_page_uploading_file_info": "Uploading file info", + "backup_err_only_album": "Cannot remove the only album", + "backup_info_card_assets": "assets", + "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", + "cache_settings_clear_cache_button": "Clear cache", + "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", + "cache_settings_image_cache_size": "Image cache size ({} assets)", + "cache_settings_statistics_album": "Library thumbnails", + "cache_settings_statistics_assets": "{} assets ({})", + "cache_settings_statistics_full": "Full images", + "cache_settings_statistics_shared": "Shared album thumbnails", + "cache_settings_statistics_thumbnail": "Thumbnails", + "cache_settings_statistics_title": "Cache usage", + "cache_settings_subtitle": "Control the caching behaviour of the Immich mobile application", + "cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)", + "cache_settings_title": "Caching Settings", + "change_password_form_confirm_password": "Confirm Password", + "change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_new_password": "New Password", + "change_password_form_password_mismatch": "Passwords do not match", + "change_password_form_reenter_new_password": "Re-enter New Password", + "common_add_to_album": "Add to album", + "common_change_password": "Change Password", + "common_create_new_album": "Create new album", + "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", + "common_shared": "Shared", + "control_bottom_app_bar_add_to_album": "Add to album", + "control_bottom_app_bar_album_info": "{} items", + "control_bottom_app_bar_album_info_shared": "{} items · Shared", + "control_bottom_app_bar_archive": "Archive", + "control_bottom_app_bar_create_new_album": "Create new album", + "control_bottom_app_bar_delete": "Delete", + "control_bottom_app_bar_favorite": "Favorite", + "control_bottom_app_bar_share": "Share", + "control_bottom_app_bar_unarchive": "Unarchive", + "create_album_page_untitled": "Untitled", + "create_shared_album_page_create": "Create", + "create_shared_album_page_share": "Share", + "create_shared_album_page_share_add_assets": "ADD ASSETS", + "create_shared_album_page_share_select_photos": "Select Photos", + "curated_location_page_title": "Places", + "curated_object_page_title": "Things", + "daily_title_text_date": "E, MMM dd", + "daily_title_text_date_year": "E, MMM dd, yyyy", + "date_format": "E, LLL d, y • h:mm a", + "delete_dialog_alert": "These items will be permanently deleted from Immich and from your device", + "delete_dialog_cancel": "Cancel", + "delete_dialog_ok": "Delete", + "delete_dialog_title": "Delete Permanently", + "description_input_hint_text": "Add description...", + "description_input_submit_error": "Error updating description, check the log for more details", + "exif_bottom_sheet_description": "Add Description...", + "exif_bottom_sheet_details": "DETAILS", + "exif_bottom_sheet_location": "LOCATION", + "experimental_settings_new_asset_list_subtitle": "Work in progress", + "experimental_settings_new_asset_list_title": "Enable experimental photo grid", + "experimental_settings_subtitle": "Use at your own risk!", + "experimental_settings_title": "Experimental", + "favorites_page_no_favorites": "No favorite assets found", + "favorites_page_title": "Favorites", + "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", + "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", + "home_page_add_to_album_success": "Added {added} assets to album {album}.", + "home_page_archive_err_local": "Can not archive local assets yet, skipping", + "home_page_building_timeline": "Building the timeline", + "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", + "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", + "image_viewer_page_state_provider_download_error": "Download Error", + "image_viewer_page_state_provider_download_success": "Download Success", + "library_page_albums": "Albums", + "library_page_archive": "Archive", + "library_page_device_albums": "Albums on Device", + "library_page_favorites": "Favorites", + "library_page_new_album": "New album", + "library_page_sharing": "Sharing", + "library_page_sort_created": "Most recently created", + "library_page_sort_title": "Album title", + "login_form_api_exception": "API exception. Please check the server URL and try again.", + "login_form_button_text": "Login", + "login_form_email_hint": "youremail@email.com", + "login_form_endpoint_hint": "http://your-server-ip:port/api", + "login_form_endpoint_url": "Server Endpoint URL", + "login_form_err_http": "Please specify http:// or https://", + "login_form_err_invalid_email": "Invalid Email", + "login_form_err_invalid_url": "Invalid URL", + "login_form_err_leading_whitespace": "Leading whitespace", + "login_form_err_trailing_whitespace": "Trailing whitespace", + "login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL", + "login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server", + "login_form_failed_login": "Error logging you in, check server URL, email and password", + "login_form_label_email": "Email", + "login_form_label_password": "Password", + "login_form_next_button": "Next", + "login_form_password_hint": "password", + "login_form_save_login": "Stay logged in", + "login_form_server_empty": "Enter a server URL.", + "login_form_server_error": "Could not connect to server.", + "monthly_title_text_date_format": "MMMM y", + "motion_photos_page_title": "Motion Photos", + "notification_permission_dialog_cancel": "Цуцлах", + "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", + "notification_permission_dialog_settings": "Тохиргоо", + "notification_permission_list_tile_content": "Мэдэгдэл нээх эрх өгнө үү.\n", + "notification_permission_list_tile_enable_button": "Мэдэгдэл нээх", + "notification_permission_list_tile_title": "Мэдэгдлийн эрх", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", + "permission_onboarding_continue_anyway": "Continue anyway", + "permission_onboarding_get_started": "Get started", + "permission_onboarding_go_to_settings": "Go to settings", + "permission_onboarding_grant_permission": "Grant permission", + "permission_onboarding_log_out": "Log out", + "permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.", + "permission_onboarding_permission_granted": "Permission granted! You are all set.", + "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", + "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "profile_drawer_app_logs": "Logs", + "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date", + "profile_drawer_settings": "Settings", + "profile_drawer_sign_out": "Sign Out", + "recently_added_page_title": "Recently Added", + "search_bar_hint": "Search your photos", + "search_page_categories": "Categories", + "search_page_favorites": "Favorites", + "search_page_motion_photos": "Motion Photos", + "search_page_no_objects": "No Objects Info Available", + "search_page_no_places": "No Places Info Available", + "search_page_people": "People", + "search_page_places": "Places", + "search_page_recently_added": "Recently added", + "search_page_screenshots": "Screenshots", + "search_page_selfies": "Selfies", + "search_page_things": "Things", + "search_page_videos": "Videos", + "search_page_view_all_button": "View all", + "search_page_your_activity": "Your activity", + "search_result_page_new_search_hint": "New Search", + "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", + "search_suggestion_list_smart_search_hint_2": "m:your-search-term", + "select_additional_user_for_sharing_page_suggestions": "Suggestions", + "select_user_for_sharing_page_err_album": "Failed to create album", + "select_user_for_sharing_page_share_suggestions": "Suggestions", + "server_info_box_app_version": "App Version", + "server_info_box_server_version": "Server Version", + "setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).", + "setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).", + "setting_image_viewer_original_title": "Load original image", + "setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.", + "setting_image_viewer_preview_title": "Load preview image", + "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", + "setting_notifications_notify_hours": "{} hours", + "setting_notifications_notify_immediately": "immediately", + "setting_notifications_notify_minutes": "{} minutes", + "setting_notifications_notify_never": "never", + "setting_notifications_notify_seconds": "{} seconds", + "setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset", + "setting_notifications_single_progress_title": "Show background backup detail progress", + "setting_notifications_subtitle": "Adjust your notification preferences", + "setting_notifications_title": "Notifications", + "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", + "setting_notifications_total_progress_title": "Show background backup total progress", + "setting_pages_app_bar_settings": "Settings", + "settings_require_restart": "Please restart Immich to apply this setting", + "share_add": "Add", + "share_add_photos": "Add photos", + "share_add_title": "Add a title", + "share_create_album": "Create album", + "share_dialog_preparing": "Preparing...", + "share_invite": "Invite to album", + "sharing_page_album": "Shared albums", + "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", + "sharing_page_empty_list": "EMPTY LIST", + "sharing_silver_appbar_create_shared_album": "Create shared album", + "sharing_silver_appbar_share_partner": "Share with partner", + "tab_controller_nav_library": "Library", + "tab_controller_nav_photos": "Photos", + "tab_controller_nav_search": "Search", + "tab_controller_nav_sharing": "Sharing", + "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", + "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_dark_mode_switch": "Dark mode", + "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", + "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_system_theme_switch": "Automatic (Follow system setting)", + "theme_setting_theme_subtitle": "Choose the app's theme setting", + "theme_setting_theme_title": "Theme", + "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", + "theme_setting_three_stage_loading_title": "Enable three-stage loading", + "version_announcement_overlay_ack": "Acknowledge", + "version_announcement_overlay_release_notes": "release notes", + "version_announcement_overlay_text_1": "Hi friend, there is a new release of", + "version_announcement_overlay_text_2": "please take your time to visit the ", + "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", + "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89" +} \ No newline at end of file diff --git a/mobile/assets/i18n/nl-NL.json b/mobile/assets/i18n/nl-NL.json index e2973eabb..501a62a99 100644 --- a/mobile/assets/i18n/nl-NL.json +++ b/mobile/assets/i18n/nl-NL.json @@ -1,8 +1,8 @@ { "add_to_album_bottom_sheet_added": "Toegevoegd aan {album}", "add_to_album_bottom_sheet_already_exists": "Staat al in {album}", - "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", - "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_prefer_remote_subtitle": "Sommige apparaten zijn traag met het laden van afbeeldingen die zijn opgeslagen op het apparaat. Activeer deze instelling om in plaats daarvan externe afbeeldingen te laden.", + "advanced_settings_prefer_remote_title": "Externe afbeeldingen laden", "advanced_settings_tile_subtitle": "Geavanceerde gebruikersinstellingen", "advanced_settings_tile_title": "Geavanceerd", "advanced_settings_troubleshooting_subtitle": "Schakel extra functies voor probleemoplossing in ", @@ -22,7 +22,7 @@ "album_viewer_appbar_share_leave": "Verlaat album", "album_viewer_appbar_share_remove": "Verwijder uit album", "album_viewer_page_share_add_users": "Gebruikers toevoegen", - "all_people_page_title": "People", + "all_people_page_title": "Personen", "all_videos_page_title": "Video's", "archive_page_no_archived_assets": "Geen gearchiveerde items gevonden", "archive_page_title": "Archief ({})", @@ -194,14 +194,14 @@ "notification_permission_list_tile_content": "Geef toestemming om meldingen te versturen.", "notification_permission_list_tile_enable_button": "Meldingen inschakelen", "notification_permission_list_tile_title": "Meldingen toestaan", - "partner_page_add_partner": "Add partner", - "partner_page_empty_message": "Your photos are not yet shared with any partner.", - "partner_page_no_more_users": "No more users to add", - "partner_page_partner_add_failed": "Failed to add partner", - "partner_page_select_partner": "Select partner", - "partner_page_shared_to_title": "Shared to", - "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", - "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_add_partner": "Partner toevoegen", + "partner_page_empty_message": "Je foto's zijn nog niet gedeeld met een partner.", + "partner_page_no_more_users": "Geen gebruikers meer om toe te voegen", + "partner_page_partner_add_failed": "Partner toevoegen mislukt", + "partner_page_select_partner": "Selecteer partner", + "partner_page_shared_to_title": "Gedeeld met", + "partner_page_stop_sharing_content": "{} zal geen toegang meer hebben tot je fotos's.", + "partner_page_stop_sharing_title": "Stoppen met het delen van je foto's?", "partner_page_title": "Partner", "permission_onboarding_continue_anyway": "Toch doorgaan", "permission_onboarding_get_started": "Aan de slag", @@ -223,7 +223,7 @@ "search_page_motion_photos": "Bewegende foto's", "search_page_no_objects": "Geen objectgegevens beschikbaar", "search_page_no_places": "Geen locatiegegevens beschikbaar", - "search_page_people": "People", + "search_page_people": "Personen", "search_page_places": "Plaatsen", "search_page_recently_added": "Recent toegevoegd", "search_page_screenshots": "Screenshots", diff --git a/mobile/assets/i18n/sr-Cyrl.json b/mobile/assets/i18n/sr-Cyrl.json new file mode 100644 index 000000000..47e60789c --- /dev/null +++ b/mobile/assets/i18n/sr-Cyrl.json @@ -0,0 +1,293 @@ +{ + "add_to_album_bottom_sheet_added": "Added to {album}", + "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_tile_subtitle": "Advanced user's settings", + "advanced_settings_tile_title": "Advanced", + "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", + "advanced_settings_troubleshooting_title": "Troubleshooting", + "album_info_card_backup_album_excluded": "EXCLUDED", + "album_info_card_backup_album_included": "INCLUDED", + "album_thumbnail_card_item": "1 item", + "album_thumbnail_card_items": "{} items", + "album_thumbnail_card_shared": " · Shared", + "album_thumbnail_owned": "Owned", + "album_thumbnail_shared_by": "Shared by {}", + "album_viewer_appbar_share_delete": "Delete album", + "album_viewer_appbar_share_err_delete": "Failed to delete album", + "album_viewer_appbar_share_err_leave": "Failed to leave album", + "album_viewer_appbar_share_err_remove": "There are problems in removing assets from album", + "album_viewer_appbar_share_err_title": "Failed to change album title", + "album_viewer_appbar_share_leave": "Leave album", + "album_viewer_appbar_share_remove": "Remove from album", + "album_viewer_page_share_add_users": "Add users", + "all_people_page_title": "People", + "all_videos_page_title": "Videos", + "archive_page_no_archived_assets": "No archived assets found", + "archive_page_title": "Archive ({})", + "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout", + "asset_list_layout_settings_group_automatically": "Automatic", + "asset_list_layout_settings_group_by": "Group assets by", + "asset_list_layout_settings_group_by_month": "Month", + "asset_list_layout_settings_group_by_month_day": "Month + day", + "asset_list_settings_subtitle": "Photo grid layout settings", + "asset_list_settings_title": "Photo Grid", + "backup_album_selection_page_albums_device": "Albums on device ({})", + "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", + "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", + "backup_album_selection_page_select_albums": "Select albums", + "backup_album_selection_page_selection_info": "Selection Info", + "backup_album_selection_page_total_assets": "Total unique assets", + "backup_all": "All", + "backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…", + "backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…", + "backup_background_service_current_upload_notification": "Uploading {}", + "backup_background_service_default_notification": "Checking for new assets…", + "backup_background_service_error_title": "Backup error", + "backup_background_service_in_progress_notification": "Backing up your assets…", + "backup_background_service_upload_failure_notification": "Failed to upload {}", + "backup_controller_page_albums": "Backup Albums", + "backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.", + "backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled", + "backup_controller_page_background_app_refresh_enable_button_text": "Go to settings", + "backup_controller_page_background_battery_info_link": "Show me how", + "backup_controller_page_background_battery_info_message": "For the best background backup experience, please disable any battery optimizations restricting background activity for Immich.\n\nSince this is device-specific, please lookup the required information for your device manufacturer.", + "backup_controller_page_background_battery_info_ok": "OK", + "backup_controller_page_background_battery_info_title": "Battery optimizations", + "backup_controller_page_background_charging": "Only while charging", + "backup_controller_page_background_configure_error": "Failed to configure the background service", + "backup_controller_page_background_delay": "Delay new assets backup: {}", + "backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app", + "backup_controller_page_background_is_off": "Automatic background backup is off", + "backup_controller_page_background_is_on": "Automatic background backup is on", + "backup_controller_page_background_turn_off": "Turn off background service", + "backup_controller_page_background_turn_on": "Turn on background service", + "backup_controller_page_background_wifi": "Only on WiFi", + "backup_controller_page_backup": "Backup", + "backup_controller_page_backup_selected": "Selected: ", + "backup_controller_page_backup_sub": "Backed up photos and videos", + "backup_controller_page_cancel": "Cancel", + "backup_controller_page_created": "Created on: {}", + "backup_controller_page_desc_backup": "Turn on foreground backup to automatically upload new assets to the server when opening the app.", + "backup_controller_page_excluded": "Excluded: ", + "backup_controller_page_failed": "Failed ({})", + "backup_controller_page_filename": "File name: {} [{}]", + "backup_controller_page_id": "ID: {}", + "backup_controller_page_info": "Backup Information", + "backup_controller_page_none_selected": "None selected", + "backup_controller_page_remainder": "Remainder", + "backup_controller_page_remainder_sub": "Remaining photos and videos to back up from selection", + "backup_controller_page_select": "Select", + "backup_controller_page_server_storage": "Server Storage", + "backup_controller_page_start_backup": "Start Backup", + "backup_controller_page_status_off": "Automatic foreground backup is off", + "backup_controller_page_status_on": "Automatic foreground backup is on", + "backup_controller_page_storage_format": "{} of {} used", + "backup_controller_page_to_backup": "Albums to be backup", + "backup_controller_page_total": "Total", + "backup_controller_page_total_sub": "All unique photos and videos from selected albums", + "backup_controller_page_turn_off": "Turn off foreground backup", + "backup_controller_page_turn_on": "Turn on foreground backup", + "backup_controller_page_uploading_file_info": "Uploading file info", + "backup_err_only_album": "Cannot remove the only album", + "backup_info_card_assets": "assets", + "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", + "cache_settings_clear_cache_button": "Clear cache", + "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", + "cache_settings_image_cache_size": "Image cache size ({} assets)", + "cache_settings_statistics_album": "Library thumbnails", + "cache_settings_statistics_assets": "{} assets ({})", + "cache_settings_statistics_full": "Full images", + "cache_settings_statistics_shared": "Shared album thumbnails", + "cache_settings_statistics_thumbnail": "Thumbnails", + "cache_settings_statistics_title": "Cache usage", + "cache_settings_subtitle": "Control the caching behaviour of the Immich mobile application", + "cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)", + "cache_settings_title": "Caching Settings", + "change_password_form_confirm_password": "Confirm Password", + "change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_new_password": "New Password", + "change_password_form_password_mismatch": "Passwords do not match", + "change_password_form_reenter_new_password": "Re-enter New Password", + "common_add_to_album": "Add to album", + "common_change_password": "Change Password", + "common_create_new_album": "Create new album", + "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", + "common_shared": "Shared", + "control_bottom_app_bar_add_to_album": "Add to album", + "control_bottom_app_bar_album_info": "{} items", + "control_bottom_app_bar_album_info_shared": "{} items · Shared", + "control_bottom_app_bar_archive": "Archive", + "control_bottom_app_bar_create_new_album": "Create new album", + "control_bottom_app_bar_delete": "Delete", + "control_bottom_app_bar_favorite": "Favorite", + "control_bottom_app_bar_share": "Share", + "control_bottom_app_bar_unarchive": "Unarchive", + "create_album_page_untitled": "Untitled", + "create_shared_album_page_create": "Create", + "create_shared_album_page_share": "Share", + "create_shared_album_page_share_add_assets": "ADD ASSETS", + "create_shared_album_page_share_select_photos": "Select Photos", + "curated_location_page_title": "Places", + "curated_object_page_title": "Things", + "daily_title_text_date": "E, MMM dd", + "daily_title_text_date_year": "E, MMM dd, yyyy", + "date_format": "E, LLL d, y • h:mm a", + "delete_dialog_alert": "These items will be permanently deleted from Immich and from your device", + "delete_dialog_cancel": "Cancel", + "delete_dialog_ok": "Delete", + "delete_dialog_title": "Delete Permanently", + "description_input_hint_text": "Add description...", + "description_input_submit_error": "Error updating description, check the log for more details", + "exif_bottom_sheet_description": "Add Description...", + "exif_bottom_sheet_details": "DETAILS", + "exif_bottom_sheet_location": "LOCATION", + "experimental_settings_new_asset_list_subtitle": "Work in progress", + "experimental_settings_new_asset_list_title": "Enable experimental photo grid", + "experimental_settings_subtitle": "Use at your own risk!", + "experimental_settings_title": "Experimental", + "favorites_page_no_favorites": "No favorite assets found", + "favorites_page_title": "Favorites", + "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", + "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", + "home_page_add_to_album_success": "Added {added} assets to album {album}.", + "home_page_archive_err_local": "Can not archive local assets yet, skipping", + "home_page_building_timeline": "Building the timeline", + "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", + "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", + "image_viewer_page_state_provider_download_error": "Download Error", + "image_viewer_page_state_provider_download_success": "Download Success", + "library_page_albums": "Albums", + "library_page_archive": "Archive", + "library_page_device_albums": "Albums on Device", + "library_page_favorites": "Favorites", + "library_page_new_album": "New album", + "library_page_sharing": "Sharing", + "library_page_sort_created": "Most recently created", + "library_page_sort_title": "Album title", + "login_form_api_exception": "API exception. Please check the server URL and try again.", + "login_form_button_text": "Login", + "login_form_email_hint": "youremail@email.com", + "login_form_endpoint_hint": "http://your-server-ip:port/api", + "login_form_endpoint_url": "Server Endpoint URL", + "login_form_err_http": "Please specify http:// or https://", + "login_form_err_invalid_email": "Invalid Email", + "login_form_err_invalid_url": "Invalid URL", + "login_form_err_leading_whitespace": "Leading whitespace", + "login_form_err_trailing_whitespace": "Trailing whitespace", + "login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL", + "login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server", + "login_form_failed_login": "Error logging you in, check server URL, email and password", + "login_form_label_email": "Email", + "login_form_label_password": "Password", + "login_form_next_button": "Next", + "login_form_password_hint": "password", + "login_form_save_login": "Stay logged in", + "login_form_server_empty": "Enter a server URL.", + "login_form_server_error": "Could not connect to server.", + "monthly_title_text_date_format": "MMMM y", + "motion_photos_page_title": "Motion Photos", + "notification_permission_dialog_cancel": "Cancel", + "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", + "notification_permission_dialog_settings": "Settings", + "notification_permission_list_tile_content": "Grant permission to enable notifications.", + "notification_permission_list_tile_enable_button": "Enable Notifications", + "notification_permission_list_tile_title": "Notification Permission", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", + "permission_onboarding_continue_anyway": "Continue anyway", + "permission_onboarding_get_started": "Get started", + "permission_onboarding_go_to_settings": "Go to settings", + "permission_onboarding_grant_permission": "Grant permission", + "permission_onboarding_log_out": "Log out", + "permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.", + "permission_onboarding_permission_granted": "Permission granted! You are all set.", + "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", + "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "profile_drawer_app_logs": "Logs", + "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date", + "profile_drawer_settings": "Settings", + "profile_drawer_sign_out": "Sign Out", + "recently_added_page_title": "Recently Added", + "search_bar_hint": "Search your photos", + "search_page_categories": "Categories", + "search_page_favorites": "Favorites", + "search_page_motion_photos": "Motion Photos", + "search_page_no_objects": "No Objects Info Available", + "search_page_no_places": "No Places Info Available", + "search_page_people": "People", + "search_page_places": "Places", + "search_page_recently_added": "Recently added", + "search_page_screenshots": "Screenshots", + "search_page_selfies": "Selfies", + "search_page_things": "Things", + "search_page_videos": "Videos", + "search_page_view_all_button": "View all", + "search_page_your_activity": "Your activity", + "search_result_page_new_search_hint": "New Search", + "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", + "search_suggestion_list_smart_search_hint_2": "m:your-search-term", + "select_additional_user_for_sharing_page_suggestions": "Suggestions", + "select_user_for_sharing_page_err_album": "Failed to create album", + "select_user_for_sharing_page_share_suggestions": "Suggestions", + "server_info_box_app_version": "App Version", + "server_info_box_server_version": "Server Version", + "setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).", + "setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).", + "setting_image_viewer_original_title": "Load original image", + "setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.", + "setting_image_viewer_preview_title": "Load preview image", + "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", + "setting_notifications_notify_hours": "{} hours", + "setting_notifications_notify_immediately": "immediately", + "setting_notifications_notify_minutes": "{} minutes", + "setting_notifications_notify_never": "never", + "setting_notifications_notify_seconds": "{} seconds", + "setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset", + "setting_notifications_single_progress_title": "Show background backup detail progress", + "setting_notifications_subtitle": "Adjust your notification preferences", + "setting_notifications_title": "Notifications", + "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", + "setting_notifications_total_progress_title": "Show background backup total progress", + "setting_pages_app_bar_settings": "Settings", + "settings_require_restart": "Please restart Immich to apply this setting", + "share_add": "Add", + "share_add_photos": "Add photos", + "share_add_title": "Add a title", + "share_create_album": "Create album", + "share_dialog_preparing": "Preparing...", + "share_invite": "Invite to album", + "sharing_page_album": "Shared albums", + "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", + "sharing_page_empty_list": "EMPTY LIST", + "sharing_silver_appbar_create_shared_album": "Create shared album", + "sharing_silver_appbar_share_partner": "Share with partner", + "tab_controller_nav_library": "Library", + "tab_controller_nav_photos": "Photos", + "tab_controller_nav_search": "Search", + "tab_controller_nav_sharing": "Sharing", + "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", + "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_dark_mode_switch": "Dark mode", + "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", + "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_system_theme_switch": "Automatic (Follow system setting)", + "theme_setting_theme_subtitle": "Choose the app's theme setting", + "theme_setting_theme_title": "Theme", + "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", + "theme_setting_three_stage_loading_title": "Enable three-stage loading", + "version_announcement_overlay_ack": "Acknowledge", + "version_announcement_overlay_release_notes": "release notes", + "version_announcement_overlay_text_1": "Hi friend, there is a new release of", + "version_announcement_overlay_text_2": "please take your time to visit the ", + "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", + "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89" +} \ No newline at end of file diff --git a/mobile/assets/i18n/sr-Latn.json b/mobile/assets/i18n/sr-Latn.json new file mode 100644 index 000000000..6d466d052 --- /dev/null +++ b/mobile/assets/i18n/sr-Latn.json @@ -0,0 +1,293 @@ +{ + "add_to_album_bottom_sheet_added": "Dodato u {album}", + "add_to_album_bottom_sheet_already_exists": "Već u {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_tile_subtitle": "Advanced user's settings", + "advanced_settings_tile_title": "Advanced", + "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", + "advanced_settings_troubleshooting_title": "Troubleshooting", + "album_info_card_backup_album_excluded": "ISKLJUČENO", + "album_info_card_backup_album_included": "UKLJUČENO", + "album_thumbnail_card_item": "1 stavka", + "album_thumbnail_card_items": "{} stavki", + "album_thumbnail_card_shared": "Deljeno", + "album_thumbnail_owned": "Owned", + "album_thumbnail_shared_by": "Shared by {}", + "album_viewer_appbar_share_delete": "Obriši album", + "album_viewer_appbar_share_err_delete": "Neuspešno brisanje albuma", + "album_viewer_appbar_share_err_leave": "Neuspešno izlaženje iz albuma", + "album_viewer_appbar_share_err_remove": "Problemi sa brisanjem zapisa iz albuma", + "album_viewer_appbar_share_err_title": "Neuspešno menjanje naziva albuma", + "album_viewer_appbar_share_leave": "Izađi iz albuma", + "album_viewer_appbar_share_remove": "Obriši iz albuma", + "album_viewer_page_share_add_users": "Dodaj korisnike", + "all_people_page_title": "People", + "all_videos_page_title": "Videos", + "archive_page_no_archived_assets": "No archived assets found", + "archive_page_title": "Archive ({})", + "asset_list_layout_settings_dynamic_layout_title": "Dinamični raspored", + "asset_list_layout_settings_group_automatically": "Automatic", + "asset_list_layout_settings_group_by": "Grupiši zapise po", + "asset_list_layout_settings_group_by_month": "Mesec", + "asset_list_layout_settings_group_by_month_day": "Mesec + Dan", + "asset_list_settings_subtitle": "Opcije za mrežni prikaz fotografija", + "asset_list_settings_title": "Mrežni prikaz fotografija", + "backup_album_selection_page_albums_device": "Albuma na uređaju ({})", + "backup_album_selection_page_albums_tap": "Dodirni da uključiš, dodirni dvaput da isključiš", + "backup_album_selection_page_assets_scatter": "Zapisi se mogu naći u više različitih albuma. Odatle albumi se mogu uključiti ili isključiti tokom procesa pravljenja pozadinskih kopija.", + "backup_album_selection_page_select_albums": "Odaberi albume", + "backup_album_selection_page_selection_info": "Informacije o selekciji", + "backup_album_selection_page_total_assets": "Ukupno jedinstvenih ***", + "backup_all": "Sve", + "backup_background_service_backup_failed_message": "Neuspešno pravljenje rezervne kopije. Pokušavam ponovo...", + "backup_background_service_connection_failed_message": "Neuspešno povezivanje sa serverom. Pokušavam ponovo...", + "backup_background_service_current_upload_notification": "Otpremanje {}", + "backup_background_service_default_notification": "Proveravanje novih zapisa", + "backup_background_service_error_title": "Greška u pravljenju rezervnih kopija", + "backup_background_service_in_progress_notification": "Pravljenje rezervnih kopija zapisa", + "backup_background_service_upload_failure_notification": "Neuspešno otpremljeno: {}", + "backup_controller_page_albums": "Napravi rezervnu kopiju albuma", + "backup_controller_page_background_app_refresh_disabled_content": "Aktiviraj pozadinsko osvežavanje u Opcije Generalne Pozadinsko Osvežavanje kako bi napravili rezervne kopije u pozadini", + "backup_controller_page_background_app_refresh_disabled_title": "Pozadinsko osvežavanje isključeno", + "backup_controller_page_background_app_refresh_enable_button_text": "Idi u podešavanja", + "backup_controller_page_background_battery_info_link": "Pokaži mi kako", + "backup_controller_page_background_battery_info_message": "Za najpouzdanije pravljenje rezervnih kopija, ugasite bilo koju opciju u optimizacijama koje bi sprečavale Immich sa pravilnim radom.\n\nOvaj postupak varira od uređaja do uređaja, proverite potrebne korake za Vaš uređaj.", + "backup_controller_page_background_battery_info_ok": "OK", + "backup_controller_page_background_battery_info_title": "Optimizacija Baterije", + "backup_controller_page_background_charging": "Samo tokom punjenja", + "backup_controller_page_background_configure_error": "Neuspešno konfigurisanje pozadinskog servisa", + "backup_controller_page_background_delay": "Vreme između pravljejna rezervnih kopija zapisa: {}", + "backup_controller_page_background_description": "Uključi pozadinski servis da automatski praviš rezervne kopije, bez da otvaraš aplikaciju", + "backup_controller_page_background_is_off": "Automatsko pravljenje rezervnih kopija u pozadini je isključeno", + "backup_controller_page_background_is_on": "Automatsko pravljenje rezervnih kopija u pozadini je uključeno", + "backup_controller_page_background_turn_off": "Isključi pozadinski servis", + "backup_controller_page_background_turn_on": "Uključi pozadinski servis", + "backup_controller_page_background_wifi": "Samo na WiFi", + "backup_controller_page_backup": "Napravi rezervnu kopiju", + "backup_controller_page_backup_selected": "Odabrano:", + "backup_controller_page_backup_sub": "Završeno pravljenje rezervne kopije fotografija i videa", + "backup_controller_page_cancel": "Odustani", + "backup_controller_page_created": "Napravljeno:{}", + "backup_controller_page_desc_backup": "Uključi pravljenje rezervnih kopija u prvom planu da automatski napravite rezervne kopije kada otvorite aplikaciju.", + "backup_controller_page_excluded": "Isključeno:", + "backup_controller_page_failed": "Neuspešno ({})", + "backup_controller_page_filename": "Ime fajla:{} [{}]", + "backup_controller_page_id": "ID:{}", + "backup_controller_page_info": "Informacije", + "backup_controller_page_none_selected": "Ništa odabrano", + "backup_controller_page_remainder": "Podsetnik", + "backup_controller_page_remainder_sub": "Ostalo fotografija i videa da se otpremi od selekcije", + "backup_controller_page_select": "Odaberi", + "backup_controller_page_server_storage": "Prostor na serveru", + "backup_controller_page_start_backup": "Pokreni pravljenje rezervne kopije", + "backup_controller_page_status_off": "Automatsko pravljenje rezervnih kopija u prvom planu je isključeno", + "backup_controller_page_status_on": "Automatsko pravljenje rezervnih kopija u prvom planu je uključeno", + "backup_controller_page_storage_format": "{} od {} iskorišćeno", + "backup_controller_page_to_backup": "Albumi koji će se otpremiti", + "backup_controller_page_total": "Ukupno", + "backup_controller_page_total_sub": "Sve jedinstvene fotografije i videi iz odabranih albuma", + "backup_controller_page_turn_off": "Isključi pravljenje rezervnih kopija u prvom planu", + "backup_controller_page_turn_on": "Uključi pravljenje rezervnih kopija u prvom planu", + "backup_controller_page_uploading_file_info": "Otpremanje svojstava datoteke", + "backup_err_only_album": "Nemoguće brisanje jedinog albuma", + "backup_info_card_assets": "zapisi", + "cache_settings_album_thumbnails": "Sličice na stranici biblioteke", + "cache_settings_clear_cache_button": "Obriši keš memoriju", + "cache_settings_clear_cache_button_title": "Ova opcija briše keš memoriju aplikacije. Ovo će bitno uticati na performanse aplikacije dok se keš memorija ne učita ponovo.", + "cache_settings_image_cache_size": "Veličina keš memorije slika ({} stavki)", + "cache_settings_statistics_album": "Minijature biblioteka", + "cache_settings_statistics_assets": "{} stavki ({})", + "cache_settings_statistics_full": "Pune slike", + "cache_settings_statistics_shared": "Minijature deljenih albuma", + "cache_settings_statistics_thumbnail": "Minijature", + "cache_settings_statistics_title": "Iskorišćena keš memorija", + "cache_settings_subtitle": "Kontrole za keš memoriju mobilne aplikacije Immich", + "cache_settings_thumbnail_size": "Keš memorija koju zauzimaju minijature ({} stavki)", + "cache_settings_title": "Opcije za keširanje", + "change_password_form_confirm_password": "Ponovo unesite šifru", + "change_password_form_description": "Ćao, {firstName}, {lastName}\n\nOvo je verovatno Vaše prvo pristupanje sistemu, ili je podnešen zahtev za promenu šifre. Molimo Vas, unesite novu šifru ispod", + "change_password_form_new_password": "Nova šifra", + "change_password_form_password_mismatch": "Šifre se ne podudaraju", + "change_password_form_reenter_new_password": "Ponovo unesite novu šifru", + "common_add_to_album": "Dodaj u album", + "common_change_password": "Promeni Šifru", + "common_create_new_album": "Kreiraj novi album", + "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", + "common_shared": "Deljeno", + "control_bottom_app_bar_add_to_album": "Dodaj u album", + "control_bottom_app_bar_album_info": "{} stvari", + "control_bottom_app_bar_album_info_shared": "{} stvari podeljeno", + "control_bottom_app_bar_archive": "Archive", + "control_bottom_app_bar_create_new_album": "Kreiraj novi album", + "control_bottom_app_bar_delete": "Obriši", + "control_bottom_app_bar_favorite": "Omliljeno", + "control_bottom_app_bar_share": "Podeli", + "control_bottom_app_bar_unarchive": "Unarchive", + "create_album_page_untitled": "Bez naslova", + "create_shared_album_page_create": "Napravi", + "create_shared_album_page_share": "Podeli", + "create_shared_album_page_share_add_assets": "DODAJ ", + "create_shared_album_page_share_select_photos": "Odaberi fotografije", + "curated_location_page_title": "Places", + "curated_object_page_title": "Things", + "daily_title_text_date": "E, MMM dd", + "daily_title_text_date_year": "E, MMM dd, yyyy", + "date_format": "E, LLL d, y • h:mm a", + "delete_dialog_alert": "Ove stvari će permanentno biti obrisane sa Immich-a i Vašeg uređaja", + "delete_dialog_cancel": "Odustani", + "delete_dialog_ok": "Obriši", + "delete_dialog_title": "Obriši permanentno", + "description_input_hint_text": "Add description...", + "description_input_submit_error": "Error updating description, check the log for more details", + "exif_bottom_sheet_description": "Dodaj opis...", + "exif_bottom_sheet_details": "DETALJI", + "exif_bottom_sheet_location": "LOKACIJA", + "experimental_settings_new_asset_list_subtitle": "U izradi", + "experimental_settings_new_asset_list_title": "Aktiviraj eksperimentalni mrežni prikaz fotografija", + "experimental_settings_subtitle": "Koristiti na sopstvenu odgovornost!", + "experimental_settings_title": "Eksperimentalno", + "favorites_page_no_favorites": "No favorite assets found", + "favorites_page_title": "Omiljeno", + "home_page_add_to_album_conflicts": "Dodat {added} zapis u album {album}. {failed} zapisi su već u albumu ", + "home_page_add_to_album_err_local": "Trenutno nemoguće dodati lokalne zapise u albume, preskacu se", + "home_page_add_to_album_success": "Dodate {added} stavke u album {album}.", + "home_page_archive_err_local": "Can not archive local assets yet, skipping", + "home_page_building_timeline": "Kreiranje hronološke linije", + "home_page_favorite_err_local": "Trenutno nije moguce dodati lokalne zapise u favorite, preskacu se", + "home_page_first_time_notice": "Ako je ovo prvi put da koristite aplikaciju, molimo Vas da odaberete albume koje želite da sačuvate", + "image_viewer_page_state_provider_download_error": "Preuzimanje Neuspešno", + "image_viewer_page_state_provider_download_success": "Preuzimanje Uspešno", + "library_page_albums": "Albumi", + "library_page_archive": "Archive", + "library_page_device_albums": "Albums on Device", + "library_page_favorites": "Omiljeno", + "library_page_new_album": "Novi album", + "library_page_sharing": "Deljenje", + "library_page_sort_created": "Najnovije kreirano", + "library_page_sort_title": "Naziv albuma", + "login_form_api_exception": "API exception. Please check the server URL and try again.", + "login_form_button_text": "Prijavi se", + "login_form_email_hint": "vašemail@email.com", + "login_form_endpoint_hint": "http://ip-vašeg-servera:port/api", + "login_form_endpoint_url": "URL Servera", + "login_form_err_http": "Dopiši http:// ili https://", + "login_form_err_invalid_email": "Nevažeći Email", + "login_form_err_invalid_url": "Ne važeći link (URL)", + "login_form_err_leading_whitespace": "Razmak ispred", + "login_form_err_trailing_whitespace": "Razmak iza", + "login_form_failed_get_oauth_server_config": "Evidencija grešaka koristeći OAuth, proveriti serverski link (URL)", + "login_form_failed_get_oauth_server_disable": "OAuth opcija nije dostupna na ovom serveru", + "login_form_failed_login": "Neuspešna prijava, proveri URL servera, email i šifru", + "login_form_label_email": "Email", + "login_form_label_password": "Šifra", + "login_form_next_button": "Next", + "login_form_password_hint": "šifra", + "login_form_save_login": "Ostani prijavljen", + "login_form_server_empty": "Enter a server URL.", + "login_form_server_error": "Could not connect to server.", + "monthly_title_text_date_format": "MMMM y", + "motion_photos_page_title": "Motion Photos", + "notification_permission_dialog_cancel": "Odustani", + "notification_permission_dialog_content": "Da bi ukljucili notifikacije, idite u Opcije i odaberite Dozvoli", + "notification_permission_dialog_settings": "Podešavanja", + "notification_permission_list_tile_content": "Dozvoli Notifikacije\n", + "notification_permission_list_tile_enable_button": "Uključi Notifikacije", + "notification_permission_list_tile_title": "Dozvole za notifikacije", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", + "permission_onboarding_continue_anyway": "Continue anyway", + "permission_onboarding_get_started": "Get started", + "permission_onboarding_go_to_settings": "Go to settings", + "permission_onboarding_grant_permission": "Grant permission", + "permission_onboarding_log_out": "Log out", + "permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.", + "permission_onboarding_permission_granted": "Permission granted! You are all set.", + "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", + "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "profile_drawer_app_logs": "Evidencija", + "profile_drawer_client_server_up_to_date": "Klijent i server su najnovije verzije", + "profile_drawer_settings": "Opcije", + "profile_drawer_sign_out": "Odjavi se", + "recently_added_page_title": "Recently Added", + "search_bar_hint": "Pretražite Vaše fotografije", + "search_page_categories": "Categories", + "search_page_favorites": "Favorites", + "search_page_motion_photos": "Motion Photos", + "search_page_no_objects": "Bez informacija", + "search_page_no_places": "Nema informacija o mestu", + "search_page_people": "People", + "search_page_places": "Mesta", + "search_page_recently_added": "Recently added", + "search_page_screenshots": "Screenshots", + "search_page_selfies": "Selfies", + "search_page_things": "Stvari", + "search_page_videos": "Videos", + "search_page_view_all_button": "View all", + "search_page_your_activity": "Your activity", + "search_result_page_new_search_hint": "Nova pretraga", + "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", + "search_suggestion_list_smart_search_hint_2": "m:your-search-term", + "select_additional_user_for_sharing_page_suggestions": "Sugsetije", + "select_user_for_sharing_page_err_album": "Neuspešno kreiranje albuma", + "select_user_for_sharing_page_share_suggestions": "Sugestije", + "server_info_box_app_version": "Verzija Aplikacije", + "server_info_box_server_version": "Verzija Servera", + "setting_image_viewer_help": "Detaljno pregledanje prvo učitava minijaturu, pa srednju, pa original. (Ako te opcije uključene)", + "setting_image_viewer_original_subtitle": "Aktiviraj učitavanje slika u punoj rezoluciji (Velika!). Deaktivacijom ove stavke možeš da smanjiš potrošnju interneta i zauzetog prostora na uređaju.", + "setting_image_viewer_original_title": "Učitaj originalnu sliku", + "setting_image_viewer_preview_subtitle": "Aktiviraj učitavanje slika u srednjoj rezoluciji. Deaktiviraj da se direktno učitava original, ili da se samo koristi minijatura.", + "setting_image_viewer_preview_title": "Pregledaj sliku", + "setting_notifications_notify_failures_grace_period": "Neuspešne rezervne kopije: {}", + "setting_notifications_notify_hours": "{} sati", + "setting_notifications_notify_immediately": "odmah", + "setting_notifications_notify_minutes": "{} minuta", + "setting_notifications_notify_never": "nikada", + "setting_notifications_notify_seconds": "{} sekundi", + "setting_notifications_single_progress_subtitle": "Detaljne informacije o otpremanju, po zapisu", + "setting_notifications_single_progress_title": "Prikaži detalje pozadinskog pravljenja rezervnih kopija", + "setting_notifications_subtitle": "Izmeni notifikacije", + "setting_notifications_title": "Notifikacije", + "setting_notifications_total_progress_subtitle": "Ukupno otpremljenih stavki (završeno/ukupno stavki)", + "setting_notifications_total_progress_title": "Prikaži ukupan napredak pozadinskog bekapovanja.\n\n", + "setting_pages_app_bar_settings": "Opcije", + "settings_require_restart": "Restartujte Immich da primenite ovu promenu", + "share_add": "Dodaj", + "share_add_photos": "Dodaj fotografije", + "share_add_title": "Dodaj naslov", + "share_create_album": "Napravi album", + "share_dialog_preparing": "Pripremanje...", + "share_invite": "Pozivnica za album", + "sharing_page_album": "Deljeni albumi", + "sharing_page_description": "Napravi deljene albume da deliš fotografije i video zapise sa ljudima na tvojoj mreži", + "sharing_page_empty_list": "PRAZNA LISTA", + "sharing_silver_appbar_create_shared_album": "Napravi deljeni album", + "sharing_silver_appbar_share_partner": "Podeli sa partnerom", + "tab_controller_nav_library": "Biblioteka", + "tab_controller_nav_photos": "Slike", + "tab_controller_nav_search": "Pretraga", + "tab_controller_nav_sharing": "Deljenje", + "theme_setting_asset_list_storage_indicator_title": "Prikaži indikator prostora na zapisima", + "theme_setting_asset_list_tiles_per_row_title": "Broj zapisa po redu ({})", + "theme_setting_dark_mode_switch": "Tamni Mod", + "theme_setting_image_viewer_quality_subtitle": "Prilagodite kvalitet prikaza za detaljno pregledavanje slike", + "theme_setting_image_viewer_quality_title": "Kvalitet pregledača slika", + "theme_setting_system_theme_switch": "Automatski (Prati opcije sistema)", + "theme_setting_theme_subtitle": "Odaberi temu sistema", + "theme_setting_theme_title": "Teme", + "theme_setting_three_stage_loading_subtitle": "Trostepeno učitavanje možda ubrza učitavanje, po cenu potrošnje podataka", + "theme_setting_three_stage_loading_title": "Aktiviraj trostepeno učitavanje", + "version_announcement_overlay_ack": "Priznati", + "version_announcement_overlay_release_notes": "novine nove verzije", + "version_announcement_overlay_text_1": "Ćao, nova verzija", + "version_announcement_overlay_text_2": "molimo Vas izdvojite vremena da pogledate", + "version_announcement_overlay_text_3": "i proverite da su Vaš docker-compose i .env najnovije verzije da bi izbegli greške u radu. Pogotovu ako koristite WatchTower ili bilo koji drugi mehanizam koji automatski instalira nove verzije vaše serverske aplikacije.", + "version_announcement_overlay_title": "Nova verzija servera je dostupna \uD83C\uDF89" +} \ No newline at end of file diff --git a/mobile/assets/i18n/sv-FI.json b/mobile/assets/i18n/sv-FI.json new file mode 100644 index 000000000..47e60789c --- /dev/null +++ b/mobile/assets/i18n/sv-FI.json @@ -0,0 +1,293 @@ +{ + "add_to_album_bottom_sheet_added": "Added to {album}", + "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_tile_subtitle": "Advanced user's settings", + "advanced_settings_tile_title": "Advanced", + "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", + "advanced_settings_troubleshooting_title": "Troubleshooting", + "album_info_card_backup_album_excluded": "EXCLUDED", + "album_info_card_backup_album_included": "INCLUDED", + "album_thumbnail_card_item": "1 item", + "album_thumbnail_card_items": "{} items", + "album_thumbnail_card_shared": " · Shared", + "album_thumbnail_owned": "Owned", + "album_thumbnail_shared_by": "Shared by {}", + "album_viewer_appbar_share_delete": "Delete album", + "album_viewer_appbar_share_err_delete": "Failed to delete album", + "album_viewer_appbar_share_err_leave": "Failed to leave album", + "album_viewer_appbar_share_err_remove": "There are problems in removing assets from album", + "album_viewer_appbar_share_err_title": "Failed to change album title", + "album_viewer_appbar_share_leave": "Leave album", + "album_viewer_appbar_share_remove": "Remove from album", + "album_viewer_page_share_add_users": "Add users", + "all_people_page_title": "People", + "all_videos_page_title": "Videos", + "archive_page_no_archived_assets": "No archived assets found", + "archive_page_title": "Archive ({})", + "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout", + "asset_list_layout_settings_group_automatically": "Automatic", + "asset_list_layout_settings_group_by": "Group assets by", + "asset_list_layout_settings_group_by_month": "Month", + "asset_list_layout_settings_group_by_month_day": "Month + day", + "asset_list_settings_subtitle": "Photo grid layout settings", + "asset_list_settings_title": "Photo Grid", + "backup_album_selection_page_albums_device": "Albums on device ({})", + "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", + "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", + "backup_album_selection_page_select_albums": "Select albums", + "backup_album_selection_page_selection_info": "Selection Info", + "backup_album_selection_page_total_assets": "Total unique assets", + "backup_all": "All", + "backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…", + "backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…", + "backup_background_service_current_upload_notification": "Uploading {}", + "backup_background_service_default_notification": "Checking for new assets…", + "backup_background_service_error_title": "Backup error", + "backup_background_service_in_progress_notification": "Backing up your assets…", + "backup_background_service_upload_failure_notification": "Failed to upload {}", + "backup_controller_page_albums": "Backup Albums", + "backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.", + "backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled", + "backup_controller_page_background_app_refresh_enable_button_text": "Go to settings", + "backup_controller_page_background_battery_info_link": "Show me how", + "backup_controller_page_background_battery_info_message": "For the best background backup experience, please disable any battery optimizations restricting background activity for Immich.\n\nSince this is device-specific, please lookup the required information for your device manufacturer.", + "backup_controller_page_background_battery_info_ok": "OK", + "backup_controller_page_background_battery_info_title": "Battery optimizations", + "backup_controller_page_background_charging": "Only while charging", + "backup_controller_page_background_configure_error": "Failed to configure the background service", + "backup_controller_page_background_delay": "Delay new assets backup: {}", + "backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app", + "backup_controller_page_background_is_off": "Automatic background backup is off", + "backup_controller_page_background_is_on": "Automatic background backup is on", + "backup_controller_page_background_turn_off": "Turn off background service", + "backup_controller_page_background_turn_on": "Turn on background service", + "backup_controller_page_background_wifi": "Only on WiFi", + "backup_controller_page_backup": "Backup", + "backup_controller_page_backup_selected": "Selected: ", + "backup_controller_page_backup_sub": "Backed up photos and videos", + "backup_controller_page_cancel": "Cancel", + "backup_controller_page_created": "Created on: {}", + "backup_controller_page_desc_backup": "Turn on foreground backup to automatically upload new assets to the server when opening the app.", + "backup_controller_page_excluded": "Excluded: ", + "backup_controller_page_failed": "Failed ({})", + "backup_controller_page_filename": "File name: {} [{}]", + "backup_controller_page_id": "ID: {}", + "backup_controller_page_info": "Backup Information", + "backup_controller_page_none_selected": "None selected", + "backup_controller_page_remainder": "Remainder", + "backup_controller_page_remainder_sub": "Remaining photos and videos to back up from selection", + "backup_controller_page_select": "Select", + "backup_controller_page_server_storage": "Server Storage", + "backup_controller_page_start_backup": "Start Backup", + "backup_controller_page_status_off": "Automatic foreground backup is off", + "backup_controller_page_status_on": "Automatic foreground backup is on", + "backup_controller_page_storage_format": "{} of {} used", + "backup_controller_page_to_backup": "Albums to be backup", + "backup_controller_page_total": "Total", + "backup_controller_page_total_sub": "All unique photos and videos from selected albums", + "backup_controller_page_turn_off": "Turn off foreground backup", + "backup_controller_page_turn_on": "Turn on foreground backup", + "backup_controller_page_uploading_file_info": "Uploading file info", + "backup_err_only_album": "Cannot remove the only album", + "backup_info_card_assets": "assets", + "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", + "cache_settings_clear_cache_button": "Clear cache", + "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", + "cache_settings_image_cache_size": "Image cache size ({} assets)", + "cache_settings_statistics_album": "Library thumbnails", + "cache_settings_statistics_assets": "{} assets ({})", + "cache_settings_statistics_full": "Full images", + "cache_settings_statistics_shared": "Shared album thumbnails", + "cache_settings_statistics_thumbnail": "Thumbnails", + "cache_settings_statistics_title": "Cache usage", + "cache_settings_subtitle": "Control the caching behaviour of the Immich mobile application", + "cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)", + "cache_settings_title": "Caching Settings", + "change_password_form_confirm_password": "Confirm Password", + "change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_new_password": "New Password", + "change_password_form_password_mismatch": "Passwords do not match", + "change_password_form_reenter_new_password": "Re-enter New Password", + "common_add_to_album": "Add to album", + "common_change_password": "Change Password", + "common_create_new_album": "Create new album", + "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", + "common_shared": "Shared", + "control_bottom_app_bar_add_to_album": "Add to album", + "control_bottom_app_bar_album_info": "{} items", + "control_bottom_app_bar_album_info_shared": "{} items · Shared", + "control_bottom_app_bar_archive": "Archive", + "control_bottom_app_bar_create_new_album": "Create new album", + "control_bottom_app_bar_delete": "Delete", + "control_bottom_app_bar_favorite": "Favorite", + "control_bottom_app_bar_share": "Share", + "control_bottom_app_bar_unarchive": "Unarchive", + "create_album_page_untitled": "Untitled", + "create_shared_album_page_create": "Create", + "create_shared_album_page_share": "Share", + "create_shared_album_page_share_add_assets": "ADD ASSETS", + "create_shared_album_page_share_select_photos": "Select Photos", + "curated_location_page_title": "Places", + "curated_object_page_title": "Things", + "daily_title_text_date": "E, MMM dd", + "daily_title_text_date_year": "E, MMM dd, yyyy", + "date_format": "E, LLL d, y • h:mm a", + "delete_dialog_alert": "These items will be permanently deleted from Immich and from your device", + "delete_dialog_cancel": "Cancel", + "delete_dialog_ok": "Delete", + "delete_dialog_title": "Delete Permanently", + "description_input_hint_text": "Add description...", + "description_input_submit_error": "Error updating description, check the log for more details", + "exif_bottom_sheet_description": "Add Description...", + "exif_bottom_sheet_details": "DETAILS", + "exif_bottom_sheet_location": "LOCATION", + "experimental_settings_new_asset_list_subtitle": "Work in progress", + "experimental_settings_new_asset_list_title": "Enable experimental photo grid", + "experimental_settings_subtitle": "Use at your own risk!", + "experimental_settings_title": "Experimental", + "favorites_page_no_favorites": "No favorite assets found", + "favorites_page_title": "Favorites", + "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", + "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", + "home_page_add_to_album_success": "Added {added} assets to album {album}.", + "home_page_archive_err_local": "Can not archive local assets yet, skipping", + "home_page_building_timeline": "Building the timeline", + "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", + "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", + "image_viewer_page_state_provider_download_error": "Download Error", + "image_viewer_page_state_provider_download_success": "Download Success", + "library_page_albums": "Albums", + "library_page_archive": "Archive", + "library_page_device_albums": "Albums on Device", + "library_page_favorites": "Favorites", + "library_page_new_album": "New album", + "library_page_sharing": "Sharing", + "library_page_sort_created": "Most recently created", + "library_page_sort_title": "Album title", + "login_form_api_exception": "API exception. Please check the server URL and try again.", + "login_form_button_text": "Login", + "login_form_email_hint": "youremail@email.com", + "login_form_endpoint_hint": "http://your-server-ip:port/api", + "login_form_endpoint_url": "Server Endpoint URL", + "login_form_err_http": "Please specify http:// or https://", + "login_form_err_invalid_email": "Invalid Email", + "login_form_err_invalid_url": "Invalid URL", + "login_form_err_leading_whitespace": "Leading whitespace", + "login_form_err_trailing_whitespace": "Trailing whitespace", + "login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL", + "login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server", + "login_form_failed_login": "Error logging you in, check server URL, email and password", + "login_form_label_email": "Email", + "login_form_label_password": "Password", + "login_form_next_button": "Next", + "login_form_password_hint": "password", + "login_form_save_login": "Stay logged in", + "login_form_server_empty": "Enter a server URL.", + "login_form_server_error": "Could not connect to server.", + "monthly_title_text_date_format": "MMMM y", + "motion_photos_page_title": "Motion Photos", + "notification_permission_dialog_cancel": "Cancel", + "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", + "notification_permission_dialog_settings": "Settings", + "notification_permission_list_tile_content": "Grant permission to enable notifications.", + "notification_permission_list_tile_enable_button": "Enable Notifications", + "notification_permission_list_tile_title": "Notification Permission", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", + "permission_onboarding_continue_anyway": "Continue anyway", + "permission_onboarding_get_started": "Get started", + "permission_onboarding_go_to_settings": "Go to settings", + "permission_onboarding_grant_permission": "Grant permission", + "permission_onboarding_log_out": "Log out", + "permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.", + "permission_onboarding_permission_granted": "Permission granted! You are all set.", + "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", + "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "profile_drawer_app_logs": "Logs", + "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date", + "profile_drawer_settings": "Settings", + "profile_drawer_sign_out": "Sign Out", + "recently_added_page_title": "Recently Added", + "search_bar_hint": "Search your photos", + "search_page_categories": "Categories", + "search_page_favorites": "Favorites", + "search_page_motion_photos": "Motion Photos", + "search_page_no_objects": "No Objects Info Available", + "search_page_no_places": "No Places Info Available", + "search_page_people": "People", + "search_page_places": "Places", + "search_page_recently_added": "Recently added", + "search_page_screenshots": "Screenshots", + "search_page_selfies": "Selfies", + "search_page_things": "Things", + "search_page_videos": "Videos", + "search_page_view_all_button": "View all", + "search_page_your_activity": "Your activity", + "search_result_page_new_search_hint": "New Search", + "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", + "search_suggestion_list_smart_search_hint_2": "m:your-search-term", + "select_additional_user_for_sharing_page_suggestions": "Suggestions", + "select_user_for_sharing_page_err_album": "Failed to create album", + "select_user_for_sharing_page_share_suggestions": "Suggestions", + "server_info_box_app_version": "App Version", + "server_info_box_server_version": "Server Version", + "setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).", + "setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).", + "setting_image_viewer_original_title": "Load original image", + "setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.", + "setting_image_viewer_preview_title": "Load preview image", + "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", + "setting_notifications_notify_hours": "{} hours", + "setting_notifications_notify_immediately": "immediately", + "setting_notifications_notify_minutes": "{} minutes", + "setting_notifications_notify_never": "never", + "setting_notifications_notify_seconds": "{} seconds", + "setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset", + "setting_notifications_single_progress_title": "Show background backup detail progress", + "setting_notifications_subtitle": "Adjust your notification preferences", + "setting_notifications_title": "Notifications", + "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", + "setting_notifications_total_progress_title": "Show background backup total progress", + "setting_pages_app_bar_settings": "Settings", + "settings_require_restart": "Please restart Immich to apply this setting", + "share_add": "Add", + "share_add_photos": "Add photos", + "share_add_title": "Add a title", + "share_create_album": "Create album", + "share_dialog_preparing": "Preparing...", + "share_invite": "Invite to album", + "sharing_page_album": "Shared albums", + "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", + "sharing_page_empty_list": "EMPTY LIST", + "sharing_silver_appbar_create_shared_album": "Create shared album", + "sharing_silver_appbar_share_partner": "Share with partner", + "tab_controller_nav_library": "Library", + "tab_controller_nav_photos": "Photos", + "tab_controller_nav_search": "Search", + "tab_controller_nav_sharing": "Sharing", + "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", + "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_dark_mode_switch": "Dark mode", + "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", + "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_system_theme_switch": "Automatic (Follow system setting)", + "theme_setting_theme_subtitle": "Choose the app's theme setting", + "theme_setting_theme_title": "Theme", + "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", + "theme_setting_three_stage_loading_title": "Enable three-stage loading", + "version_announcement_overlay_ack": "Acknowledge", + "version_announcement_overlay_release_notes": "release notes", + "version_announcement_overlay_text_1": "Hi friend, there is a new release of", + "version_announcement_overlay_text_2": "please take your time to visit the ", + "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", + "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89" +} \ No newline at end of file diff --git a/mobile/assets/i18n/th-TH.json b/mobile/assets/i18n/th-TH.json new file mode 100644 index 000000000..0a3276421 --- /dev/null +++ b/mobile/assets/i18n/th-TH.json @@ -0,0 +1,293 @@ +{ + "add_to_album_bottom_sheet_added": "Added to {album}", + "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "advanced_settings_prefer_remote_subtitle": "อุปกรณ์บางเครื่องโหลด thumbnails ช้ามาก เปิดการตั้งค่านี้เพื่อโหลดรูปภาพรีโมทแทน", + "advanced_settings_prefer_remote_title": "ให้ความสำคัญกับรูปภาพรีโมท", + "advanced_settings_tile_subtitle": "ตั้งค่าผู้ใช้งานขั้นสูง", + "advanced_settings_tile_title": "ขั้งสูง", + "advanced_settings_troubleshooting_subtitle": "เปิดฟีเจอร์เพิ่มเติมเพื่อแก้ไขปัญหา", + "advanced_settings_troubleshooting_title": "แก้ไขปัญหา", + "album_info_card_backup_album_excluded": "EXCLUDED", + "album_info_card_backup_album_included": "INCLUDED", + "album_thumbnail_card_item": "1 item", + "album_thumbnail_card_items": "{} items", + "album_thumbnail_card_shared": " · Shared", + "album_thumbnail_owned": "เป็นเจ้าของ", + "album_thumbnail_shared_by": "แชร์โดย {}", + "album_viewer_appbar_share_delete": "Delete album", + "album_viewer_appbar_share_err_delete": "Failed to delete album", + "album_viewer_appbar_share_err_leave": "Failed to leave album", + "album_viewer_appbar_share_err_remove": "There are problems in removing assets from album", + "album_viewer_appbar_share_err_title": "Failed to change album title", + "album_viewer_appbar_share_leave": "Leave album", + "album_viewer_appbar_share_remove": "Remove from album", + "album_viewer_page_share_add_users": "Add users", + "all_people_page_title": "ผู้คน", + "all_videos_page_title": "วิดีโอ", + "archive_page_no_archived_assets": "ไม่พบทรัพยากรในที่เก็บถาวร", + "archive_page_title": "เก็บถาวร ({})", + "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout", + "asset_list_layout_settings_group_automatically": "อัตโนมัติ", + "asset_list_layout_settings_group_by": "Group assets by", + "asset_list_layout_settings_group_by_month": "Month", + "asset_list_layout_settings_group_by_month_day": "Month + day", + "asset_list_settings_subtitle": "Photo grid layout settings", + "asset_list_settings_title": "Photo Grid", + "backup_album_selection_page_albums_device": "Albums on device ({})", + "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", + "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", + "backup_album_selection_page_select_albums": "Select albums", + "backup_album_selection_page_selection_info": "Selection Info", + "backup_album_selection_page_total_assets": "Total unique assets", + "backup_all": "All", + "backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…", + "backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…", + "backup_background_service_current_upload_notification": "Uploading {}", + "backup_background_service_default_notification": "Checking for new assets…", + "backup_background_service_error_title": "Backup error", + "backup_background_service_in_progress_notification": "Backing up your assets…", + "backup_background_service_upload_failure_notification": "Failed to upload {}", + "backup_controller_page_albums": "Backup Albums", + "backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.", + "backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled", + "backup_controller_page_background_app_refresh_enable_button_text": "Go to settings", + "backup_controller_page_background_battery_info_link": "Show me how", + "backup_controller_page_background_battery_info_message": "For the best background backup experience, please disable any battery optimizations restricting background activity for Immich.\n\nSince this is device-specific, please lookup the required information for your device manufacturer.", + "backup_controller_page_background_battery_info_ok": "OK", + "backup_controller_page_background_battery_info_title": "Battery optimizations", + "backup_controller_page_background_charging": "Only while charging", + "backup_controller_page_background_configure_error": "Failed to configure the background service", + "backup_controller_page_background_delay": "Delay new assets backup: {}", + "backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app", + "backup_controller_page_background_is_off": "Automatic background backup is off", + "backup_controller_page_background_is_on": "Automatic background backup is on", + "backup_controller_page_background_turn_off": "Turn off background service", + "backup_controller_page_background_turn_on": "Turn on background service", + "backup_controller_page_background_wifi": "Only on WiFi", + "backup_controller_page_backup": "Backup", + "backup_controller_page_backup_selected": "Selected: ", + "backup_controller_page_backup_sub": "Backed up photos and videos", + "backup_controller_page_cancel": "Cancel", + "backup_controller_page_created": "Created on: {}", + "backup_controller_page_desc_backup": "Turn on foreground backup to automatically upload new assets to the server when opening the app.", + "backup_controller_page_excluded": "Excluded: ", + "backup_controller_page_failed": "Failed ({})", + "backup_controller_page_filename": "File name: {} [{}]", + "backup_controller_page_id": "ID: {}", + "backup_controller_page_info": "Backup Information", + "backup_controller_page_none_selected": "None selected", + "backup_controller_page_remainder": "Remainder", + "backup_controller_page_remainder_sub": "Remaining photos and videos to back up from selection", + "backup_controller_page_select": "Select", + "backup_controller_page_server_storage": "Server Storage", + "backup_controller_page_start_backup": "Start Backup", + "backup_controller_page_status_off": "Automatic foreground backup is off", + "backup_controller_page_status_on": "Automatic foreground backup is on", + "backup_controller_page_storage_format": "{} of {} used", + "backup_controller_page_to_backup": "Albums to be backup", + "backup_controller_page_total": "Total", + "backup_controller_page_total_sub": "All unique photos and videos from selected albums", + "backup_controller_page_turn_off": "Turn off foreground backup", + "backup_controller_page_turn_on": "Turn on foreground backup", + "backup_controller_page_uploading_file_info": "Uploading file info", + "backup_err_only_album": "Cannot remove the only album", + "backup_info_card_assets": "assets", + "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", + "cache_settings_clear_cache_button": "Clear cache", + "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", + "cache_settings_image_cache_size": "Image cache size ({} assets)", + "cache_settings_statistics_album": "Library thumbnails", + "cache_settings_statistics_assets": "{} assets ({})", + "cache_settings_statistics_full": "Full images", + "cache_settings_statistics_shared": "Shared album thumbnails", + "cache_settings_statistics_thumbnail": "Thumbnails", + "cache_settings_statistics_title": "Cache usage", + "cache_settings_subtitle": "Control the caching behaviour of the Immich mobile application", + "cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)", + "cache_settings_title": "Caching Settings", + "change_password_form_confirm_password": "Confirm Password", + "change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_new_password": "New Password", + "change_password_form_password_mismatch": "Passwords do not match", + "change_password_form_reenter_new_password": "Re-enter New Password", + "common_add_to_album": "Add to album", + "common_change_password": "Change Password", + "common_create_new_album": "Create new album", + "common_server_error": "กรุณาตรวจสอบการเชื่อมต่ออินเทอร์เน็ต ให้แน่ใจว่าเซิร์ฟเวอร์สามารถเข้าถึงได้ และเวอร์ชั่นแอพและเซิร์ฟเวอร์เข้ากันได้", + "common_shared": "Shared", + "control_bottom_app_bar_add_to_album": "Add to album", + "control_bottom_app_bar_album_info": "{} items", + "control_bottom_app_bar_album_info_shared": "{} items · Shared", + "control_bottom_app_bar_archive": "เก็บถาวร", + "control_bottom_app_bar_create_new_album": "Create new album", + "control_bottom_app_bar_delete": "Delete", + "control_bottom_app_bar_favorite": "Favorite", + "control_bottom_app_bar_share": "Share", + "control_bottom_app_bar_unarchive": "นำออกจากที่เก็บถาวร", + "create_album_page_untitled": "Untitled", + "create_shared_album_page_create": "Create", + "create_shared_album_page_share": "Share", + "create_shared_album_page_share_add_assets": "ADD ASSETS", + "create_shared_album_page_share_select_photos": "Select Photos", + "curated_location_page_title": "สถานที่", + "curated_object_page_title": "สิ่งของ", + "daily_title_text_date": "E, MMM dd", + "daily_title_text_date_year": "E, MMM dd, yyyy", + "date_format": "E, LLL d, y • h:mm a", + "delete_dialog_alert": "These items will be permanently deleted from Immich and from your device", + "delete_dialog_cancel": "Cancel", + "delete_dialog_ok": "Delete", + "delete_dialog_title": "Delete Permanently", + "description_input_hint_text": "เพื่มรายละเอียด...", + "description_input_submit_error": "อัพเดตรายละเอียดผิดพลาด ตรวจสอบการบันทึกเพื่อรายละเอียดเพิ่มเติม", + "exif_bottom_sheet_description": "Add Description...", + "exif_bottom_sheet_details": "DETAILS", + "exif_bottom_sheet_location": "LOCATION", + "experimental_settings_new_asset_list_subtitle": "Work in progress", + "experimental_settings_new_asset_list_title": "Enable experimental photo grid", + "experimental_settings_subtitle": "Use at your own risk!", + "experimental_settings_title": "Experimental", + "favorites_page_no_favorites": "ไม่พบทรัพยากรในรายการโปรด", + "favorites_page_title": "Favorites", + "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", + "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", + "home_page_add_to_album_success": "Added {added} assets to album {album}.", + "home_page_archive_err_local": "ไม่สามารถเก็บถาวรในขณะนี้ กำลังข้าม", + "home_page_building_timeline": "Building the timeline", + "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", + "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", + "image_viewer_page_state_provider_download_error": "Download Error", + "image_viewer_page_state_provider_download_success": "Download Success", + "library_page_albums": "Albums", + "library_page_archive": "เก็บถาวร", + "library_page_device_albums": "อัลบั้มบนเครื่อง", + "library_page_favorites": "Favorites", + "library_page_new_album": "New album", + "library_page_sharing": "Sharing", + "library_page_sort_created": "Most recently created", + "library_page_sort_title": "Album title", + "login_form_api_exception": "ข้อผิดพลาด API กรุณาตรวจสอบ URL แล้วลองใหม่", + "login_form_button_text": "Login", + "login_form_email_hint": "youremail@email.com", + "login_form_endpoint_hint": "http://your-server-ip:port/api", + "login_form_endpoint_url": "Server Endpoint URL", + "login_form_err_http": "Please specify http:// or https://", + "login_form_err_invalid_email": "Invalid Email", + "login_form_err_invalid_url": "Invalid URL", + "login_form_err_leading_whitespace": "Leading whitespace", + "login_form_err_trailing_whitespace": "Trailing whitespace", + "login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL", + "login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server", + "login_form_failed_login": "Error logging you in, check server URL, email and password", + "login_form_label_email": "Email", + "login_form_label_password": "Password", + "login_form_next_button": "ต่อไป", + "login_form_password_hint": "password", + "login_form_save_login": "Stay logged in", + "login_form_server_empty": "กรอก URL เซิร์ฟเวอร์", + "login_form_server_error": "ไม่สามารถติดต่อกับเซิร์ฟเวอร์", + "monthly_title_text_date_format": "MMMM y", + "motion_photos_page_title": "ภาพเคลื่อนไหว", + "notification_permission_dialog_cancel": "Cancel", + "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", + "notification_permission_dialog_settings": "Settings", + "notification_permission_list_tile_content": "Grant permission to enable notifications.", + "notification_permission_list_tile_enable_button": "Enable Notifications", + "notification_permission_list_tile_title": "Notification Permission", + "partner_page_add_partner": "เพิ่มพันธมิตร", + "partner_page_empty_message": "รูปภาพของคุณยังไม่ถูกแชร์กับพันธมิตร", + "partner_page_no_more_users": "ไม่มีผู้ใช้งานให้เพิ่ม", + "partner_page_partner_add_failed": "การเพิ่มพันธมิตรล้มเหลว", + "partner_page_select_partner": "เลือกพันธมิตร", + "partner_page_shared_to_title": "แชร์กับ", + "partner_page_stop_sharing_content": "{} จะไม่สามารถเข้าถึงรูปภาพของคุณ", + "partner_page_stop_sharing_title": "หยุดแชร์รูปภาพหรือไม่?", + "partner_page_title": "พันธมิตร", + "permission_onboarding_continue_anyway": "ไปต่ออยู่ดี", + "permission_onboarding_get_started": "เริ่มต้น", + "permission_onboarding_go_to_settings": "ไปยังการตั้งค่า", + "permission_onboarding_grant_permission": "ใหิสิทธิ์", + "permission_onboarding_log_out": "ออกจากระบบ", + "permission_onboarding_permission_denied": "ไม่อนุญาต ตั้งค่าสิทธิ์เข้าถึงรูปภาพและวิดีโอเพื่อใช้งาน Immich", + "permission_onboarding_permission_granted": "ให้สิทธิ์สำเร็จ คุณพร้อมใช้งานแล้ว", + "permission_onboarding_permission_limited": "สิทธ์จำกัด เพื่อให้ Immich สำรองข้อมูลและบริหารคลังรูปภาพได้ ตั้งค่าสิทธิเข้าถึงรูปภาพและวิดิโอ", + "permission_onboarding_request": "Immich จำเป็นจะต้องได้รับสิทธิ์ดูรูปภาพและวิดีโอ", + "profile_drawer_app_logs": "Logs", + "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date", + "profile_drawer_settings": "Settings", + "profile_drawer_sign_out": "Sign Out", + "recently_added_page_title": "เพิ่มมาเร็วๆนี้", + "search_bar_hint": "Search your photos", + "search_page_categories": "หมวดหมู่", + "search_page_favorites": "รายการโปรด", + "search_page_motion_photos": "ภาพเคลื่อนไหว", + "search_page_no_objects": "No Objects Info Available", + "search_page_no_places": "No Places Info Available", + "search_page_people": "ผู้คน", + "search_page_places": "Places", + "search_page_recently_added": "เพิ่มมาเร็วๆนี้", + "search_page_screenshots": "แคปหน้าจอ", + "search_page_selfies": "เซลฟี่", + "search_page_things": "Things", + "search_page_videos": "วิดีโอ", + "search_page_view_all_button": "ดูทั้งหมด", + "search_page_your_activity": "กิจกรรมของคุณ", + "search_result_page_new_search_hint": "New Search", + "search_suggestion_list_smart_search_hint_1": "การค้นหาอัจฉริยะเปิดเป็นค่าเริ่มต้น เพื่อค้นหา metadata ให้ใช้ไวยากรณ์", + "search_suggestion_list_smart_search_hint_2": "m:คำค้นหา", + "select_additional_user_for_sharing_page_suggestions": "Suggestions", + "select_user_for_sharing_page_err_album": "Failed to create album", + "select_user_for_sharing_page_share_suggestions": "Suggestions", + "server_info_box_app_version": "App Version", + "server_info_box_server_version": "Server Version", + "setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).", + "setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).", + "setting_image_viewer_original_title": "Load original image", + "setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.", + "setting_image_viewer_preview_title": "Load preview image", + "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", + "setting_notifications_notify_hours": "{} hours", + "setting_notifications_notify_immediately": "immediately", + "setting_notifications_notify_minutes": "{} minutes", + "setting_notifications_notify_never": "never", + "setting_notifications_notify_seconds": "{} seconds", + "setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset", + "setting_notifications_single_progress_title": "Show background backup detail progress", + "setting_notifications_subtitle": "Adjust your notification preferences", + "setting_notifications_title": "Notifications", + "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", + "setting_notifications_total_progress_title": "Show background backup total progress", + "setting_pages_app_bar_settings": "Settings", + "settings_require_restart": "Please restart Immich to apply this setting", + "share_add": "Add", + "share_add_photos": "Add photos", + "share_add_title": "Add a title", + "share_create_album": "Create album", + "share_dialog_preparing": "Preparing...", + "share_invite": "Invite to album", + "sharing_page_album": "Shared albums", + "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", + "sharing_page_empty_list": "EMPTY LIST", + "sharing_silver_appbar_create_shared_album": "Create shared album", + "sharing_silver_appbar_share_partner": "Share with partner", + "tab_controller_nav_library": "Library", + "tab_controller_nav_photos": "Photos", + "tab_controller_nav_search": "Search", + "tab_controller_nav_sharing": "Sharing", + "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", + "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_dark_mode_switch": "Dark mode", + "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", + "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_system_theme_switch": "Automatic (Follow system setting)", + "theme_setting_theme_subtitle": "Choose the app's theme setting", + "theme_setting_theme_title": "Theme", + "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", + "theme_setting_three_stage_loading_title": "Enable three-stage loading", + "version_announcement_overlay_ack": "Acknowledge", + "version_announcement_overlay_release_notes": "release notes", + "version_announcement_overlay_text_1": "Hi friend, there is a new release of", + "version_announcement_overlay_text_2": "please take your time to visit the ", + "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", + "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89" +} \ No newline at end of file diff --git a/mobile/assets/i18n/uk-UA.json b/mobile/assets/i18n/uk-UA.json new file mode 100644 index 000000000..47e60789c --- /dev/null +++ b/mobile/assets/i18n/uk-UA.json @@ -0,0 +1,293 @@ +{ + "add_to_album_bottom_sheet_added": "Added to {album}", + "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_tile_subtitle": "Advanced user's settings", + "advanced_settings_tile_title": "Advanced", + "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", + "advanced_settings_troubleshooting_title": "Troubleshooting", + "album_info_card_backup_album_excluded": "EXCLUDED", + "album_info_card_backup_album_included": "INCLUDED", + "album_thumbnail_card_item": "1 item", + "album_thumbnail_card_items": "{} items", + "album_thumbnail_card_shared": " · Shared", + "album_thumbnail_owned": "Owned", + "album_thumbnail_shared_by": "Shared by {}", + "album_viewer_appbar_share_delete": "Delete album", + "album_viewer_appbar_share_err_delete": "Failed to delete album", + "album_viewer_appbar_share_err_leave": "Failed to leave album", + "album_viewer_appbar_share_err_remove": "There are problems in removing assets from album", + "album_viewer_appbar_share_err_title": "Failed to change album title", + "album_viewer_appbar_share_leave": "Leave album", + "album_viewer_appbar_share_remove": "Remove from album", + "album_viewer_page_share_add_users": "Add users", + "all_people_page_title": "People", + "all_videos_page_title": "Videos", + "archive_page_no_archived_assets": "No archived assets found", + "archive_page_title": "Archive ({})", + "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout", + "asset_list_layout_settings_group_automatically": "Automatic", + "asset_list_layout_settings_group_by": "Group assets by", + "asset_list_layout_settings_group_by_month": "Month", + "asset_list_layout_settings_group_by_month_day": "Month + day", + "asset_list_settings_subtitle": "Photo grid layout settings", + "asset_list_settings_title": "Photo Grid", + "backup_album_selection_page_albums_device": "Albums on device ({})", + "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", + "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", + "backup_album_selection_page_select_albums": "Select albums", + "backup_album_selection_page_selection_info": "Selection Info", + "backup_album_selection_page_total_assets": "Total unique assets", + "backup_all": "All", + "backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…", + "backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…", + "backup_background_service_current_upload_notification": "Uploading {}", + "backup_background_service_default_notification": "Checking for new assets…", + "backup_background_service_error_title": "Backup error", + "backup_background_service_in_progress_notification": "Backing up your assets…", + "backup_background_service_upload_failure_notification": "Failed to upload {}", + "backup_controller_page_albums": "Backup Albums", + "backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.", + "backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled", + "backup_controller_page_background_app_refresh_enable_button_text": "Go to settings", + "backup_controller_page_background_battery_info_link": "Show me how", + "backup_controller_page_background_battery_info_message": "For the best background backup experience, please disable any battery optimizations restricting background activity for Immich.\n\nSince this is device-specific, please lookup the required information for your device manufacturer.", + "backup_controller_page_background_battery_info_ok": "OK", + "backup_controller_page_background_battery_info_title": "Battery optimizations", + "backup_controller_page_background_charging": "Only while charging", + "backup_controller_page_background_configure_error": "Failed to configure the background service", + "backup_controller_page_background_delay": "Delay new assets backup: {}", + "backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app", + "backup_controller_page_background_is_off": "Automatic background backup is off", + "backup_controller_page_background_is_on": "Automatic background backup is on", + "backup_controller_page_background_turn_off": "Turn off background service", + "backup_controller_page_background_turn_on": "Turn on background service", + "backup_controller_page_background_wifi": "Only on WiFi", + "backup_controller_page_backup": "Backup", + "backup_controller_page_backup_selected": "Selected: ", + "backup_controller_page_backup_sub": "Backed up photos and videos", + "backup_controller_page_cancel": "Cancel", + "backup_controller_page_created": "Created on: {}", + "backup_controller_page_desc_backup": "Turn on foreground backup to automatically upload new assets to the server when opening the app.", + "backup_controller_page_excluded": "Excluded: ", + "backup_controller_page_failed": "Failed ({})", + "backup_controller_page_filename": "File name: {} [{}]", + "backup_controller_page_id": "ID: {}", + "backup_controller_page_info": "Backup Information", + "backup_controller_page_none_selected": "None selected", + "backup_controller_page_remainder": "Remainder", + "backup_controller_page_remainder_sub": "Remaining photos and videos to back up from selection", + "backup_controller_page_select": "Select", + "backup_controller_page_server_storage": "Server Storage", + "backup_controller_page_start_backup": "Start Backup", + "backup_controller_page_status_off": "Automatic foreground backup is off", + "backup_controller_page_status_on": "Automatic foreground backup is on", + "backup_controller_page_storage_format": "{} of {} used", + "backup_controller_page_to_backup": "Albums to be backup", + "backup_controller_page_total": "Total", + "backup_controller_page_total_sub": "All unique photos and videos from selected albums", + "backup_controller_page_turn_off": "Turn off foreground backup", + "backup_controller_page_turn_on": "Turn on foreground backup", + "backup_controller_page_uploading_file_info": "Uploading file info", + "backup_err_only_album": "Cannot remove the only album", + "backup_info_card_assets": "assets", + "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", + "cache_settings_clear_cache_button": "Clear cache", + "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", + "cache_settings_image_cache_size": "Image cache size ({} assets)", + "cache_settings_statistics_album": "Library thumbnails", + "cache_settings_statistics_assets": "{} assets ({})", + "cache_settings_statistics_full": "Full images", + "cache_settings_statistics_shared": "Shared album thumbnails", + "cache_settings_statistics_thumbnail": "Thumbnails", + "cache_settings_statistics_title": "Cache usage", + "cache_settings_subtitle": "Control the caching behaviour of the Immich mobile application", + "cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)", + "cache_settings_title": "Caching Settings", + "change_password_form_confirm_password": "Confirm Password", + "change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_new_password": "New Password", + "change_password_form_password_mismatch": "Passwords do not match", + "change_password_form_reenter_new_password": "Re-enter New Password", + "common_add_to_album": "Add to album", + "common_change_password": "Change Password", + "common_create_new_album": "Create new album", + "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", + "common_shared": "Shared", + "control_bottom_app_bar_add_to_album": "Add to album", + "control_bottom_app_bar_album_info": "{} items", + "control_bottom_app_bar_album_info_shared": "{} items · Shared", + "control_bottom_app_bar_archive": "Archive", + "control_bottom_app_bar_create_new_album": "Create new album", + "control_bottom_app_bar_delete": "Delete", + "control_bottom_app_bar_favorite": "Favorite", + "control_bottom_app_bar_share": "Share", + "control_bottom_app_bar_unarchive": "Unarchive", + "create_album_page_untitled": "Untitled", + "create_shared_album_page_create": "Create", + "create_shared_album_page_share": "Share", + "create_shared_album_page_share_add_assets": "ADD ASSETS", + "create_shared_album_page_share_select_photos": "Select Photos", + "curated_location_page_title": "Places", + "curated_object_page_title": "Things", + "daily_title_text_date": "E, MMM dd", + "daily_title_text_date_year": "E, MMM dd, yyyy", + "date_format": "E, LLL d, y • h:mm a", + "delete_dialog_alert": "These items will be permanently deleted from Immich and from your device", + "delete_dialog_cancel": "Cancel", + "delete_dialog_ok": "Delete", + "delete_dialog_title": "Delete Permanently", + "description_input_hint_text": "Add description...", + "description_input_submit_error": "Error updating description, check the log for more details", + "exif_bottom_sheet_description": "Add Description...", + "exif_bottom_sheet_details": "DETAILS", + "exif_bottom_sheet_location": "LOCATION", + "experimental_settings_new_asset_list_subtitle": "Work in progress", + "experimental_settings_new_asset_list_title": "Enable experimental photo grid", + "experimental_settings_subtitle": "Use at your own risk!", + "experimental_settings_title": "Experimental", + "favorites_page_no_favorites": "No favorite assets found", + "favorites_page_title": "Favorites", + "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", + "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", + "home_page_add_to_album_success": "Added {added} assets to album {album}.", + "home_page_archive_err_local": "Can not archive local assets yet, skipping", + "home_page_building_timeline": "Building the timeline", + "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", + "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", + "image_viewer_page_state_provider_download_error": "Download Error", + "image_viewer_page_state_provider_download_success": "Download Success", + "library_page_albums": "Albums", + "library_page_archive": "Archive", + "library_page_device_albums": "Albums on Device", + "library_page_favorites": "Favorites", + "library_page_new_album": "New album", + "library_page_sharing": "Sharing", + "library_page_sort_created": "Most recently created", + "library_page_sort_title": "Album title", + "login_form_api_exception": "API exception. Please check the server URL and try again.", + "login_form_button_text": "Login", + "login_form_email_hint": "youremail@email.com", + "login_form_endpoint_hint": "http://your-server-ip:port/api", + "login_form_endpoint_url": "Server Endpoint URL", + "login_form_err_http": "Please specify http:// or https://", + "login_form_err_invalid_email": "Invalid Email", + "login_form_err_invalid_url": "Invalid URL", + "login_form_err_leading_whitespace": "Leading whitespace", + "login_form_err_trailing_whitespace": "Trailing whitespace", + "login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL", + "login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server", + "login_form_failed_login": "Error logging you in, check server URL, email and password", + "login_form_label_email": "Email", + "login_form_label_password": "Password", + "login_form_next_button": "Next", + "login_form_password_hint": "password", + "login_form_save_login": "Stay logged in", + "login_form_server_empty": "Enter a server URL.", + "login_form_server_error": "Could not connect to server.", + "monthly_title_text_date_format": "MMMM y", + "motion_photos_page_title": "Motion Photos", + "notification_permission_dialog_cancel": "Cancel", + "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", + "notification_permission_dialog_settings": "Settings", + "notification_permission_list_tile_content": "Grant permission to enable notifications.", + "notification_permission_list_tile_enable_button": "Enable Notifications", + "notification_permission_list_tile_title": "Notification Permission", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", + "permission_onboarding_continue_anyway": "Continue anyway", + "permission_onboarding_get_started": "Get started", + "permission_onboarding_go_to_settings": "Go to settings", + "permission_onboarding_grant_permission": "Grant permission", + "permission_onboarding_log_out": "Log out", + "permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.", + "permission_onboarding_permission_granted": "Permission granted! You are all set.", + "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", + "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "profile_drawer_app_logs": "Logs", + "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date", + "profile_drawer_settings": "Settings", + "profile_drawer_sign_out": "Sign Out", + "recently_added_page_title": "Recently Added", + "search_bar_hint": "Search your photos", + "search_page_categories": "Categories", + "search_page_favorites": "Favorites", + "search_page_motion_photos": "Motion Photos", + "search_page_no_objects": "No Objects Info Available", + "search_page_no_places": "No Places Info Available", + "search_page_people": "People", + "search_page_places": "Places", + "search_page_recently_added": "Recently added", + "search_page_screenshots": "Screenshots", + "search_page_selfies": "Selfies", + "search_page_things": "Things", + "search_page_videos": "Videos", + "search_page_view_all_button": "View all", + "search_page_your_activity": "Your activity", + "search_result_page_new_search_hint": "New Search", + "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", + "search_suggestion_list_smart_search_hint_2": "m:your-search-term", + "select_additional_user_for_sharing_page_suggestions": "Suggestions", + "select_user_for_sharing_page_err_album": "Failed to create album", + "select_user_for_sharing_page_share_suggestions": "Suggestions", + "server_info_box_app_version": "App Version", + "server_info_box_server_version": "Server Version", + "setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).", + "setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).", + "setting_image_viewer_original_title": "Load original image", + "setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.", + "setting_image_viewer_preview_title": "Load preview image", + "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", + "setting_notifications_notify_hours": "{} hours", + "setting_notifications_notify_immediately": "immediately", + "setting_notifications_notify_minutes": "{} minutes", + "setting_notifications_notify_never": "never", + "setting_notifications_notify_seconds": "{} seconds", + "setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset", + "setting_notifications_single_progress_title": "Show background backup detail progress", + "setting_notifications_subtitle": "Adjust your notification preferences", + "setting_notifications_title": "Notifications", + "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", + "setting_notifications_total_progress_title": "Show background backup total progress", + "setting_pages_app_bar_settings": "Settings", + "settings_require_restart": "Please restart Immich to apply this setting", + "share_add": "Add", + "share_add_photos": "Add photos", + "share_add_title": "Add a title", + "share_create_album": "Create album", + "share_dialog_preparing": "Preparing...", + "share_invite": "Invite to album", + "sharing_page_album": "Shared albums", + "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", + "sharing_page_empty_list": "EMPTY LIST", + "sharing_silver_appbar_create_shared_album": "Create shared album", + "sharing_silver_appbar_share_partner": "Share with partner", + "tab_controller_nav_library": "Library", + "tab_controller_nav_photos": "Photos", + "tab_controller_nav_search": "Search", + "tab_controller_nav_sharing": "Sharing", + "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", + "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_dark_mode_switch": "Dark mode", + "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", + "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_system_theme_switch": "Automatic (Follow system setting)", + "theme_setting_theme_subtitle": "Choose the app's theme setting", + "theme_setting_theme_title": "Theme", + "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", + "theme_setting_three_stage_loading_title": "Enable three-stage loading", + "version_announcement_overlay_ack": "Acknowledge", + "version_announcement_overlay_release_notes": "release notes", + "version_announcement_overlay_text_1": "Hi friend, there is a new release of", + "version_announcement_overlay_text_2": "please take your time to visit the ", + "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", + "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89" +} \ No newline at end of file diff --git a/mobile/assets/i18n/vi-VN.json b/mobile/assets/i18n/vi-VN.json new file mode 100644 index 000000000..18298dad9 --- /dev/null +++ b/mobile/assets/i18n/vi-VN.json @@ -0,0 +1,293 @@ +{ + "add_to_album_bottom_sheet_added": "Added to {album}", + "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_tile_subtitle": "Advanced user's settings", + "advanced_settings_tile_title": "Advanced", + "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", + "advanced_settings_troubleshooting_title": "Troubleshooting", + "album_info_card_backup_album_excluded": "EXCLUDED", + "album_info_card_backup_album_included": "INCLUDED", + "album_thumbnail_card_item": "1 item", + "album_thumbnail_card_items": "{} items", + "album_thumbnail_card_shared": " · Shared", + "album_thumbnail_owned": "Owned", + "album_thumbnail_shared_by": "Shared by {}", + "album_viewer_appbar_share_delete": "Delete album", + "album_viewer_appbar_share_err_delete": "Failed to delete album", + "album_viewer_appbar_share_err_leave": "Failed to leave album", + "album_viewer_appbar_share_err_remove": "There are problems in removing assets from album", + "album_viewer_appbar_share_err_title": "Failed to change album title", + "album_viewer_appbar_share_leave": "Leave album", + "album_viewer_appbar_share_remove": "Remove from album", + "album_viewer_page_share_add_users": "Add users", + "all_people_page_title": "People", + "all_videos_page_title": "Videos", + "archive_page_no_archived_assets": "No archived assets found", + "archive_page_title": "Archive ({})", + "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout", + "asset_list_layout_settings_group_automatically": "Automatic", + "asset_list_layout_settings_group_by": "Group assets by", + "asset_list_layout_settings_group_by_month": "Month", + "asset_list_layout_settings_group_by_month_day": "Month + day", + "asset_list_settings_subtitle": "Photo grid layout settings", + "asset_list_settings_title": "Photo Grid", + "backup_album_selection_page_albums_device": "Albums on device ({})", + "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", + "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", + "backup_album_selection_page_select_albums": "Select albums", + "backup_album_selection_page_selection_info": "Selection Info", + "backup_album_selection_page_total_assets": "Total unique assets", + "backup_all": "All", + "backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…", + "backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…", + "backup_background_service_current_upload_notification": "Uploading {}", + "backup_background_service_default_notification": "Checking for new assets…", + "backup_background_service_error_title": "Backup error", + "backup_background_service_in_progress_notification": "Backing up your assets…", + "backup_background_service_upload_failure_notification": "Failed to upload {}", + "backup_controller_page_albums": "Backup Albums", + "backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.", + "backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled", + "backup_controller_page_background_app_refresh_enable_button_text": "Go to settings", + "backup_controller_page_background_battery_info_link": "Show me how", + "backup_controller_page_background_battery_info_message": "For the best background backup experience, please disable any battery optimizations restricting background activity for Immich.\n\nSince this is device-specific, please lookup the required information for your device manufacturer.", + "backup_controller_page_background_battery_info_ok": "OK", + "backup_controller_page_background_battery_info_title": "Battery optimizations", + "backup_controller_page_background_charging": "Only while charging", + "backup_controller_page_background_configure_error": "Failed to configure the background service", + "backup_controller_page_background_delay": "Delay new assets backup: {}", + "backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app", + "backup_controller_page_background_is_off": "Automatic background backup is off", + "backup_controller_page_background_is_on": "Automatic background backup is on", + "backup_controller_page_background_turn_off": "Turn off background service", + "backup_controller_page_background_turn_on": "Turn on background service", + "backup_controller_page_background_wifi": "Only on WiFi", + "backup_controller_page_backup": "Backup", + "backup_controller_page_backup_selected": "Selected: ", + "backup_controller_page_backup_sub": "Backed up photos and videos", + "backup_controller_page_cancel": "Cancel", + "backup_controller_page_created": "Created on: {}", + "backup_controller_page_desc_backup": "Turn on foreground backup to automatically upload new assets to the server when opening the app.", + "backup_controller_page_excluded": "Excluded: ", + "backup_controller_page_failed": "Failed ({})", + "backup_controller_page_filename": "File name: {} [{}]", + "backup_controller_page_id": "ID: {}", + "backup_controller_page_info": "Backup Information", + "backup_controller_page_none_selected": "None selected", + "backup_controller_page_remainder": "Remainder", + "backup_controller_page_remainder_sub": "Remaining photos and videos to back up from selection", + "backup_controller_page_select": "Select", + "backup_controller_page_server_storage": "Server Storage", + "backup_controller_page_start_backup": "Start Backup", + "backup_controller_page_status_off": "Automatic foreground backup is off", + "backup_controller_page_status_on": "Automatic foreground backup is on", + "backup_controller_page_storage_format": "{} of {} used", + "backup_controller_page_to_backup": "Albums to be backup", + "backup_controller_page_total": "Total", + "backup_controller_page_total_sub": "All unique photos and videos from selected albums", + "backup_controller_page_turn_off": "Turn off foreground backup", + "backup_controller_page_turn_on": "Turn on foreground backup", + "backup_controller_page_uploading_file_info": "Uploading file info", + "backup_err_only_album": "Cannot remove the only album", + "backup_info_card_assets": "assets", + "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", + "cache_settings_clear_cache_button": "Clear cache", + "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", + "cache_settings_image_cache_size": "Image cache size ({} assets)", + "cache_settings_statistics_album": "Library thumbnails", + "cache_settings_statistics_assets": "{} assets ({})", + "cache_settings_statistics_full": "Full images", + "cache_settings_statistics_shared": "Shared album thumbnails", + "cache_settings_statistics_thumbnail": "Thumbnails", + "cache_settings_statistics_title": "Cache usage", + "cache_settings_subtitle": "Control the caching behaviour of the Immich mobile application", + "cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)", + "cache_settings_title": "Caching Settings", + "change_password_form_confirm_password": "Confirm Password", + "change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_new_password": "New Password", + "change_password_form_password_mismatch": "Passwords do not match", + "change_password_form_reenter_new_password": "Re-enter New Password", + "common_add_to_album": "Add to album", + "common_change_password": "Change Password", + "common_create_new_album": "Create new album", + "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", + "common_shared": "Shared", + "control_bottom_app_bar_add_to_album": "Add to album", + "control_bottom_app_bar_album_info": "{} items", + "control_bottom_app_bar_album_info_shared": "{} items · Shared", + "control_bottom_app_bar_archive": "Archive", + "control_bottom_app_bar_create_new_album": "Create new album", + "control_bottom_app_bar_delete": "Delete", + "control_bottom_app_bar_favorite": "Favorite", + "control_bottom_app_bar_share": "Share", + "control_bottom_app_bar_unarchive": "Unarchive", + "create_album_page_untitled": "Untitled", + "create_shared_album_page_create": "Create", + "create_shared_album_page_share": "Share", + "create_shared_album_page_share_add_assets": "ADD ASSETS", + "create_shared_album_page_share_select_photos": "Select Photos", + "curated_location_page_title": "Places", + "curated_object_page_title": "Things", + "daily_title_text_date": "E, MMM dd", + "daily_title_text_date_year": "E, MMM dd, yyyy", + "date_format": "E, LLL d, y • h:mm a", + "delete_dialog_alert": "These items will be permanently deleted from Immich and from your device", + "delete_dialog_cancel": "Cancel", + "delete_dialog_ok": "Delete", + "delete_dialog_title": "Delete Permanently", + "description_input_hint_text": "Add description...", + "description_input_submit_error": "Error updating description, check the log for more details", + "exif_bottom_sheet_description": "Add Description...", + "exif_bottom_sheet_details": "DETAILS", + "exif_bottom_sheet_location": "LOCATION", + "experimental_settings_new_asset_list_subtitle": "Work in progress", + "experimental_settings_new_asset_list_title": "Enable experimental photo grid", + "experimental_settings_subtitle": "Use at your own risk!", + "experimental_settings_title": "Experimental", + "favorites_page_no_favorites": "No favorite assets found", + "favorites_page_title": "Favorites", + "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", + "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", + "home_page_add_to_album_success": "Added {added} assets to album {album}.", + "home_page_archive_err_local": "Can not archive local assets yet, skipping", + "home_page_building_timeline": "Building the timeline", + "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", + "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", + "image_viewer_page_state_provider_download_error": "Download Error", + "image_viewer_page_state_provider_download_success": "Download Success", + "library_page_albums": "Albums", + "library_page_archive": "Archive", + "library_page_device_albums": "Albums on Device", + "library_page_favorites": "Favorites", + "library_page_new_album": "New album", + "library_page_sharing": "Sharing", + "library_page_sort_created": "Most recently created", + "library_page_sort_title": "Album title", + "login_form_api_exception": "API exception. Please check the server URL and try again.", + "login_form_button_text": "Login", + "login_form_email_hint": "youremail@email.com", + "login_form_endpoint_hint": "http://your-server-ip:port/api", + "login_form_endpoint_url": "Server Endpoint URL", + "login_form_err_http": "Please specify http:// or https://", + "login_form_err_invalid_email": "Invalid Email", + "login_form_err_invalid_url": "Invalid URL", + "login_form_err_leading_whitespace": "Leading whitespace", + "login_form_err_trailing_whitespace": "Trailing whitespace", + "login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL", + "login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server", + "login_form_failed_login": "Lỗi đăng nhập, xin vui lòng kiểm tra địa chỉ server, email và mật khẩu", + "login_form_label_email": "Email", + "login_form_label_password": "Password", + "login_form_next_button": "Next", + "login_form_password_hint": "password", + "login_form_save_login": "Stay logged in", + "login_form_server_empty": "Enter a server URL.", + "login_form_server_error": "Could not connect to server.", + "monthly_title_text_date_format": "MMMM y", + "motion_photos_page_title": "Motion Photos", + "notification_permission_dialog_cancel": "Cancel", + "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", + "notification_permission_dialog_settings": "Settings", + "notification_permission_list_tile_content": "Grant permission to enable notifications.", + "notification_permission_list_tile_enable_button": "Enable Notifications", + "notification_permission_list_tile_title": "Notification Permission", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_title": "Partner", + "permission_onboarding_continue_anyway": "Continue anyway", + "permission_onboarding_get_started": "Get started", + "permission_onboarding_go_to_settings": "Go to settings", + "permission_onboarding_grant_permission": "Grant permission", + "permission_onboarding_log_out": "Log out", + "permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.", + "permission_onboarding_permission_granted": "Permission granted! You are all set.", + "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", + "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "profile_drawer_app_logs": "Logs", + "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date", + "profile_drawer_settings": "Settings", + "profile_drawer_sign_out": "Sign Out", + "recently_added_page_title": "Recently Added", + "search_bar_hint": "Search your photos", + "search_page_categories": "Categories", + "search_page_favorites": "Favorites", + "search_page_motion_photos": "Motion Photos", + "search_page_no_objects": "Không có thông tin vật thể", + "search_page_no_places": "No Places Info Available", + "search_page_people": "People", + "search_page_places": "Places", + "search_page_recently_added": "Recently added", + "search_page_screenshots": "Screenshots", + "search_page_selfies": "Selfies", + "search_page_things": "Things", + "search_page_videos": "Videos", + "search_page_view_all_button": "View all", + "search_page_your_activity": "Your activity", + "search_result_page_new_search_hint": "New Search", + "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", + "search_suggestion_list_smart_search_hint_2": "m:your-search-term", + "select_additional_user_for_sharing_page_suggestions": "Suggestions", + "select_user_for_sharing_page_err_album": "Failed to create album", + "select_user_for_sharing_page_share_suggestions": "Gợi ý", + "server_info_box_app_version": "App Version", + "server_info_box_server_version": "Server Version", + "setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).", + "setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).", + "setting_image_viewer_original_title": "Load original image", + "setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.", + "setting_image_viewer_preview_title": "Load preview image", + "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", + "setting_notifications_notify_hours": "{} hours", + "setting_notifications_notify_immediately": "immediately", + "setting_notifications_notify_minutes": "{} minutes", + "setting_notifications_notify_never": "never", + "setting_notifications_notify_seconds": "{} seconds", + "setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset", + "setting_notifications_single_progress_title": "Show background backup detail progress", + "setting_notifications_subtitle": "Adjust your notification preferences", + "setting_notifications_title": "Notifications", + "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", + "setting_notifications_total_progress_title": "Show background backup total progress", + "setting_pages_app_bar_settings": "Settings", + "settings_require_restart": "Please restart Immich to apply this setting", + "share_add": "Add", + "share_add_photos": "Add photos", + "share_add_title": "Add a title", + "share_create_album": "Create album", + "share_dialog_preparing": "Preparing...", + "share_invite": "Invite to album", + "sharing_page_album": "Shared albums", + "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", + "sharing_page_empty_list": "EMPTY LIST", + "sharing_silver_appbar_create_shared_album": "Create shared album", + "sharing_silver_appbar_share_partner": "Share with partner", + "tab_controller_nav_library": "Library", + "tab_controller_nav_photos": "Photos", + "tab_controller_nav_search": "Search", + "tab_controller_nav_sharing": "Sharing", + "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", + "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_dark_mode_switch": "Dark mode", + "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", + "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_system_theme_switch": "Automatic (Follow system setting)", + "theme_setting_theme_subtitle": "Choose the app's theme setting", + "theme_setting_theme_title": "Theme", + "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", + "theme_setting_three_stage_loading_title": "Enable three-stage loading", + "version_announcement_overlay_ack": "Acknowledge", + "version_announcement_overlay_release_notes": "release notes", + "version_announcement_overlay_text_1": "Hi friend, there is a new release of", + "version_announcement_overlay_text_2": "please take your time to visit the ", + "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", + "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89" +} \ No newline at end of file diff --git a/mobile/assets/i18n/zh-CN.json b/mobile/assets/i18n/zh-CN.json index 12cc258d2..bba4b1643 100644 --- a/mobile/assets/i18n/zh-CN.json +++ b/mobile/assets/i18n/zh-CN.json @@ -1,8 +1,8 @@ { "add_to_album_bottom_sheet_added": "添加到 {album}", "add_to_album_bottom_sheet_already_exists": "已在 {album} 中", - "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", - "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_prefer_remote_subtitle": "在某些设备上,从本地的项目加载缩略图的速度非常慢。\n启用此选项以加载远程项目。", + "advanced_settings_prefer_remote_title": "优先远程项目", "advanced_settings_tile_subtitle": "高级用户设置", "advanced_settings_tile_title": "高级", "advanced_settings_troubleshooting_subtitle": "启用用于故障排除的额外功能", @@ -22,7 +22,7 @@ "album_viewer_appbar_share_leave": "退出共享", "album_viewer_appbar_share_remove": "从相册中移除", "album_viewer_page_share_add_users": "创建用户", - "all_people_page_title": "People", + "all_people_page_title": "人物", "all_videos_page_title": "视频", "archive_page_no_archived_assets": "未找到归档项目", "archive_page_title": "归档({})", @@ -194,15 +194,15 @@ "notification_permission_list_tile_content": "授予启用通知的权限。", "notification_permission_list_tile_enable_button": "启用通知", "notification_permission_list_tile_title": "通知权限", - "partner_page_add_partner": "Add partner", - "partner_page_empty_message": "Your photos are not yet shared with any partner.", - "partner_page_no_more_users": "No more users to add", - "partner_page_partner_add_failed": "Failed to add partner", - "partner_page_select_partner": "Select partner", - "partner_page_shared_to_title": "Shared to", - "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", - "partner_page_stop_sharing_title": "Stop sharing your photos?", - "partner_page_title": "Partner", + "partner_page_add_partner": "添加同伴", + "partner_page_empty_message": "您的照片尚未与任何同伴共享。", + "partner_page_no_more_users": "无需添加更多用户", + "partner_page_partner_add_failed": "添加同伴失败", + "partner_page_select_partner": "选择同伴", + "partner_page_shared_to_title": "共享给", + "partner_page_stop_sharing_content": "{} 将无法再访问您的照片。", + "partner_page_stop_sharing_title": "您确定要停止共享您的照片吗?", + "partner_page_title": "同伴", "permission_onboarding_continue_anyway": "仍然继续", "permission_onboarding_get_started": "开始使用", "permission_onboarding_go_to_settings": "转到设置", @@ -223,7 +223,7 @@ "search_page_motion_photos": "动图", "search_page_no_objects": "没有事物信息", "search_page_no_places": "地点信息不存在", - "search_page_people": "People", + "search_page_people": "人物", "search_page_places": "地点", "search_page_recently_added": "最近添加", "search_page_screenshots": "屏幕截图", diff --git a/mobile/assets/i18n/zh-Hans.json b/mobile/assets/i18n/zh-Hans.json new file mode 100644 index 000000000..7cb167c39 --- /dev/null +++ b/mobile/assets/i18n/zh-Hans.json @@ -0,0 +1,293 @@ +{ + "add_to_album_bottom_sheet_added": "添加到 {album}", + "add_to_album_bottom_sheet_already_exists": "已在 {album} 中", + "advanced_settings_prefer_remote_subtitle": "在某些设备上,从本地的项目加载缩略图的速度非常慢。\n启用此选项以加载远程\n项目。", + "advanced_settings_prefer_remote_title": "优先远程项目", + "advanced_settings_tile_subtitle": "高级用户设置", + "advanced_settings_tile_title": "高级", + "advanced_settings_troubleshooting_subtitle": "启用用于故障排除的额外功能", + "advanced_settings_troubleshooting_title": "故障排除", + "album_info_card_backup_album_excluded": "已排除", + "album_info_card_backup_album_included": "已选中", + "album_thumbnail_card_item": "1 项", + "album_thumbnail_card_items": "{} 项", + "album_thumbnail_card_shared": " · 已共享", + "album_thumbnail_owned": "拥有", + "album_thumbnail_shared_by": "由 {} 共享", + "album_viewer_appbar_share_delete": "删除相册", + "album_viewer_appbar_share_err_delete": "删除相册失败", + "album_viewer_appbar_share_err_leave": "退出共享失败", + "album_viewer_appbar_share_err_remove": "从相册中移除时出现错误", + "album_viewer_appbar_share_err_title": "修改相册标题失败", + "album_viewer_appbar_share_leave": "退出共享", + "album_viewer_appbar_share_remove": "从相册中移除", + "album_viewer_page_share_add_users": "创建用户", + "all_people_page_title": "人物", + "all_videos_page_title": "视频", + "archive_page_no_archived_assets": "未找到归档项目", + "archive_page_title": "归档({})", + "asset_list_layout_settings_dynamic_layout_title": "动态布局", + "asset_list_layout_settings_group_automatically": "自动", + "asset_list_layout_settings_group_by": "项目分组方式", + "asset_list_layout_settings_group_by_month": "月", + "asset_list_layout_settings_group_by_month_day": "月和日", + "asset_list_settings_subtitle": "照片网格布局设置", + "asset_list_settings_title": "照片网格", + "backup_album_selection_page_albums_device": "设备上的相册({})", + "backup_album_selection_page_albums_tap": "单击选中, 双击排除", + "backup_album_selection_page_assets_scatter": "项目会分散在多个相册中。因此,可以在备份过程中包含或排除相册。", + "backup_album_selection_page_select_albums": "选择相册", + "backup_album_selection_page_selection_info": "选择信息", + "backup_album_selection_page_total_assets": "总计", + "backup_all": "全部", + "backup_background_service_backup_failed_message": "备份失败。正在重试…", + "backup_background_service_connection_failed_message": "连接服务器失败。正在重试…", + "backup_background_service_current_upload_notification": "正在上传 {}", + "backup_background_service_default_notification": "正在检查新项目…", + "backup_background_service_error_title": "备份失败", + "backup_background_service_in_progress_notification": "正在备份…", + "backup_background_service_upload_failure_notification": "上传失败 {}", + "backup_controller_page_albums": "备份相册", + "backup_controller_page_background_app_refresh_disabled_content": "要使用后台备份功能,请在“设置”>“常规”>“后台应用刷新”中启用后台应用程序刷新。", + "backup_controller_page_background_app_refresh_disabled_title": "后台应用刷新已禁用", + "backup_controller_page_background_app_refresh_enable_button_text": "前往设置", + "backup_controller_page_background_battery_info_link": "怎么做", + "backup_controller_page_background_battery_info_message": "为了获得最佳的后台备份体验,请禁用任何限制 Immich 后台活动的电池优化。\n\n由于这是设备相关的,因此请查找设备制造商提供的信息进行操作。", + "backup_controller_page_background_battery_info_ok": "我知道了", + "backup_controller_page_background_battery_info_title": "电池优化", + "backup_controller_page_background_charging": "仅充电时", + "backup_controller_page_background_configure_error": "配置后台服务失败", + "backup_controller_page_background_delay": "延迟 {} 后备份", + "backup_controller_page_background_description": "打开后台服务以自动备份任何新项目,且无需打开应用", + "backup_controller_page_background_is_off": "后台自动备份已关闭", + "backup_controller_page_background_is_on": "后台自动备份已开启", + "backup_controller_page_background_turn_off": "关闭后台服务", + "backup_controller_page_background_turn_on": "开启后台服务", + "backup_controller_page_background_wifi": "仅 WiFi", + "backup_controller_page_backup": "备份", + "backup_controller_page_backup_selected": "已选中:", + "backup_controller_page_backup_sub": "已备份的照片和视频", + "backup_controller_page_cancel": "取消", + "backup_controller_page_created": "创建时间: {}", + "backup_controller_page_desc_backup": "打开前台备份,以在程序运行时自动备份", + "backup_controller_page_excluded": "已排除:", + "backup_controller_page_failed": "失败({})", + "backup_controller_page_filename": "文件名称: {} [{}]", + "backup_controller_page_id": "ID: {}", + "backup_controller_page_info": "备份信息", + "backup_controller_page_none_selected": "未选择", + "backup_controller_page_remainder": "剩余", + "backup_controller_page_remainder_sub": "要从所选内容备份的剩余照片和视频", + "backup_controller_page_select": "选择", + "backup_controller_page_server_storage": "服务器存储", + "backup_controller_page_start_backup": "开始备份", + "backup_controller_page_status_off": "前台自动备份已关闭", + "backup_controller_page_status_on": "前台自动备份已开启", + "backup_controller_page_storage_format": "{}/{} 已使用", + "backup_controller_page_to_backup": "要备份的相册", + "backup_controller_page_total": "总计", + "backup_controller_page_total_sub": "选中相册中的所有不重复的视频和图像", + "backup_controller_page_turn_off": "关闭前台备份", + "backup_controller_page_turn_on": "开启前台备份", + "backup_controller_page_uploading_file_info": "正在上传文件信息", + "backup_err_only_album": "不能移除唯一的一个相册", + "backup_info_card_assets": "张", + "cache_settings_album_thumbnails": "图库缩略图({} 张)", + "cache_settings_clear_cache_button": "清除缓存", + "cache_settings_clear_cache_button_title": "清除应用缓存。在重新生成缓存之前,将显著影响应用的性能。", + "cache_settings_image_cache_size": "图像缓存大小({} 张)", + "cache_settings_statistics_album": "图库缩略图", + "cache_settings_statistics_assets": "{} 张({})", + "cache_settings_statistics_full": "完整图像", + "cache_settings_statistics_shared": "共享相册缩略图", + "cache_settings_statistics_thumbnail": "缩略图", + "cache_settings_statistics_title": "缓存使用情况", + "cache_settings_subtitle": "控制 Immich 的缓存行为", + "cache_settings_thumbnail_size": "缩略图缓存大小({} 张)", + "cache_settings_title": "缓存设置", + "change_password_form_confirm_password": "确认密码", + "change_password_form_description": "{firstName} {lastName} 您好,\n\n这是您首次登录系统,或被管理员要求更改密码。\n请在下方输入新密码。", + "change_password_form_new_password": "新密码", + "change_password_form_password_mismatch": "密码不匹配", + "change_password_form_reenter_new_password": "重新输入新的密码", + "common_add_to_album": "添加到相册", + "common_change_password": "更改密码", + "common_create_new_album": "新建相册", + "common_server_error": "请检查您的网络连接,确保服务器可访问且该应用程序或服务器版本兼容。", + "common_shared": "共享", + "control_bottom_app_bar_add_to_album": "添加到相册", + "control_bottom_app_bar_album_info": "{} 项", + "control_bottom_app_bar_album_info_shared": "{} 项 · 已共享", + "control_bottom_app_bar_archive": "归档", + "control_bottom_app_bar_create_new_album": "新建相册", + "control_bottom_app_bar_delete": "删除", + "control_bottom_app_bar_favorite": "收藏", + "control_bottom_app_bar_share": "共享", + "control_bottom_app_bar_unarchive": "取消归档", + "create_album_page_untitled": "未命名", + "create_shared_album_page_create": "创建", + "create_shared_album_page_share": "共享", + "create_shared_album_page_share_add_assets": "添加项目", + "create_shared_album_page_share_select_photos": "选择项目", + "curated_location_page_title": "地点", + "curated_object_page_title": "事物", + "daily_title_text_date": "E, MMM dd", + "daily_title_text_date_year": "E, MMM dd, yyyy", + "date_format": "E, LLL d, y • h:mm a", + "delete_dialog_alert": "这些项目将从 Immich 和您的设备中永久删除", + "delete_dialog_cancel": "取消", + "delete_dialog_ok": "删除", + "delete_dialog_title": "永久删除", + "description_input_hint_text": "添加描述...", + "description_input_submit_error": "更新描述时出错,请检查日志以获取更多详细信息", + "exif_bottom_sheet_description": "添加描述...", + "exif_bottom_sheet_details": "详情", + "exif_bottom_sheet_location": "位置", + "experimental_settings_new_asset_list_subtitle": "正在处理", + "experimental_settings_new_asset_list_title": "启用实验性照片网格", + "experimental_settings_subtitle": "使用风险自负!", + "experimental_settings_title": "实验性功能", + "favorites_page_no_favorites": "未找到收藏项目", + "favorites_page_title": "收藏", + "home_page_add_to_album_conflicts": "已向相册 {album} 中添加 {added} 项。\n其中 {failed} 项在相册中已存在。", + "home_page_add_to_album_err_local": "暂不能将本地资项目添加到相册中,跳过", + "home_page_add_to_album_success": "已向相册 {album} 中添加 {added} 项。", + "home_page_archive_err_local": "暂无法归档本地项目,跳过", + "home_page_building_timeline": "正在生成时间线", + "home_page_favorite_err_local": "暂不能收藏本地项目,跳过", + "home_page_first_time_notice": "如果这是您第一次使用该应用程序,请确保选择一个要备份的本地相册,以便可以在时间线中预览该相册中的照片和视频。", + "image_viewer_page_state_provider_download_error": "下载出现错误", + "image_viewer_page_state_provider_download_success": "下载成功", + "library_page_albums": "相册", + "library_page_archive": "归档", + "library_page_device_albums": "设备上的相册", + "library_page_favorites": "收藏", + "library_page_new_album": "新建相册", + "library_page_sharing": "共享", + "library_page_sort_created": "最近创建的", + "library_page_sort_title": "相册标题", + "login_form_api_exception": "API 异常,请检查服务器地址并重试。", + "login_form_button_text": "登录", + "login_form_email_hint": "youremail@email.com", + "login_form_endpoint_hint": "http(s)://你的服务器地址:端口/api", + "login_form_endpoint_url": "服务器终结点地址", + "login_form_err_http": "请注明 http:// 或 https://", + "login_form_err_invalid_email": "无效的电子邮件", + "login_form_err_invalid_url": "无效的地址", + "login_form_err_leading_whitespace": "带有前导空格", + "login_form_err_trailing_whitespace": "带有尾随空格", + "login_form_failed_get_oauth_server_config": "使用 OAuth 登录时错误,请检查服务器地址", + "login_form_failed_get_oauth_server_disable": "OAuth 功能在此服务器上不可用", + "login_form_failed_login": "登录失败, 请检查服务器地址、邮箱和密码", + "login_form_label_email": "邮箱", + "login_form_label_password": "密码", + "login_form_next_button": "下一个", + "login_form_password_hint": "密码", + "login_form_save_login": "保持登录", + "login_form_server_empty": "输入服务器地址。", + "login_form_server_error": "无法连接到服务器。", + "monthly_title_text_date_format": "MMMM y", + "motion_photos_page_title": "动图", + "notification_permission_dialog_cancel": "取消", + "notification_permission_dialog_content": "要启用通知,请转到“设置”,并选择“允许”。", + "notification_permission_dialog_settings": "设置", + "notification_permission_list_tile_content": "授予启用通知的权限。", + "notification_permission_list_tile_enable_button": "启用通知", + "notification_permission_list_tile_title": "通知权限", + "partner_page_add_partner": "添加同伴失败", + "partner_page_empty_message": "您的照片尚未与任何同伴共享。", + "partner_page_no_more_users": "无需添加更多用户", + "partner_page_partner_add_failed": "添加同伴失败", + "partner_page_select_partner": "选择同伴", + "partner_page_shared_to_title": "共享给", + "partner_page_stop_sharing_content": "{} 将无法再访问您的照片。", + "partner_page_stop_sharing_title": "您确定要停止共享您的照片吗?", + "partner_page_title": "同伴", + "permission_onboarding_continue_anyway": "仍然继续", + "permission_onboarding_get_started": "开始使用", + "permission_onboarding_go_to_settings": "转到设置", + "permission_onboarding_grant_permission": "授予权限", + "permission_onboarding_log_out": "注销", + "permission_onboarding_permission_denied": "权限被拒:要使用 Immich,请在“设置”中授予照片和视频权限。", + "permission_onboarding_permission_granted": "已授权!一切就绪。", + "permission_onboarding_permission_limited": "权限有限:要让 Immich 备份和管理您的整个图库收藏,请在“设置”中授予照片和视频权限。", + "permission_onboarding_request": "Immich 需要权限才能查看您的照片和视频。", + "profile_drawer_app_logs": "日志", + "profile_drawer_client_server_up_to_date": "客户端和服务端都是最新的", + "profile_drawer_settings": "设置", + "profile_drawer_sign_out": "退出登录", + "recently_added_page_title": "最近添加", + "search_bar_hint": "搜索照片", + "search_page_categories": "类别", + "search_page_favorites": "收藏", + "search_page_motion_photos": "动图", + "search_page_no_objects": "没有事物信息", + "search_page_no_places": "地点信息不存在", + "search_page_people": "人物", + "search_page_places": "地点", + "search_page_recently_added": "最近添加", + "search_page_screenshots": "屏幕截图", + "search_page_selfies": "自拍", + "search_page_things": "事物", + "search_page_videos": "视频", + "search_page_view_all_button": "查看全部", + "search_page_your_activity": "您的活动", + "search_result_page_new_search_hint": "搜索新的", + "search_suggestion_list_smart_search_hint_1": "默认情况下启用智能搜索;要搜索元数据,请使用相关语法", + "search_suggestion_list_smart_search_hint_2": "m:你的搜索关键词", + "select_additional_user_for_sharing_page_suggestions": "建议", + "select_user_for_sharing_page_err_album": "创建相册失败", + "select_user_for_sharing_page_share_suggestions": "建议", + "server_info_box_app_version": "App 版本", + "server_info_box_server_version": "服务器版本", + "setting_image_viewer_help": "详细信息查看器首先加载小缩略图,然后加载中等大小的预览图(若启用),最后加载原始图像。", + "setting_image_viewer_original_subtitle": "启用以加载原图,禁用以减少数据使用量(网络和设备缓存)。", + "setting_image_viewer_original_title": "加载原图", + "setting_image_viewer_preview_subtitle": "启用以加载中等质量的图像,禁用以加载原图或缩略图。", + "setting_image_viewer_preview_title": "加载预览图", + "setting_notifications_notify_failures_grace_period": "后台备份失败通知:{}", + "setting_notifications_notify_hours": "{} 小时", + "setting_notifications_notify_immediately": "立即", + "setting_notifications_notify_minutes": "{} 分钟", + "setting_notifications_notify_never": "从不", + "setting_notifications_notify_seconds": "{} 秒", + "setting_notifications_single_progress_subtitle": "每项的详细上传进度信息", + "setting_notifications_single_progress_title": "显示后台备份详细进度", + "setting_notifications_subtitle": "调整通知首选项", + "setting_notifications_title": "通知", + "setting_notifications_total_progress_subtitle": "总体上传进度(已完成/总计)", + "setting_notifications_total_progress_title": "显示后台备份总进度", + "setting_pages_app_bar_settings": "设置", + "settings_require_restart": "请重启 Immich 以使设置生效", + "share_add": "添加", + "share_add_photos": "添加项目", + "share_add_title": "添加标题", + "share_create_album": "创建相册", + "share_dialog_preparing": "这种准备...", + "share_invite": "邀请相册共享", + "sharing_page_album": "共享相册", + "sharing_page_description": "创建共享相册以与网络中的人共享照片和视频。", + "sharing_page_empty_list": "空", + "sharing_silver_appbar_create_shared_album": "创建共享相册", + "sharing_silver_appbar_share_partner": "共享给同伴", + "tab_controller_nav_library": "图库", + "tab_controller_nav_photos": "照片", + "tab_controller_nav_search": "搜索", + "tab_controller_nav_sharing": "共享", + "theme_setting_asset_list_storage_indicator_title": "在项目标题上显示存储占用", + "theme_setting_asset_list_tiles_per_row_title": "每行展示 {} 项", + "theme_setting_dark_mode_switch": "暗黑模式", + "theme_setting_image_viewer_quality_subtitle": "调整查看大图时的图像质量", + "theme_setting_image_viewer_quality_title": "图像质量", + "theme_setting_system_theme_switch": "自动(跟随系统设置)", + "theme_setting_theme_subtitle": "选择应用主题", + "theme_setting_theme_title": "主题", + "theme_setting_three_stage_loading_subtitle": "三段式加载可能会提升加载性能,但可能会导致更高的网络负载", + "theme_setting_three_stage_loading_title": "启用三段式加载", + "version_announcement_overlay_ack": "我知道了", + "version_announcement_overlay_release_notes": "发行说明", + "version_announcement_overlay_text_1": "号外号外,有新版本的", + "version_announcement_overlay_text_2": "请花点时间访问", + "version_announcement_overlay_text_3": "并检查您的 docker-compose 和 .env 是否为最新且正确的配置,特别是您在使用 WatchTower 或者其他自动更新的程序时,您需要更加细致的检查。", + "version_announcement_overlay_title": "服务端有新版本啦 \uD83C\uDF89" +} \ No newline at end of file From 27018e4ab632eaa689179b87f47ff7a3baae0435 Mon Sep 17 00:00:00 2001 From: faupau Date: Sun, 9 Jul 2023 04:32:34 +0200 Subject: [PATCH 07/38] feat(web): add emptyplaceholder when no assets (#3155) * add emptyplace holder when no assets * remove unecessary number type * wording --------- Co-authored-by: Alex Tran --- web/src/routes/(user)/photos/+page.svelte | 24 ++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/web/src/routes/(user)/photos/+page.svelte b/web/src/routes/(user)/photos/+page.svelte index 830c21f7f..677e17102 100644 --- a/web/src/routes/(user)/photos/+page.svelte +++ b/web/src/routes/(user)/photos/+page.svelte @@ -12,18 +12,31 @@ import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import { assetInteractionStore, isMultiSelectStoreState, selectedAssets } from '$lib/stores/asset-interaction.store'; import { assetStore } from '$lib/stores/assets.store'; - import { onDestroy } from 'svelte'; + import { onDestroy, onMount } from 'svelte'; import DotsVertical from 'svelte-material-icons/DotsVertical.svelte'; import Plus from 'svelte-material-icons/Plus.svelte'; import type { PageData } from './$types'; + import { api } from '@api'; + import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; + import { openFileUploadDialog } from '$lib/utils/file-uploader'; export let data: PageData; + let assetCount = 1; + + onMount(async () => { + const { data: allAssetCount } = await api.assetApi.getAssetCountByUserId(); + assetCount = allAssetCount.total; + }); onDestroy(() => { assetInteractionStore.clearMultiselect(); }); $: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite); + + const handleUpload = async () => { + openFileUploadDialog(); + }; @@ -45,6 +58,11 @@ {/if} - - + + {#if assetCount} + + {:else} + + {/if} + From 8349a28ed89656f1ba946769876380b99bef1bcc Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Sat, 8 Jul 2023 22:43:11 -0400 Subject: [PATCH 08/38] refactor(server): modularize `getFfmpegOptions` (#3138) * refactored `getFfmpegOptions` refactor transcoding, make separate service * fixed enum casing * use `Logger` instead of `console.log` * review suggestions * use enum for `getHandler` * fixed formatting * Update server/src/domain/media/media.util.ts Co-authored-by: Jason Rasmussen * Update server/src/domain/media/media.util.ts Co-authored-by: Jason Rasmussen * More specific imports, renamed codec classes * simplified code * removed unused import * added tests * added base implementation for bitrate and threads --------- Co-authored-by: Jason Rasmussen --- cli/src/api/open-api/api.ts | 90 +++++-- mobile/openapi/.openapi-generator/FILES | 9 + mobile/openapi/README.md | 3 + mobile/openapi/doc/AudioCodec.md | 14 ++ mobile/openapi/doc/SystemConfigFFmpegDto.md | 6 +- mobile/openapi/doc/TranscodePolicy.md | 14 ++ mobile/openapi/doc/VideoCodec.md | 14 ++ mobile/openapi/lib/api.dart | 3 + mobile/openapi/lib/api_client.dart | 6 + mobile/openapi/lib/api_helper.dart | 9 + mobile/openapi/lib/model/audio_codec.dart | 88 +++++++ .../lib/model/system_config_f_fmpeg_dto.dart | 124 ++-------- .../openapi/lib/model/transcode_policy.dart | 91 +++++++ mobile/openapi/lib/model/video_codec.dart | 88 +++++++ mobile/openapi/test/audio_codec_test.dart | 21 ++ .../test/system_config_f_fmpeg_dto_test.dart | 24 +- .../openapi/test/transcode_policy_test.dart | 21 ++ mobile/openapi/test/video_codec_test.dart | 21 ++ server/immich-openapi-specs.json | 51 ++-- server/src/domain/media/media.repository.ts | 12 + server/src/domain/media/media.service.spec.ts | 232 ++++++++++++++++-- server/src/domain/media/media.service.ts | 131 ++-------- server/src/domain/media/media.util.ts | 191 ++++++++++++++ .../dto/system-config-ffmpeg.dto.ts | 17 +- .../system-config/system-config.core.ts | 10 +- .../system-config.service.spec.ts | 15 +- .../infra/entities/system-config.entity.ts | 20 +- server/src/infra/infra.module.ts | 2 +- .../infra/repositories/media.repository.ts | 23 +- server/src/microservices/app.service.ts | 2 +- server/test/fixtures.ts | 10 +- web/src/api/open-api/api.ts | 90 +++++-- .../settings/ffmpeg/ffmpeg-settings.svelte | 24 +- 33 files changed, 1131 insertions(+), 345 deletions(-) create mode 100644 mobile/openapi/doc/AudioCodec.md create mode 100644 mobile/openapi/doc/TranscodePolicy.md create mode 100644 mobile/openapi/doc/VideoCodec.md create mode 100644 mobile/openapi/lib/model/audio_codec.dart create mode 100644 mobile/openapi/lib/model/transcode_policy.dart create mode 100644 mobile/openapi/lib/model/video_codec.dart create mode 100644 mobile/openapi/test/audio_codec_test.dart create mode 100644 mobile/openapi/test/transcode_policy_test.dart create mode 100644 mobile/openapi/test/video_codec_test.dart create mode 100644 server/src/domain/media/media.util.ts diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 277d676da..3d661f991 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -746,6 +746,21 @@ export const AssetTypeEnum = { export type AssetTypeEnum = typeof AssetTypeEnum[keyof typeof AssetTypeEnum]; +/** + * + * @export + * @enum {string} + */ + +export const AudioCodec = { + Mp3: 'mp3', + Aac: 'aac', + Opus: 'opus' +} as const; + +export type AudioCodec = typeof AudioCodec[keyof typeof AudioCodec]; + + /** * * @export @@ -2411,24 +2426,30 @@ export interface SystemConfigFFmpegDto { * @memberof SystemConfigFFmpegDto */ 'threads': number; + /** + * + * @type {VideoCodec} + * @memberof SystemConfigFFmpegDto + */ + 'targetVideoCodec': VideoCodec; + /** + * + * @type {AudioCodec} + * @memberof SystemConfigFFmpegDto + */ + 'targetAudioCodec': AudioCodec; + /** + * + * @type {TranscodePolicy} + * @memberof SystemConfigFFmpegDto + */ + 'transcode': TranscodePolicy; /** * * @type {string} * @memberof SystemConfigFFmpegDto */ 'preset': string; - /** - * - * @type {string} - * @memberof SystemConfigFFmpegDto - */ - 'targetVideoCodec': string; - /** - * - * @type {string} - * @memberof SystemConfigFFmpegDto - */ - 'targetAudioCodec': string; /** * * @type {string} @@ -2447,22 +2468,8 @@ export interface SystemConfigFFmpegDto { * @memberof SystemConfigFFmpegDto */ 'twoPass': boolean; - /** - * - * @type {string} - * @memberof SystemConfigFFmpegDto - */ - 'transcode': SystemConfigFFmpegDtoTranscodeEnum; } -export const SystemConfigFFmpegDtoTranscodeEnum = { - All: 'all', - Optimal: 'optimal', - Required: 'required', - Disabled: 'disabled' -} as const; - -export type SystemConfigFFmpegDtoTranscodeEnum = typeof SystemConfigFFmpegDtoTranscodeEnum[keyof typeof SystemConfigFFmpegDtoTranscodeEnum]; /** * @@ -2749,6 +2756,22 @@ export const TimeGroupEnum = { export type TimeGroupEnum = typeof TimeGroupEnum[keyof typeof TimeGroupEnum]; +/** + * + * @export + * @enum {string} + */ + +export const TranscodePolicy = { + All: 'all', + Optimal: 'optimal', + Required: 'required', + Disabled: 'disabled' +} as const; + +export type TranscodePolicy = typeof TranscodePolicy[keyof typeof TranscodePolicy]; + + /** * * @export @@ -3027,6 +3050,21 @@ export interface ValidateAccessTokenResponseDto { */ 'authStatus': boolean; } +/** + * + * @export + * @enum {string} + */ + +export const VideoCodec = { + H264: 'h264', + Hevc: 'hevc', + Vp9: 'vp9' +} as const; + +export type VideoCodec = typeof VideoCodec[keyof typeof VideoCodec]; + + /** * APIKeyApi - axios parameter creator diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 26eeb1c6b..9862f98c4 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -29,6 +29,7 @@ doc/AssetIdsDto.md doc/AssetIdsResponseDto.md doc/AssetResponseDto.md doc/AssetTypeEnum.md +doc/AudioCodec.md doc/AuthDeviceResponseDto.md doc/AuthenticationApi.md doc/ChangePasswordDto.md @@ -108,6 +109,7 @@ doc/TagResponseDto.md doc/TagTypeEnum.md doc/ThumbnailFormat.md doc/TimeGroupEnum.md +doc/TranscodePolicy.md doc/UpdateAlbumDto.md doc/UpdateAssetDto.md doc/UpdateTagDto.md @@ -117,6 +119,7 @@ doc/UserApi.md doc/UserCountResponseDto.md doc/UserResponseDto.md doc/ValidateAccessTokenResponseDto.md +doc/VideoCodec.md git_push.sh lib/api.dart lib/api/album_api.dart @@ -164,6 +167,7 @@ lib/model/asset_ids_dto.dart lib/model/asset_ids_response_dto.dart lib/model/asset_response_dto.dart lib/model/asset_type_enum.dart +lib/model/audio_codec.dart lib/model/auth_device_response_dto.dart lib/model/change_password_dto.dart lib/model/check_duplicate_asset_dto.dart @@ -233,6 +237,7 @@ lib/model/tag_response_dto.dart lib/model/tag_type_enum.dart lib/model/thumbnail_format.dart lib/model/time_group_enum.dart +lib/model/transcode_policy.dart lib/model/update_album_dto.dart lib/model/update_asset_dto.dart lib/model/update_tag_dto.dart @@ -241,6 +246,7 @@ lib/model/usage_by_user_dto.dart lib/model/user_count_response_dto.dart lib/model/user_response_dto.dart lib/model/validate_access_token_response_dto.dart +lib/model/video_codec.dart pubspec.yaml test/add_assets_dto_test.dart test/add_assets_response_dto_test.dart @@ -268,6 +274,7 @@ test/asset_ids_dto_test.dart test/asset_ids_response_dto_test.dart test/asset_response_dto_test.dart test/asset_type_enum_test.dart +test/audio_codec_test.dart test/auth_device_response_dto_test.dart test/authentication_api_test.dart test/change_password_dto_test.dart @@ -347,6 +354,7 @@ test/tag_response_dto_test.dart test/tag_type_enum_test.dart test/thumbnail_format_test.dart test/time_group_enum_test.dart +test/transcode_policy_test.dart test/update_album_dto_test.dart test/update_asset_dto_test.dart test/update_tag_dto_test.dart @@ -356,3 +364,4 @@ test/user_api_test.dart test/user_count_response_dto_test.dart test/user_response_dto_test.dart test/validate_access_token_response_dto_test.dart +test/video_codec_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 42ecf4177..a78726e08 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -199,6 +199,7 @@ Class | Method | HTTP request | Description - [AssetIdsResponseDto](doc//AssetIdsResponseDto.md) - [AssetResponseDto](doc//AssetResponseDto.md) - [AssetTypeEnum](doc//AssetTypeEnum.md) + - [AudioCodec](doc//AudioCodec.md) - [AuthDeviceResponseDto](doc//AuthDeviceResponseDto.md) - [ChangePasswordDto](doc//ChangePasswordDto.md) - [CheckDuplicateAssetDto](doc//CheckDuplicateAssetDto.md) @@ -268,6 +269,7 @@ Class | Method | HTTP request | Description - [TagTypeEnum](doc//TagTypeEnum.md) - [ThumbnailFormat](doc//ThumbnailFormat.md) - [TimeGroupEnum](doc//TimeGroupEnum.md) + - [TranscodePolicy](doc//TranscodePolicy.md) - [UpdateAlbumDto](doc//UpdateAlbumDto.md) - [UpdateAssetDto](doc//UpdateAssetDto.md) - [UpdateTagDto](doc//UpdateTagDto.md) @@ -276,6 +278,7 @@ Class | Method | HTTP request | Description - [UserCountResponseDto](doc//UserCountResponseDto.md) - [UserResponseDto](doc//UserResponseDto.md) - [ValidateAccessTokenResponseDto](doc//ValidateAccessTokenResponseDto.md) + - [VideoCodec](doc//VideoCodec.md) ## Documentation For Authorization diff --git a/mobile/openapi/doc/AudioCodec.md b/mobile/openapi/doc/AudioCodec.md new file mode 100644 index 000000000..eef859185 --- /dev/null +++ b/mobile/openapi/doc/AudioCodec.md @@ -0,0 +1,14 @@ +# openapi.model.AudioCodec + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/SystemConfigFFmpegDto.md b/mobile/openapi/doc/SystemConfigFFmpegDto.md index e2dcb45db..a08261e79 100644 --- a/mobile/openapi/doc/SystemConfigFFmpegDto.md +++ b/mobile/openapi/doc/SystemConfigFFmpegDto.md @@ -10,13 +10,13 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **crf** | **int** | | **threads** | **int** | | +**targetVideoCodec** | [**VideoCodec**](VideoCodec.md) | | +**targetAudioCodec** | [**AudioCodec**](AudioCodec.md) | | +**transcode** | [**TranscodePolicy**](TranscodePolicy.md) | | **preset** | **String** | | -**targetVideoCodec** | **String** | | -**targetAudioCodec** | **String** | | **targetResolution** | **String** | | **maxBitrate** | **String** | | **twoPass** | **bool** | | -**transcode** | **String** | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/TranscodePolicy.md b/mobile/openapi/doc/TranscodePolicy.md new file mode 100644 index 000000000..bf6b88cd3 --- /dev/null +++ b/mobile/openapi/doc/TranscodePolicy.md @@ -0,0 +1,14 @@ +# openapi.model.TranscodePolicy + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/VideoCodec.md b/mobile/openapi/doc/VideoCodec.md new file mode 100644 index 000000000..7b7d95798 --- /dev/null +++ b/mobile/openapi/doc/VideoCodec.md @@ -0,0 +1,14 @@ +# openapi.model.VideoCodec + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 47cfa9aa2..604f07f19 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -66,6 +66,7 @@ part 'model/asset_ids_dto.dart'; part 'model/asset_ids_response_dto.dart'; part 'model/asset_response_dto.dart'; part 'model/asset_type_enum.dart'; +part 'model/audio_codec.dart'; part 'model/auth_device_response_dto.dart'; part 'model/change_password_dto.dart'; part 'model/check_duplicate_asset_dto.dart'; @@ -135,6 +136,7 @@ part 'model/tag_response_dto.dart'; part 'model/tag_type_enum.dart'; part 'model/thumbnail_format.dart'; part 'model/time_group_enum.dart'; +part 'model/transcode_policy.dart'; part 'model/update_album_dto.dart'; part 'model/update_asset_dto.dart'; part 'model/update_tag_dto.dart'; @@ -143,6 +145,7 @@ part 'model/usage_by_user_dto.dart'; part 'model/user_count_response_dto.dart'; part 'model/user_response_dto.dart'; part 'model/validate_access_token_response_dto.dart'; +part 'model/video_codec.dart'; const _delimiters = {'csv': ',', 'ssv': ' ', 'tsv': '\t', 'pipes': '|'}; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 7ba532835..4ddf1833a 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -227,6 +227,8 @@ class ApiClient { return AssetResponseDto.fromJson(value); case 'AssetTypeEnum': return AssetTypeEnumTypeTransformer().decode(value); + case 'AudioCodec': + return AudioCodecTypeTransformer().decode(value); case 'AuthDeviceResponseDto': return AuthDeviceResponseDto.fromJson(value); case 'ChangePasswordDto': @@ -365,6 +367,8 @@ class ApiClient { return ThumbnailFormatTypeTransformer().decode(value); case 'TimeGroupEnum': return TimeGroupEnumTypeTransformer().decode(value); + case 'TranscodePolicy': + return TranscodePolicyTypeTransformer().decode(value); case 'UpdateAlbumDto': return UpdateAlbumDto.fromJson(value); case 'UpdateAssetDto': @@ -381,6 +385,8 @@ class ApiClient { return UserResponseDto.fromJson(value); case 'ValidateAccessTokenResponseDto': return ValidateAccessTokenResponseDto.fromJson(value); + case 'VideoCodec': + return VideoCodecTypeTransformer().decode(value); default: dynamic match; if (value is List && (match = _regList.firstMatch(targetType)?.group(1)) != null) { diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 386e6a7e7..9e7f5c3be 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -58,6 +58,9 @@ String parameterToString(dynamic value) { if (value is AssetTypeEnum) { return AssetTypeEnumTypeTransformer().encode(value).toString(); } + if (value is AudioCodec) { + return AudioCodecTypeTransformer().encode(value).toString(); + } if (value is DeleteAssetStatus) { return DeleteAssetStatusTypeTransformer().encode(value).toString(); } @@ -79,6 +82,12 @@ String parameterToString(dynamic value) { if (value is TimeGroupEnum) { return TimeGroupEnumTypeTransformer().encode(value).toString(); } + if (value is TranscodePolicy) { + return TranscodePolicyTypeTransformer().encode(value).toString(); + } + if (value is VideoCodec) { + return VideoCodecTypeTransformer().encode(value).toString(); + } return value.toString(); } diff --git a/mobile/openapi/lib/model/audio_codec.dart b/mobile/openapi/lib/model/audio_codec.dart new file mode 100644 index 000000000..f5b50006a --- /dev/null +++ b/mobile/openapi/lib/model/audio_codec.dart @@ -0,0 +1,88 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class AudioCodec { + /// Instantiate a new enum with the provided [value]. + const AudioCodec._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const mp3 = AudioCodec._(r'mp3'); + static const aac = AudioCodec._(r'aac'); + static const opus = AudioCodec._(r'opus'); + + /// List of all possible values in this [enum][AudioCodec]. + static const values = [ + mp3, + aac, + opus, + ]; + + static AudioCodec? fromJson(dynamic value) => AudioCodecTypeTransformer().decode(value); + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AudioCodec.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [AudioCodec] to String, +/// and [decode] dynamic data back to [AudioCodec]. +class AudioCodecTypeTransformer { + factory AudioCodecTypeTransformer() => _instance ??= const AudioCodecTypeTransformer._(); + + const AudioCodecTypeTransformer._(); + + String encode(AudioCodec data) => data.value; + + /// Decodes a [dynamic value][data] to a AudioCodec. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + AudioCodec? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'mp3': return AudioCodec.mp3; + case r'aac': return AudioCodec.aac; + case r'opus': return AudioCodec.opus; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [AudioCodecTypeTransformer] instance. + static AudioCodecTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart index 2cb29e0ce..7f21d9d6e 100644 --- a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart +++ b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart @@ -15,72 +15,72 @@ class SystemConfigFFmpegDto { SystemConfigFFmpegDto({ required this.crf, required this.threads, - required this.preset, required this.targetVideoCodec, required this.targetAudioCodec, + required this.transcode, + required this.preset, required this.targetResolution, required this.maxBitrate, required this.twoPass, - required this.transcode, }); int crf; int threads; + VideoCodec targetVideoCodec; + + AudioCodec targetAudioCodec; + + TranscodePolicy transcode; + String preset; - String targetVideoCodec; - - String targetAudioCodec; - String targetResolution; String maxBitrate; bool twoPass; - SystemConfigFFmpegDtoTranscodeEnum transcode; - @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigFFmpegDto && other.crf == crf && other.threads == threads && - other.preset == preset && other.targetVideoCodec == targetVideoCodec && other.targetAudioCodec == targetAudioCodec && + other.transcode == transcode && + other.preset == preset && other.targetResolution == targetResolution && other.maxBitrate == maxBitrate && - other.twoPass == twoPass && - other.transcode == transcode; + other.twoPass == twoPass; @override int get hashCode => // ignore: unnecessary_parenthesis (crf.hashCode) + (threads.hashCode) + - (preset.hashCode) + (targetVideoCodec.hashCode) + (targetAudioCodec.hashCode) + + (transcode.hashCode) + + (preset.hashCode) + (targetResolution.hashCode) + (maxBitrate.hashCode) + - (twoPass.hashCode) + - (transcode.hashCode); + (twoPass.hashCode); @override - String toString() => 'SystemConfigFFmpegDto[crf=$crf, threads=$threads, preset=$preset, targetVideoCodec=$targetVideoCodec, targetAudioCodec=$targetAudioCodec, targetResolution=$targetResolution, maxBitrate=$maxBitrate, twoPass=$twoPass, transcode=$transcode]'; + String toString() => 'SystemConfigFFmpegDto[crf=$crf, threads=$threads, targetVideoCodec=$targetVideoCodec, targetAudioCodec=$targetAudioCodec, transcode=$transcode, preset=$preset, targetResolution=$targetResolution, maxBitrate=$maxBitrate, twoPass=$twoPass]'; Map toJson() { final json = {}; json[r'crf'] = this.crf; json[r'threads'] = this.threads; - json[r'preset'] = this.preset; json[r'targetVideoCodec'] = this.targetVideoCodec; json[r'targetAudioCodec'] = this.targetAudioCodec; + json[r'transcode'] = this.transcode; + json[r'preset'] = this.preset; json[r'targetResolution'] = this.targetResolution; json[r'maxBitrate'] = this.maxBitrate; json[r'twoPass'] = this.twoPass; - json[r'transcode'] = this.transcode; return json; } @@ -94,13 +94,13 @@ class SystemConfigFFmpegDto { return SystemConfigFFmpegDto( crf: mapValueOfType(json, r'crf')!, threads: mapValueOfType(json, r'threads')!, + targetVideoCodec: VideoCodec.fromJson(json[r'targetVideoCodec'])!, + targetAudioCodec: AudioCodec.fromJson(json[r'targetAudioCodec'])!, + transcode: TranscodePolicy.fromJson(json[r'transcode'])!, preset: mapValueOfType(json, r'preset')!, - targetVideoCodec: mapValueOfType(json, r'targetVideoCodec')!, - targetAudioCodec: mapValueOfType(json, r'targetAudioCodec')!, targetResolution: mapValueOfType(json, r'targetResolution')!, maxBitrate: mapValueOfType(json, r'maxBitrate')!, twoPass: mapValueOfType(json, r'twoPass')!, - transcode: SystemConfigFFmpegDtoTranscodeEnum.fromJson(json[r'transcode'])!, ); } return null; @@ -150,93 +150,13 @@ class SystemConfigFFmpegDto { static const requiredKeys = { 'crf', 'threads', - 'preset', 'targetVideoCodec', 'targetAudioCodec', + 'transcode', + 'preset', 'targetResolution', 'maxBitrate', 'twoPass', - 'transcode', }; } - -class SystemConfigFFmpegDtoTranscodeEnum { - /// Instantiate a new enum with the provided [value]. - const SystemConfigFFmpegDtoTranscodeEnum._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const all = SystemConfigFFmpegDtoTranscodeEnum._(r'all'); - static const optimal = SystemConfigFFmpegDtoTranscodeEnum._(r'optimal'); - static const required_ = SystemConfigFFmpegDtoTranscodeEnum._(r'required'); - static const disabled = SystemConfigFFmpegDtoTranscodeEnum._(r'disabled'); - - /// List of all possible values in this [enum][SystemConfigFFmpegDtoTranscodeEnum]. - static const values = [ - all, - optimal, - required_, - disabled, - ]; - - static SystemConfigFFmpegDtoTranscodeEnum? fromJson(dynamic value) => SystemConfigFFmpegDtoTranscodeEnumTypeTransformer().decode(value); - - static List? listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = SystemConfigFFmpegDtoTranscodeEnum.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [SystemConfigFFmpegDtoTranscodeEnum] to String, -/// and [decode] dynamic data back to [SystemConfigFFmpegDtoTranscodeEnum]. -class SystemConfigFFmpegDtoTranscodeEnumTypeTransformer { - factory SystemConfigFFmpegDtoTranscodeEnumTypeTransformer() => _instance ??= const SystemConfigFFmpegDtoTranscodeEnumTypeTransformer._(); - - const SystemConfigFFmpegDtoTranscodeEnumTypeTransformer._(); - - String encode(SystemConfigFFmpegDtoTranscodeEnum data) => data.value; - - /// Decodes a [dynamic value][data] to a SystemConfigFFmpegDtoTranscodeEnum. - /// - /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, - /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, - /// and users are still using an old app with the old code. - SystemConfigFFmpegDtoTranscodeEnum? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'all': return SystemConfigFFmpegDtoTranscodeEnum.all; - case r'optimal': return SystemConfigFFmpegDtoTranscodeEnum.optimal; - case r'required': return SystemConfigFFmpegDtoTranscodeEnum.required_; - case r'disabled': return SystemConfigFFmpegDtoTranscodeEnum.disabled; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [SystemConfigFFmpegDtoTranscodeEnumTypeTransformer] instance. - static SystemConfigFFmpegDtoTranscodeEnumTypeTransformer? _instance; -} - - diff --git a/mobile/openapi/lib/model/transcode_policy.dart b/mobile/openapi/lib/model/transcode_policy.dart new file mode 100644 index 000000000..c490b5cff --- /dev/null +++ b/mobile/openapi/lib/model/transcode_policy.dart @@ -0,0 +1,91 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class TranscodePolicy { + /// Instantiate a new enum with the provided [value]. + const TranscodePolicy._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const all = TranscodePolicy._(r'all'); + static const optimal = TranscodePolicy._(r'optimal'); + static const required_ = TranscodePolicy._(r'required'); + static const disabled = TranscodePolicy._(r'disabled'); + + /// List of all possible values in this [enum][TranscodePolicy]. + static const values = [ + all, + optimal, + required_, + disabled, + ]; + + static TranscodePolicy? fromJson(dynamic value) => TranscodePolicyTypeTransformer().decode(value); + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TranscodePolicy.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [TranscodePolicy] to String, +/// and [decode] dynamic data back to [TranscodePolicy]. +class TranscodePolicyTypeTransformer { + factory TranscodePolicyTypeTransformer() => _instance ??= const TranscodePolicyTypeTransformer._(); + + const TranscodePolicyTypeTransformer._(); + + String encode(TranscodePolicy data) => data.value; + + /// Decodes a [dynamic value][data] to a TranscodePolicy. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + TranscodePolicy? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'all': return TranscodePolicy.all; + case r'optimal': return TranscodePolicy.optimal; + case r'required': return TranscodePolicy.required_; + case r'disabled': return TranscodePolicy.disabled; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [TranscodePolicyTypeTransformer] instance. + static TranscodePolicyTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/video_codec.dart b/mobile/openapi/lib/model/video_codec.dart new file mode 100644 index 000000000..784c4acb5 --- /dev/null +++ b/mobile/openapi/lib/model/video_codec.dart @@ -0,0 +1,88 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class VideoCodec { + /// Instantiate a new enum with the provided [value]. + const VideoCodec._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const h264 = VideoCodec._(r'h264'); + static const hevc = VideoCodec._(r'hevc'); + static const vp9 = VideoCodec._(r'vp9'); + + /// List of all possible values in this [enum][VideoCodec]. + static const values = [ + h264, + hevc, + vp9, + ]; + + static VideoCodec? fromJson(dynamic value) => VideoCodecTypeTransformer().decode(value); + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = VideoCodec.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [VideoCodec] to String, +/// and [decode] dynamic data back to [VideoCodec]. +class VideoCodecTypeTransformer { + factory VideoCodecTypeTransformer() => _instance ??= const VideoCodecTypeTransformer._(); + + const VideoCodecTypeTransformer._(); + + String encode(VideoCodec data) => data.value; + + /// Decodes a [dynamic value][data] to a VideoCodec. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + VideoCodec? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'h264': return VideoCodec.h264; + case r'hevc': return VideoCodec.hevc; + case r'vp9': return VideoCodec.vp9; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [VideoCodecTypeTransformer] instance. + static VideoCodecTypeTransformer? _instance; +} + diff --git a/mobile/openapi/test/audio_codec_test.dart b/mobile/openapi/test/audio_codec_test.dart new file mode 100644 index 000000000..a6c61661d --- /dev/null +++ b/mobile/openapi/test/audio_codec_test.dart @@ -0,0 +1,21 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for AudioCodec +void main() { + + group('test AudioCodec', () { + + }); + +} diff --git a/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart b/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart index 3305d8d00..7f210e978 100644 --- a/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart +++ b/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart @@ -26,21 +26,26 @@ void main() { // TODO }); - // String preset - test('to test the property `preset`', () async { - // TODO - }); - - // String targetVideoCodec + // VideoCodec targetVideoCodec test('to test the property `targetVideoCodec`', () async { // TODO }); - // String targetAudioCodec + // AudioCodec targetAudioCodec test('to test the property `targetAudioCodec`', () async { // TODO }); + // TranscodePolicy transcode + test('to test the property `transcode`', () async { + // TODO + }); + + // String preset + test('to test the property `preset`', () async { + // TODO + }); + // String targetResolution test('to test the property `targetResolution`', () async { // TODO @@ -56,11 +61,6 @@ void main() { // TODO }); - // String transcode - test('to test the property `transcode`', () async { - // TODO - }); - }); diff --git a/mobile/openapi/test/transcode_policy_test.dart b/mobile/openapi/test/transcode_policy_test.dart new file mode 100644 index 000000000..4a27e2a88 --- /dev/null +++ b/mobile/openapi/test/transcode_policy_test.dart @@ -0,0 +1,21 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for TranscodePolicy +void main() { + + group('test TranscodePolicy', () { + + }); + +} diff --git a/mobile/openapi/test/video_codec_test.dart b/mobile/openapi/test/video_codec_test.dart new file mode 100644 index 000000000..8c1e4a17f --- /dev/null +++ b/mobile/openapi/test/video_codec_test.dart @@ -0,0 +1,21 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for VideoCodec +void main() { + + group('test VideoCodec', () { + + }); + +} diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 5f664730f..b35660fda 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -4929,6 +4929,14 @@ "OTHER" ] }, + "AudioCodec": { + "type": "string", + "enum": [ + "mp3", + "aac", + "opus" + ] + }, "AuthDeviceResponseDto": { "type": "object", "properties": { @@ -6347,13 +6355,16 @@ "threads": { "type": "integer" }, - "preset": { - "type": "string" - }, "targetVideoCodec": { - "type": "string" + "$ref": "#/components/schemas/VideoCodec" }, "targetAudioCodec": { + "$ref": "#/components/schemas/AudioCodec" + }, + "transcode": { + "$ref": "#/components/schemas/TranscodePolicy" + }, + "preset": { "type": "string" }, "targetResolution": { @@ -6364,27 +6375,18 @@ }, "twoPass": { "type": "boolean" - }, - "transcode": { - "type": "string", - "enum": [ - "all", - "optimal", - "required", - "disabled" - ] } }, "required": [ "crf", "threads", - "preset", "targetVideoCodec", "targetAudioCodec", + "transcode", + "preset", "targetResolution", "maxBitrate", - "twoPass", - "transcode" + "twoPass" ] }, "SystemConfigJobDto": { @@ -6604,6 +6606,15 @@ "month" ] }, + "TranscodePolicy": { + "type": "string", + "enum": [ + "all", + "optimal", + "required", + "disabled" + ] + }, "UpdateAlbumDto": { "type": "object", "properties": { @@ -6804,6 +6815,14 @@ "required": [ "authStatus" ] + }, + "VideoCodec": { + "type": "string", + "enum": [ + "h264", + "hevc", + "vp9" + ] } } } diff --git a/server/src/domain/media/media.repository.ts b/server/src/domain/media/media.repository.ts index c3d7dc0e4..c6ca835df 100644 --- a/server/src/domain/media/media.repository.ts +++ b/server/src/domain/media/media.repository.ts @@ -39,10 +39,22 @@ export interface CropOptions { } export interface TranscodeOptions { + inputOptions: string[]; outputOptions: string[]; twoPass: boolean; } +export interface BitrateDistribution { + max: number; + target: number; + min: number; + unit: string; +} + +export interface VideoCodecSWConfig { + getOptions(stream: VideoStreamInfo): TranscodeOptions; +} + export interface IMediaRepository { // image resize(input: string | Buffer, output: string, options: ResizeOptions): Promise; diff --git a/server/src/domain/media/media.service.spec.ts b/server/src/domain/media/media.service.spec.ts index 010e68a23..8a5f1e297 100644 --- a/server/src/domain/media/media.service.spec.ts +++ b/server/src/domain/media/media.service.spec.ts @@ -1,4 +1,4 @@ -import { AssetType, SystemConfigKey } from '@app/infra/entities'; +import { AssetType, SystemConfigKey, TranscodePolicy, VideoCodec } from '@app/infra/entities'; import { assetEntityStub, newAssetRepositoryMock, @@ -104,6 +104,13 @@ describe(MediaService.name, () => { }); describe('handleGenerateJpegThumbnail', () => { + it('should skip thumbnail generation if asset not found', async () => { + assetMock.getByIds.mockResolvedValue([]); + await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id }); + expect(mediaMock.resize).not.toHaveBeenCalled(); + expect(assetMock.save).not.toHaveBeenCalledWith(); + }); + it('should generate a thumbnail for an image', async () => { assetMock.getByIds.mockResolvedValue([assetEntityStub.image]); await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id }); @@ -142,15 +149,22 @@ describe(MediaService.name, () => { }); describe('handleGenerateWebpThumbnail', () => { + it('should skip thumbnail generation if asset not found', async () => { + assetMock.getByIds.mockResolvedValue([]); + await sut.handleGenerateWebpThumbnail({ id: assetEntityStub.image.id }); + expect(mediaMock.resize).not.toHaveBeenCalled(); + expect(assetMock.save).not.toHaveBeenCalledWith(); + }); + it('should skip thumbnail generate if resize path is missing', async () => { assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]); - await sut.handleGenerateWepbThumbnail({ id: assetEntityStub.noResizePath.id }); + await sut.handleGenerateWebpThumbnail({ id: assetEntityStub.noResizePath.id }); expect(mediaMock.resize).not.toHaveBeenCalled(); }); it('should generate a thumbnail', async () => { assetMock.getByIds.mockResolvedValue([assetEntityStub.image]); - await sut.handleGenerateWepbThumbnail({ id: assetEntityStub.image.id }); + await sut.handleGenerateWebpThumbnail({ id: assetEntityStub.image.id }); expect(mediaMock.resize).toHaveBeenCalledWith( '/uploads/user-id/thumbs/path.ext', @@ -162,6 +176,12 @@ describe(MediaService.name, () => { }); describe('handleGenerateThumbhashThumbnail', () => { + it('should skip thumbhash generation if asset not found', async () => { + assetMock.getByIds.mockResolvedValue([]); + await sut.handleGenerateThumbhashThumbnail({ id: assetEntityStub.image.id }); + expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); + }); + it('should skip thumbhash generation if resize path is missing', async () => { assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]); await sut.handleGenerateThumbhashThumbnail({ id: assetEntityStub.noResizePath.id }); @@ -219,6 +239,20 @@ describe(MediaService.name, () => { assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); }); + it('should skip transcoding if asset not found', async () => { + assetMock.getByIds.mockResolvedValue([]); + await sut.handleVideoConversion({ id: assetEntityStub.video.id }); + expect(mediaMock.probe).not.toHaveBeenCalled(); + expect(mediaMock.transcode).not.toHaveBeenCalled(); + }); + + it('should skip transcoding if non-video asset', async () => { + assetMock.getByIds.mockResolvedValue([assetEntityStub.image]); + await sut.handleVideoConversion({ id: assetEntityStub.image.id }); + expect(mediaMock.probe).not.toHaveBeenCalled(); + expect(mediaMock.transcode).not.toHaveBeenCalled(); + }); + it('should transcode the longest stream', async () => { assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); @@ -232,6 +266,7 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec h264', '-acodec aac', @@ -261,13 +296,14 @@ describe(MediaService.name, () => { it('should transcode when set to all', async () => { mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'all' }]); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.ALL }]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); await sut.handleVideoConversion({ id: assetEntityStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec h264', '-acodec aac', @@ -283,12 +319,13 @@ describe(MediaService.name, () => { it('should transcode when optimal and too big', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); await sut.handleVideoConversion({ id: assetEntityStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec h264', '-acodec aac', @@ -306,7 +343,7 @@ describe(MediaService.name, () => { it('should not scale resolution if no target resolution', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'all' }, + { key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.ALL }, { key: SystemConfigKey.FFMPEG_TARGET_RESOLUTION, value: 'original' }, ]); await sut.handleVideoConversion({ id: assetEntityStub.video.id }); @@ -314,6 +351,7 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec h264', '-acodec aac', @@ -329,13 +367,14 @@ describe(MediaService.name, () => { it('should transcode with alternate scaling video is vertical', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); await sut.handleVideoConversion({ id: assetEntityStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec h264', '-acodec aac', @@ -352,13 +391,14 @@ describe(MediaService.name, () => { it('should transcode when audio doesnt match target', async () => { mediaMock.probe.mockResolvedValue(probeStub.audioStreamMp3); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); await sut.handleVideoConversion({ id: assetEntityStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec h264', '-acodec aac', @@ -375,13 +415,14 @@ describe(MediaService.name, () => { it('should transcode when container doesnt match target', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); await sut.handleVideoConversion({ id: assetEntityStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec h264', '-acodec aac', @@ -404,6 +445,22 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).not.toHaveBeenCalled(); }); + it('should not transcode if transcoding is disabled', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.DISABLED }]); + assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); + await sut.handleVideoConversion({ id: assetEntityStub.video.id }); + expect(mediaMock.transcode).not.toHaveBeenCalled(); + }); + + it('should not transcode if target codec is invalid', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'invalid' }]); + assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); + await sut.handleVideoConversion({ id: assetEntityStub.video.id }); + expect(mediaMock.transcode).not.toHaveBeenCalled(); + }); + it('should set max bitrate if above 0', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }]); @@ -413,6 +470,7 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec h264', '-acodec aac', @@ -441,6 +499,7 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec h264', '-acodec aac', @@ -466,6 +525,7 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec h264', '-acodec aac', @@ -480,11 +540,12 @@ describe(MediaService.name, () => { ); }); - it('should configure preset for vp9', async () => { + it('should transcode by bitrate in two passes for vp9 when two pass mode and max bitrate are enabled', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' }, - { key: SystemConfigKey.FFMPEG_THREADS, value: 2 }, + { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }, + { key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }, + { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, ]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); await sut.handleVideoConversion({ id: assetEntityStub.video.id }); @@ -492,6 +553,7 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec vp9', '-acodec aac', @@ -500,7 +562,64 @@ describe(MediaService.name, () => { '-vf scale=-2:720', '-cpu-used 5', '-row-mt 1', - '-threads 2', + '-b:v 3104k', + '-minrate 1552k', + '-maxrate 4500k', + ], + twoPass: true, + }, + ); + }); + + it('should configure preset for vp9', async () => { + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + configMock.load.mockResolvedValue([ + { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, + { key: SystemConfigKey.FFMPEG_PRESET, value: 'slow' }, + ]); + assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); + await sut.handleVideoConversion({ id: assetEntityStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/asset-id.mp4', + { + inputOptions: [], + outputOptions: [ + '-vcodec vp9', + '-acodec aac', + '-movflags faststart', + '-fps_mode passthrough', + '-vf scale=-2:720', + '-cpu-used 2', + '-row-mt 1', + '-crf 23', + '-b:v 0', + ], + twoPass: false, + }, + ); + }); + + it('should not configure preset for vp9 if invalid', async () => { + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + configMock.load.mockResolvedValue([ + { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, + { key: SystemConfigKey.FFMPEG_PRESET, value: 'invalid' }, + ]); + assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); + await sut.handleVideoConversion({ id: assetEntityStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/asset-id.mp4', + { + inputOptions: [], + outputOptions: [ + '-vcodec vp9', + '-acodec aac', + '-movflags faststart', + '-fps_mode passthrough', + '-vf scale=-2:720', + '-row-mt 1', '-crf 23', '-b:v 0', ], @@ -512,7 +631,7 @@ describe(MediaService.name, () => { it('should configure threads if above 0', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' }, + { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, { key: SystemConfigKey.FFMPEG_THREADS, value: 2 }, ]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); @@ -521,6 +640,7 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec vp9', '-acodec aac', @@ -538,7 +658,7 @@ describe(MediaService.name, () => { ); }); - it('should disable thread pooling for x264/x265 if thread limit is above 0', async () => { + it('should disable thread pooling for h264 if thread limit is above 0', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 }]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); @@ -547,6 +667,7 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec h264', '-acodec aac', @@ -563,5 +684,86 @@ describe(MediaService.name, () => { }, ); }); + + it('should omit thread flags for h264 if thread limit is at or below 0', async () => { + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 0 }]); + assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); + await sut.handleVideoConversion({ id: assetEntityStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/asset-id.mp4', + { + inputOptions: [], + outputOptions: [ + '-vcodec h264', + '-acodec aac', + '-movflags faststart', + '-fps_mode passthrough', + '-vf scale=-2:720', + '-preset ultrafast', + '-crf 23', + ], + twoPass: false, + }, + ); + }); + + it('should disable thread pooling for hevc if thread limit is above 0', async () => { + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + configMock.load.mockResolvedValue([ + { key: SystemConfigKey.FFMPEG_THREADS, value: 2 }, + { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, + ]); + assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); + await sut.handleVideoConversion({ id: assetEntityStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/asset-id.mp4', + { + inputOptions: [], + outputOptions: [ + '-vcodec hevc', + '-acodec aac', + '-movflags faststart', + '-fps_mode passthrough', + '-vf scale=-2:720', + '-preset ultrafast', + '-threads 2', + '-x265-params "pools=none"', + '-x265-params "frame-threads=2"', + '-crf 23', + ], + twoPass: false, + }, + ); + }); + + it('should omit thread flags for hevc if thread limit is at or below 0', async () => { + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + configMock.load.mockResolvedValue([ + { key: SystemConfigKey.FFMPEG_THREADS, value: 0 }, + { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, + ]); + assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); + await sut.handleVideoConversion({ id: assetEntityStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/asset-id.mp4', + { + inputOptions: [], + outputOptions: [ + '-vcodec hevc', + '-acodec aac', + '-movflags faststart', + '-fps_mode passthrough', + '-vf scale=-2:720', + '-preset ultrafast', + '-crf 23', + ], + twoPass: false, + }, + ); + }); }); }); diff --git a/server/src/domain/media/media.service.ts b/server/src/domain/media/media.service.ts index 91f25df87..cfc04fba1 100644 --- a/server/src/domain/media/media.service.ts +++ b/server/src/domain/media/media.service.ts @@ -1,5 +1,5 @@ -import { AssetEntity, AssetType, TranscodePreset } from '@app/infra/entities'; -import { Inject, Injectable, Logger } from '@nestjs/common'; +import { AssetEntity, AssetType, TranscodePolicy, VideoCodec } from '@app/infra/entities'; +import { Inject, Injectable, Logger, UnsupportedMediaTypeException } from '@nestjs/common'; import { join } from 'path'; import { IAssetRepository, WithoutProperty } from '../asset'; import { usePagination } from '../domain.util'; @@ -9,6 +9,7 @@ import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config import { SystemConfigCore } from '../system-config/system-config.core'; import { JPEG_THUMBNAIL_SIZE, WEBP_THUMBNAIL_SIZE } from './media.constant'; import { AudioStreamInfo, IMediaRepository, VideoStreamInfo } from './media.repository'; +import { H264Config, HEVCConfig, VP9Config } from './media.util'; @Injectable() export class MediaService { @@ -82,7 +83,7 @@ export class MediaService { return true; } - async handleGenerateWepbThumbnail({ id }: IEntityJob) { + async handleGenerateWebpThumbnail({ id }: IEntityJob) { const [asset] = await this.assetRepository.getByIds([id]); if (!asset || !asset.resizePath) { return false; @@ -152,11 +153,16 @@ export class MediaService { return false; } - const outputOptions = this.getFfmpegOptions(mainVideoStream, config); - const twoPass = this.eligibleForTwoPass(config); + let transcodeOptions; + try { + transcodeOptions = this.getCodecConfig(config).getOptions(mainVideoStream); + } catch (err) { + this.logger.error(`An error occurred while configuring transcoding options: ${err}`); + return false; + } - this.logger.log(`Start encoding video ${asset.id} ${outputOptions}`); - await this.mediaRepository.transcode(input, output, { outputOptions, twoPass }); + this.logger.log(`Start encoding video ${asset.id} ${JSON.stringify(transcodeOptions)}`); + await this.mediaRepository.transcode(input, output, transcodeOptions); this.logger.log(`Encoding success ${asset.id}`); @@ -199,16 +205,16 @@ export class MediaService { const isLargerThanTargetRes = scalingEnabled && Math.min(videoStream.height, videoStream.width) > targetRes; switch (ffmpegConfig.transcode) { - case TranscodePreset.DISABLED: + case TranscodePolicy.DISABLED: return false; - case TranscodePreset.ALL: + case TranscodePolicy.ALL: return true; - case TranscodePreset.REQUIRED: + case TranscodePolicy.REQUIRED: return !allTargetsMatching; - case TranscodePreset.OPTIMAL: + case TranscodePolicy.OPTIMAL: return !allTargetsMatching || isLargerThanTargetRes; default: @@ -216,99 +222,16 @@ export class MediaService { } } - private getFfmpegOptions(stream: VideoStreamInfo, ffmpeg: SystemConfigFFmpegDto) { - const options = [ - `-vcodec ${ffmpeg.targetVideoCodec}`, - `-acodec ${ffmpeg.targetAudioCodec}`, - // Makes a second pass moving the moov atom to the beginning of - // the file for improved playback speed. - '-movflags faststart', - '-fps_mode passthrough', - ]; - - // video dimensions - const videoIsRotated = Math.abs(stream.rotation) === 90; - const scalingEnabled = ffmpeg.targetResolution !== 'original'; - const targetResolution = Number.parseInt(ffmpeg.targetResolution); - const isVideoVertical = stream.height > stream.width || videoIsRotated; - const scaling = isVideoVertical ? `${targetResolution}:-2` : `-2:${targetResolution}`; - const shouldScale = scalingEnabled && Math.min(stream.height, stream.width) > targetResolution; - - // video codec - const isVP9 = ffmpeg.targetVideoCodec === 'vp9'; - const isH264 = ffmpeg.targetVideoCodec === 'h264'; - const isH265 = ffmpeg.targetVideoCodec === 'hevc'; - - // transcode efficiency - const limitThreads = ffmpeg.threads > 0; - const maxBitrateValue = Number.parseInt(ffmpeg.maxBitrate) || 0; - const constrainMaximumBitrate = maxBitrateValue > 0; - const bitrateUnit = ffmpeg.maxBitrate.trim().substring(maxBitrateValue.toString().length); // use inputted unit if provided - - if (shouldScale) { - options.push(`-vf scale=${scaling}`); + private getCodecConfig(config: SystemConfigFFmpegDto) { + switch (config.targetVideoCodec) { + case VideoCodec.H264: + return new H264Config(config); + case VideoCodec.HEVC: + return new HEVCConfig(config); + case VideoCodec.VP9: + return new VP9Config(config); + default: + throw new UnsupportedMediaTypeException(`Codec '${config.targetVideoCodec}' is unsupported`); } - - if (isH264 || isH265) { - options.push(`-preset ${ffmpeg.preset}`); - } - - if (isVP9) { - // vp9 doesn't have presets, but does have a similar setting -cpu-used, from 0-5, 0 being the slowest - const presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast']; - const speed = Math.min(presets.indexOf(ffmpeg.preset), 5); // values over 5 require realtime mode, which is its own can of worms since it overrides -crf and -threads - if (speed >= 0) { - options.push(`-cpu-used ${speed}`); - } - options.push('-row-mt 1'); // better multithreading - } - - if (limitThreads) { - options.push(`-threads ${ffmpeg.threads}`); - - // x264 and x265 handle threads differently than one might expect - // https://x265.readthedocs.io/en/latest/cli.html#cmdoption-pools - if (isH264 || isH265) { - options.push(`-${isH265 ? 'x265' : 'x264'}-params "pools=none"`); - options.push(`-${isH265 ? 'x265' : 'x264'}-params "frame-threads=${ffmpeg.threads}"`); - } - } - - // two-pass mode for x264/x265 uses bitrate ranges, so it requires a max bitrate from which to derive a target and min bitrate - if (constrainMaximumBitrate && ffmpeg.twoPass) { - const targetBitrateValue = Math.ceil(maxBitrateValue / 1.45); // recommended by https://developers.google.com/media/vp9/settings/vod - const minBitrateValue = targetBitrateValue / 2; - - options.push(`-b:v ${targetBitrateValue}${bitrateUnit}`); - options.push(`-minrate ${minBitrateValue}${bitrateUnit}`); - options.push(`-maxrate ${maxBitrateValue}${bitrateUnit}`); - } else if (constrainMaximumBitrate || isVP9) { - // for vp9, these flags work for both one-pass and two-pass - options.push(`-crf ${ffmpeg.crf}`); - if (isVP9) { - options.push(`-b:v ${maxBitrateValue}${bitrateUnit}`); - } else { - options.push(`-maxrate ${maxBitrateValue}${bitrateUnit}`); - // -bufsize is the peak possible bitrate at any moment, while -maxrate is the max rolling average bitrate - // needed for -maxrate to be enforced - options.push(`-bufsize ${maxBitrateValue * 2}${bitrateUnit}`); - } - } else { - options.push(`-crf ${ffmpeg.crf}`); - } - - return options; - } - - private eligibleForTwoPass(ffmpeg: SystemConfigFFmpegDto) { - if (!ffmpeg.twoPass) { - return false; - } - - const isVP9 = ffmpeg.targetVideoCodec === 'vp9'; - const maxBitrateValue = Number.parseInt(ffmpeg.maxBitrate) || 0; - const constrainMaximumBitrate = maxBitrateValue > 0; - - return constrainMaximumBitrate || isVP9; } } diff --git a/server/src/domain/media/media.util.ts b/server/src/domain/media/media.util.ts new file mode 100644 index 000000000..bee22e9e6 --- /dev/null +++ b/server/src/domain/media/media.util.ts @@ -0,0 +1,191 @@ +import { SystemConfigFFmpegDto } from '../system-config/dto'; +import { BitrateDistribution, TranscodeOptions, VideoCodecSWConfig, VideoStreamInfo } from './media.repository'; + +class BaseConfig implements VideoCodecSWConfig { + constructor(protected config: SystemConfigFFmpegDto) {} + + getOptions(stream: VideoStreamInfo) { + const options = { + inputOptions: this.getBaseInputOptions(), + outputOptions: this.getBaseOutputOptions(), + twoPass: this.eligibleForTwoPass(), + } as TranscodeOptions; + const filters = this.getFilterOptions(stream); + if (filters.length > 0) { + options.outputOptions.push(`-vf ${filters.join(',')}`); + } + options.outputOptions.push(...this.getPresetOptions()); + options.outputOptions.push(...this.getThreadOptions()); + options.outputOptions.push(...this.getBitrateOptions()); + + return options; + } + + getBaseInputOptions(): string[] { + return []; + } + + getBaseOutputOptions() { + return [ + `-vcodec ${this.config.targetVideoCodec}`, + `-acodec ${this.config.targetAudioCodec}`, + // Makes a second pass moving the moov atom to the beginning of + // the file for improved playback speed. + '-movflags faststart', + '-fps_mode passthrough', + ]; + } + + getFilterOptions(stream: VideoStreamInfo) { + const options = []; + if (this.shouldScale(stream)) { + options.push(`scale=${this.getScaling(stream)}`); + } + + return options; + } + + getPresetOptions() { + return [`-preset ${this.config.preset}`]; + } + + getBitrateOptions() { + const bitrates = this.getBitrateDistribution(); + if (this.eligibleForTwoPass()) { + return [ + `-b:v ${bitrates.target}${bitrates.unit}`, + `-minrate ${bitrates.min}${bitrates.unit}`, + `-maxrate ${bitrates.max}${bitrates.unit}`, + ]; + } else if (bitrates.max > 0) { + // -bufsize is the peak possible bitrate at any moment, while -maxrate is the max rolling average bitrate + return [ + `-crf ${this.config.crf}`, + `-maxrate ${bitrates.max}${bitrates.unit}`, + `-bufsize ${bitrates.max * 2}${bitrates.unit}`, + ]; + } else { + return [`-crf ${this.config.crf}`]; + } + } + + getThreadOptions(): Array { + if (this.config.threads <= 0) { + return []; + } + return [`-threads ${this.config.threads}`]; + } + + eligibleForTwoPass() { + if (!this.config.twoPass) { + return false; + } + + return this.isBitrateConstrained() || this.config.targetVideoCodec === 'vp9'; + } + + getBitrateDistribution() { + const max = this.getMaxBitrateValue(); + const target = Math.ceil(max / 1.45); // recommended by https://developers.google.com/media/vp9/settings/vod + const min = target / 2; + const unit = this.getBitrateUnit(); + + return { max, target, min, unit } as BitrateDistribution; + } + + getTargetResolution(stream: VideoStreamInfo) { + if (this.config.targetResolution === 'original') { + return Math.min(stream.height, stream.width); + } + + return Number.parseInt(this.config.targetResolution); + } + + shouldScale(stream: VideoStreamInfo) { + return Math.min(stream.height, stream.width) > this.getTargetResolution(stream); + } + + getScaling(stream: VideoStreamInfo) { + const targetResolution = this.getTargetResolution(stream); + return this.isVideoVertical(stream) ? `${targetResolution}:-2` : `-2:${targetResolution}`; + } + + isVideoRotated(stream: VideoStreamInfo) { + return Math.abs(stream.rotation) === 90; + } + + isVideoVertical(stream: VideoStreamInfo) { + return stream.height > stream.width || this.isVideoRotated(stream); + } + + isBitrateConstrained() { + return this.getMaxBitrateValue() > 0; + } + + getBitrateUnit() { + const maxBitrate = this.getMaxBitrateValue(); + return this.config.maxBitrate.trim().substring(maxBitrate.toString().length); // use inputted unit if provided + } + + getMaxBitrateValue() { + return Number.parseInt(this.config.maxBitrate) || 0; + } + + getPresetIndex() { + const presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast']; + return presets.indexOf(this.config.preset); + } +} + +export class H264Config extends BaseConfig { + getThreadOptions() { + if (this.config.threads <= 0) { + return []; + } + return [ + ...super.getThreadOptions(), + '-x264-params "pools=none"', + `-x264-params "frame-threads=${this.config.threads}"`, + ]; + } +} + +export class HEVCConfig extends BaseConfig { + getThreadOptions() { + if (this.config.threads <= 0) { + return []; + } + return [ + ...super.getThreadOptions(), + '-x265-params "pools=none"', + `-x265-params "frame-threads=${this.config.threads}"`, + ]; + } +} + +export class VP9Config extends BaseConfig { + getPresetOptions() { + const speed = Math.min(this.getPresetIndex(), 5); // values over 5 require realtime mode, which is its own can of worms since it overrides -crf and -threads + if (speed >= 0) { + return [`-cpu-used ${speed}`]; + } + return []; + } + + getBitrateOptions() { + const bitrates = this.getBitrateDistribution(); + if (this.eligibleForTwoPass()) { + return [ + `-b:v ${bitrates.target}${bitrates.unit}`, + `-minrate ${bitrates.min}${bitrates.unit}`, + `-maxrate ${bitrates.max}${bitrates.unit}`, + ]; + } + + return [`-crf ${this.config.crf}`, `-b:v ${bitrates.max}${bitrates.unit}`]; + } + + getThreadOptions() { + return ['-row-mt 1', ...super.getThreadOptions()]; + } +} diff --git a/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts b/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts index 1a641828d..01f9f9ca7 100644 --- a/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts +++ b/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts @@ -1,4 +1,4 @@ -import { TranscodePreset } from '@app/infra/entities'; +import { AudioCodec, TranscodePolicy, VideoCodec } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsBoolean, IsEnum, IsInt, IsString, Max, Min } from 'class-validator'; @@ -20,11 +20,13 @@ export class SystemConfigFFmpegDto { @IsString() preset!: string; - @IsString() - targetVideoCodec!: string; + @IsEnum(VideoCodec) + @ApiProperty({ enumName: 'VideoCodec', enum: VideoCodec }) + targetVideoCodec!: VideoCodec; - @IsString() - targetAudioCodec!: string; + @IsEnum(AudioCodec) + @ApiProperty({ enumName: 'AudioCodec', enum: AudioCodec }) + targetAudioCodec!: AudioCodec; @IsString() targetResolution!: string; @@ -35,6 +37,7 @@ export class SystemConfigFFmpegDto { @IsBoolean() twoPass!: boolean; - @IsEnum(TranscodePreset) - transcode!: TranscodePreset; + @IsEnum(TranscodePolicy) + @ApiProperty({ enumName: 'TranscodePolicy', enum: TranscodePolicy }) + transcode!: TranscodePolicy; } diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts index dcec26690..0c440835c 100644 --- a/server/src/domain/system-config/system-config.core.ts +++ b/server/src/domain/system-config/system-config.core.ts @@ -1,9 +1,11 @@ import { + AudioCodec, SystemConfig, SystemConfigEntity, SystemConfigKey, SystemConfigValue, - TranscodePreset, + TranscodePolicy, + VideoCodec, } from '@app/infra/entities'; import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import * as _ from 'lodash'; @@ -19,12 +21,12 @@ const defaults = Object.freeze({ crf: 23, threads: 0, preset: 'ultrafast', - targetVideoCodec: 'h264', - targetAudioCodec: 'aac', + targetVideoCodec: VideoCodec.H264, + targetAudioCodec: AudioCodec.AAC, targetResolution: '720', maxBitrate: '0', twoPass: false, - transcode: TranscodePreset.REQUIRED, + transcode: TranscodePolicy.REQUIRED, }, job: { [QueueName.BACKGROUND_TASK]: { concurrency: 5 }, diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index e18eb296e..54018df79 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -1,4 +1,11 @@ -import { SystemConfig, SystemConfigEntity, SystemConfigKey, TranscodePreset } from '@app/infra/entities'; +import { + AudioCodec, + SystemConfig, + SystemConfigEntity, + SystemConfigKey, + TranscodePolicy, + VideoCodec, +} from '@app/infra/entities'; import { BadRequestException } from '@nestjs/common'; import { newJobRepositoryMock, newSystemConfigRepositoryMock, systemConfigStub } from '@test'; import { IJobRepository, JobName, QueueName } from '../job'; @@ -28,12 +35,12 @@ const updatedConfig = Object.freeze({ crf: 30, threads: 0, preset: 'ultrafast', - targetAudioCodec: 'aac', + targetAudioCodec: AudioCodec.AAC, targetResolution: '720', - targetVideoCodec: 'h264', + targetVideoCodec: VideoCodec.H264, maxBitrate: '0', twoPass: false, - transcode: TranscodePreset.REQUIRED, + transcode: TranscodePolicy.REQUIRED, }, oauth: { autoLaunch: true, diff --git a/server/src/infra/entities/system-config.entity.ts b/server/src/infra/entities/system-config.entity.ts index 8d2a5c5b5..804654613 100644 --- a/server/src/infra/entities/system-config.entity.ts +++ b/server/src/infra/entities/system-config.entity.ts @@ -51,24 +51,36 @@ export enum SystemConfigKey { STORAGE_TEMPLATE = 'storageTemplate.template', } -export enum TranscodePreset { +export enum TranscodePolicy { ALL = 'all', OPTIMAL = 'optimal', REQUIRED = 'required', DISABLED = 'disabled', } +export enum VideoCodec { + H264 = 'h264', + HEVC = 'hevc', + VP9 = 'vp9', +} + +export enum AudioCodec { + MP3 = 'mp3', + AAC = 'aac', + OPUS = 'opus', +} + export interface SystemConfig { ffmpeg: { crf: number; threads: number; preset: string; - targetVideoCodec: string; - targetAudioCodec: string; + targetVideoCodec: VideoCodec; + targetAudioCodec: AudioCodec; targetResolution: string; maxBitrate: string; twoPass: boolean; - transcode: TranscodePreset; + transcode: TranscodePolicy; }; job: Record; oauth: { diff --git a/server/src/infra/infra.module.ts b/server/src/infra/infra.module.ts index 20bac55d5..060c64ae3 100644 --- a/server/src/infra/infra.module.ts +++ b/server/src/infra/infra.module.ts @@ -65,7 +65,6 @@ const providers: Provider[] = [ { provide: IJobRepository, useClass: JobRepository }, { provide: IKeyRepository, useClass: APIKeyRepository }, { provide: IMachineLearningRepository, useClass: MachineLearningRepository }, - { provide: IMediaRepository, useClass: MediaRepository }, { provide: IPartnerRepository, useClass: PartnerRepository }, { provide: IPersonRepository, useClass: PersonRepository }, { provide: ISearchRepository, useClass: TypesenseRepository }, @@ -74,6 +73,7 @@ const providers: Provider[] = [ { provide: IStorageRepository, useClass: FilesystemProvider }, { provide: ISystemConfigRepository, useClass: SystemConfigRepository }, { provide: ITagRepository, useClass: TagRepository }, + { provide: IMediaRepository, useClass: MediaRepository }, { provide: IUserRepository, useClass: UserRepository }, { provide: IUserTokenRepository, useClass: UserTokenRepository }, ]; diff --git a/server/src/infra/repositories/media.repository.ts b/server/src/infra/repositories/media.repository.ts index b73b61aae..4b0345faa 100644 --- a/server/src/infra/repositories/media.repository.ts +++ b/server/src/infra/repositories/media.repository.ts @@ -1,4 +1,5 @@ import { CropOptions, IMediaRepository, ResizeOptions, TranscodeOptions, VideoInfo } from '@app/domain'; +import { Logger } from '@nestjs/common'; import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'; import fs from 'fs/promises'; import sharp from 'sharp'; @@ -7,6 +8,8 @@ import { promisify } from 'util'; const probe = promisify(ffmpeg.ffprobe); export class MediaRepository implements IMediaRepository { + private logger = new Logger(MediaRepository.name); + crop(input: string, options: CropOptions): Promise { return sharp(input, { failOnError: false }) .extract({ @@ -47,7 +50,10 @@ export class MediaRepository implements IMediaRepository { `-vf scale='min(${size},iw)':'min(${size},ih)':force_original_aspect_ratio=increase`, ]) .output(output) - .on('error', reject) + .on('error', (err, stdout, stderr) => { + this.logger.error(stderr); + reject(err); + }) .on('end', resolve) .run(); }); @@ -87,7 +93,10 @@ export class MediaRepository implements IMediaRepository { ffmpeg(input, { niceness: 10 }) .outputOptions(options.outputOptions) .output(output) - .on('error', reject) + .on('error', (err, stdout, stderr) => { + this.logger.error(stderr); + reject(err); + }) .on('end', resolve) .run(); }); @@ -102,7 +111,10 @@ export class MediaRepository implements IMediaRepository { .addOptions('-passlogfile', output) .addOptions('-f null') .output('/dev/null') // first pass output is not saved as only the .log file is needed - .on('error', reject) + .on('error', (err, stdout, stderr) => { + this.logger.error(stderr); + reject(err); + }) .on('end', () => { // second pass ffmpeg(input, { niceness: 10 }) @@ -110,7 +122,10 @@ export class MediaRepository implements IMediaRepository { .addOptions('-pass', '2') .addOptions('-passlogfile', output) .output(output) - .on('error', reject) + .on('error', (err, stdout, stderr) => { + this.logger.error(stderr); + reject(err); + }) .on('end', () => fs.unlink(`${output}-0.log`)) .on('end', () => fs.rm(`${output}-0.log.mbtree`, { force: true })) .on('end', resolve) diff --git a/server/src/microservices/app.service.ts b/server/src/microservices/app.service.ts index 079fd40d3..a8f30e188 100644 --- a/server/src/microservices/app.service.ts +++ b/server/src/microservices/app.service.ts @@ -60,7 +60,7 @@ export class AppService { [JobName.SYSTEM_CONFIG_CHANGE]: () => this.systemConfigService.refreshConfig(), [JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data), [JobName.GENERATE_JPEG_THUMBNAIL]: (data) => this.mediaService.handleGenerateJpegThumbnail(data), - [JobName.GENERATE_WEBP_THUMBNAIL]: (data) => this.mediaService.handleGenerateWepbThumbnail(data), + [JobName.GENERATE_WEBP_THUMBNAIL]: (data) => this.mediaService.handleGenerateWebpThumbnail(data), [JobName.GENERATE_THUMBHASH_THUMBNAIL]: (data) => this.mediaService.handleGenerateThumbhashThumbnail(data), [JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data), [JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data), diff --git a/server/test/fixtures.ts b/server/test/fixtures.ts index f1adb8a76..72a05a328 100644 --- a/server/test/fixtures.ts +++ b/server/test/fixtures.ts @@ -19,6 +19,7 @@ import { AssetEntity, AssetFaceEntity, AssetType, + AudioCodec, ExifEntity, PartnerEntity, PersonEntity, @@ -27,9 +28,10 @@ import { SystemConfig, TagEntity, TagType, - TranscodePreset, + TranscodePolicy, UserEntity, UserTokenEntity, + VideoCodec, } from '@app/infra/entities'; const today = new Date(); @@ -685,12 +687,12 @@ export const systemConfigStub = { crf: 23, threads: 0, preset: 'ultrafast', - targetAudioCodec: 'aac', + targetAudioCodec: AudioCodec.AAC, targetResolution: '720', - targetVideoCodec: 'h264', + targetVideoCodec: VideoCodec.H264, maxBitrate: '0', twoPass: false, - transcode: TranscodePreset.REQUIRED, + transcode: TranscodePolicy.REQUIRED, }, job: { [QueueName.BACKGROUND_TASK]: { concurrency: 5 }, diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index c1a8f7f22..1292c7481 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -746,6 +746,21 @@ export const AssetTypeEnum = { export type AssetTypeEnum = typeof AssetTypeEnum[keyof typeof AssetTypeEnum]; +/** + * + * @export + * @enum {string} + */ + +export const AudioCodec = { + Mp3: 'mp3', + Aac: 'aac', + Opus: 'opus' +} as const; + +export type AudioCodec = typeof AudioCodec[keyof typeof AudioCodec]; + + /** * * @export @@ -2411,24 +2426,30 @@ export interface SystemConfigFFmpegDto { * @memberof SystemConfigFFmpegDto */ 'threads': number; + /** + * + * @type {VideoCodec} + * @memberof SystemConfigFFmpegDto + */ + 'targetVideoCodec': VideoCodec; + /** + * + * @type {AudioCodec} + * @memberof SystemConfigFFmpegDto + */ + 'targetAudioCodec': AudioCodec; + /** + * + * @type {TranscodePolicy} + * @memberof SystemConfigFFmpegDto + */ + 'transcode': TranscodePolicy; /** * * @type {string} * @memberof SystemConfigFFmpegDto */ 'preset': string; - /** - * - * @type {string} - * @memberof SystemConfigFFmpegDto - */ - 'targetVideoCodec': string; - /** - * - * @type {string} - * @memberof SystemConfigFFmpegDto - */ - 'targetAudioCodec': string; /** * * @type {string} @@ -2447,22 +2468,8 @@ export interface SystemConfigFFmpegDto { * @memberof SystemConfigFFmpegDto */ 'twoPass': boolean; - /** - * - * @type {string} - * @memberof SystemConfigFFmpegDto - */ - 'transcode': SystemConfigFFmpegDtoTranscodeEnum; } -export const SystemConfigFFmpegDtoTranscodeEnum = { - All: 'all', - Optimal: 'optimal', - Required: 'required', - Disabled: 'disabled' -} as const; - -export type SystemConfigFFmpegDtoTranscodeEnum = typeof SystemConfigFFmpegDtoTranscodeEnum[keyof typeof SystemConfigFFmpegDtoTranscodeEnum]; /** * @@ -2749,6 +2756,22 @@ export const TimeGroupEnum = { export type TimeGroupEnum = typeof TimeGroupEnum[keyof typeof TimeGroupEnum]; +/** + * + * @export + * @enum {string} + */ + +export const TranscodePolicy = { + All: 'all', + Optimal: 'optimal', + Required: 'required', + Disabled: 'disabled' +} as const; + +export type TranscodePolicy = typeof TranscodePolicy[keyof typeof TranscodePolicy]; + + /** * * @export @@ -3027,6 +3050,21 @@ export interface ValidateAccessTokenResponseDto { */ 'authStatus': boolean; } +/** + * + * @export + * @enum {string} + */ + +export const VideoCodec = { + H264: 'h264', + Hevc: 'hevc', + Vp9: 'vp9' +} as const; + +export type VideoCodec = typeof VideoCodec[keyof typeof VideoCodec]; + + /** * APIKeyApi - axios parameter creator diff --git a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte index 0b3d3b981..6112419c0 100644 --- a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte +++ b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte @@ -3,7 +3,7 @@ notificationController, NotificationType, } from '$lib/components/shared-components/notification/notification'; - import { api, SystemConfigFFmpegDto, SystemConfigFFmpegDtoTranscodeEnum } from '@api'; + import { api, AudioCodec, SystemConfigFFmpegDto, TranscodePolicy, VideoCodec } from '@api'; import SettingButtonsRow from '../setting-buttons-row.svelte'; import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte'; import SettingSelect from '../setting-select.svelte'; @@ -113,9 +113,9 @@ desc="Opus is the highest quality option, but has lower compatibility with old devices or software." bind:value={ffmpegConfig.targetAudioCodec} options={[ - { value: 'aac', text: 'aac' }, - { value: 'mp3', text: 'mp3' }, - { value: 'opus', text: 'opus' }, + { value: AudioCodec.Aac, text: 'aac' }, + { value: AudioCodec.Mp3, text: 'mp3' }, + { value: AudioCodec.Opus, text: 'opus' }, ]} name="acodec" isEdited={!(ffmpegConfig.targetAudioCodec == savedConfig.targetAudioCodec)} @@ -126,9 +126,9 @@ desc="VP9 has high efficiency and web compatibility, but takes longer to transcode. HEVC performs similarly, but has lower web compatibility. H.264 is widely compatible and quick to transcode, but produces much larger files." bind:value={ffmpegConfig.targetVideoCodec} options={[ - { value: 'h264', text: 'h264' }, - { value: 'hevc', text: 'hevc' }, - { value: 'vp9', text: 'vp9' }, + { value: VideoCodec.H264, text: 'h264' }, + { value: VideoCodec.Hevc, text: 'hevc' }, + { value: VideoCodec.Vp9, text: 'vp9' }, ]} name="vcodec" isEdited={!(ffmpegConfig.targetVideoCodec == savedConfig.targetVideoCodec)} @@ -167,22 +167,22 @@ /> Date: Sun, 9 Jul 2023 00:37:40 -0400 Subject: [PATCH 09/38] refactor(server): upload config (#3148) --- server/src/domain/access/access.core.ts | 9 +- server/src/domain/asset/asset.service.ts | 18 +- server/src/domain/crypto/crypto.repository.ts | 1 + server/src/domain/domain.constant.spec.ts | 21 -- server/src/domain/domain.constant.ts | 21 +- .../user/dto/create-profile-image.dto.ts | 4 +- .../immich/api-v1/asset/asset.controller.ts | 18 +- server/src/immich/api-v1/asset/asset.core.ts | 4 +- .../src/immich/api-v1/asset/asset.module.ts | 13 - .../immich/api-v1/asset/asset.service.spec.ts | 157 ++++++++++++- .../src/immich/api-v1/asset/asset.service.ts | 86 ++++++- .../api-v1/asset/dto/create-asset.dto.ts | 29 +-- .../validation/file-not-empty-validator.ts | 8 +- server/src/immich/app.interceptor.ts | 168 +++++++++++++ server/src/immich/app.module.ts | 13 +- server/src/immich/app.utils.ts | 4 - .../immich/config/asset-upload.config.spec.ts | 222 ------------------ .../src/immich/config/asset-upload.config.ts | 109 --------- .../profile-image-upload.config.spec.ts | 115 --------- .../config/profile-image-upload.config.ts | 61 ----- .../src/immich/controllers/user.controller.ts | 12 +- .../infra/repositories/crypto.repository.ts | 3 +- .../repositories/crypto.repository.mock.ts | 1 + 23 files changed, 473 insertions(+), 624 deletions(-) delete mode 100644 server/src/domain/domain.constant.spec.ts delete mode 100644 server/src/immich/api-v1/asset/asset.module.ts create mode 100644 server/src/immich/app.interceptor.ts delete mode 100644 server/src/immich/config/asset-upload.config.spec.ts delete mode 100644 server/src/immich/config/asset-upload.config.ts delete mode 100644 server/src/immich/config/profile-image-upload.config.spec.ts delete mode 100644 server/src/immich/config/profile-image-upload.config.ts diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index e4a2ed447..7ecaf97e1 100644 --- a/server/src/domain/access/access.core.ts +++ b/server/src/domain/access/access.core.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { AuthUserDto } from '../auth'; import { IAccessRepository } from './access.repository'; @@ -25,6 +25,13 @@ export enum Permission { export class AccessCore { constructor(private repository: IAccessRepository) {} + requireUploadAccess(authUser: AuthUserDto | null): AuthUserDto { + if (!authUser || (authUser.isPublicUser && !authUser.isAllowUpload)) { + throw new UnauthorizedException(); + } + return authUser; + } + async requirePermission(authUser: AuthUserDto, permission: Permission, ids: string[] | string) { const hasAccess = await this.hasPermission(authUser, permission, ids); if (!hasAccess) { diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 901b21a14..10e1718c6 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -1,10 +1,10 @@ +import { AssetEntity } from '@app/infra/entities'; import { BadRequestException, Inject } from '@nestjs/common'; import { DateTime } from 'luxon'; import { extname } from 'path'; -import { AssetEntity } from '../../infra/entities/asset.entity'; +import { AccessCore, IAccessRepository, Permission } from '../access'; import { AuthUserDto } from '../auth'; import { HumanReadableSize, usePagination } from '../domain.util'; -import { AccessCore, IAccessRepository, Permission } from '../index'; import { ImmichReadStream, IStorageRepository } from '../storage'; import { IAssetRepository } from './asset.repository'; import { AssetIdsDto, DownloadArchiveInfo, DownloadDto, DownloadResponseDto, MemoryLaneDto } from './dto'; @@ -12,6 +12,20 @@ import { MapMarkerDto } from './dto/map-marker.dto'; import { mapAsset, MapMarkerResponseDto } from './response-dto'; import { MemoryLaneResponseDto } from './response-dto/memory-lane-response.dto'; +export enum UploadFieldName { + ASSET_DATA = 'assetData', + LIVE_PHOTO_DATA = 'livePhotoData', + SIDECAR_DATA = 'sidecarData', + PROFILE_DATA = 'file', +} + +export interface UploadFile { + mimeType: string; + checksum: Buffer; + originalPath: string; + originalName: string; +} + export class AssetService { private access: AccessCore; diff --git a/server/src/domain/crypto/crypto.repository.ts b/server/src/domain/crypto/crypto.repository.ts index 67bacfb1e..c27b6d863 100644 --- a/server/src/domain/crypto/crypto.repository.ts +++ b/server/src/domain/crypto/crypto.repository.ts @@ -2,6 +2,7 @@ export const ICryptoRepository = 'ICryptoRepository'; export interface ICryptoRepository { randomBytes(size: number): Buffer; + randomUUID(): string; hashFile(filePath: string): Promise; hashSha256(data: string): string; hashBcrypt(data: string | Buffer, saltOrRounds: string | number): Promise; diff --git a/server/src/domain/domain.constant.spec.ts b/server/src/domain/domain.constant.spec.ts deleted file mode 100644 index 25b3e781b..000000000 --- a/server/src/domain/domain.constant.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { validMimeTypes } from './domain.constant'; - -describe('valid mime types', () => { - it('should be a sorted list', () => { - expect(validMimeTypes).toEqual(validMimeTypes.sort()); - }); - - it('should contain only unique values', () => { - expect(validMimeTypes).toEqual([...new Set(validMimeTypes)]); - }); - - it('should contain only image or video mime types', () => { - expect(validMimeTypes).toEqual( - validMimeTypes.filter((mimeType) => mimeType.startsWith('image/') || mimeType.startsWith('video/')), - ); - }); - - it('should contain only lowercase mime types', () => { - expect(validMimeTypes).toEqual(validMimeTypes.map((mimeType) => mimeType.toLowerCase())); - }); -}); diff --git a/server/src/domain/domain.constant.ts b/server/src/domain/domain.constant.ts index 9b976faa4..fd04381a0 100644 --- a/server/src/domain/domain.constant.ts +++ b/server/src/domain/domain.constant.ts @@ -28,7 +28,7 @@ export function assertMachineLearningEnabled() { } } -export const validMimeTypes = [ +export const ASSET_MIME_TYPES = [ 'image/3fr', 'image/ari', 'image/arw', @@ -106,11 +106,14 @@ export const validMimeTypes = [ 'video/x-ms-wmv', 'video/x-msvideo', ]; - -export function isSupportedFileType(mimetype: string): boolean { - return validMimeTypes.includes(mimetype); -} - -export function isSidecarFileType(mimeType: string): boolean { - return ['application/xml', 'text/xml'].includes(mimeType); -} +export const LIVE_PHOTO_MIME_TYPES = ASSET_MIME_TYPES; +export const SIDECAR_MIME_TYPES = ['application/xml', 'text/xml']; +export const PROFILE_MIME_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/heic', + 'image/heif', + 'image/dng', + 'image/webp', + 'image/avif', +]; diff --git a/server/src/domain/user/dto/create-profile-image.dto.ts b/server/src/domain/user/dto/create-profile-image.dto.ts index 7b58ba5aa..c7a1dc68b 100644 --- a/server/src/domain/user/dto/create-profile-image.dto.ts +++ b/server/src/domain/user/dto/create-profile-image.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Express } from 'express'; +import { UploadFieldName } from '../../asset/asset.service'; export class CreateProfileImageDto { @ApiProperty({ type: 'string', format: 'binary' }) - file!: Express.Multer.File; + [UploadFieldName.PROFILE_DATA]!: Express.Multer.File; } diff --git a/server/src/immich/api-v1/asset/asset.controller.ts b/server/src/immich/api-v1/asset/asset.controller.ts index 99f6d02ab..4a738f37e 100644 --- a/server/src/immich/api-v1/asset/asset.controller.ts +++ b/server/src/immich/api-v1/asset/asset.controller.ts @@ -18,11 +18,10 @@ import { UseInterceptors, ValidationPipe, } from '@nestjs/common'; -import { FileFieldsInterceptor } from '@nestjs/platform-express'; import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { Response as Res } from 'express'; import { Authenticated, AuthUser, SharedLinkRoute } from '../../app.guard'; -import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config'; +import { FileUploadInterceptor, ImmichFile, mapToUploadFile, Route } from '../../app.interceptor'; import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto'; import FileNotEmptyValidator from '../validation/file-not-empty-validator'; import { AssetService } from './asset.service'; @@ -30,7 +29,7 @@ import { AssetBulkUploadCheckDto } from './dto/asset-check.dto'; import { AssetSearchDto } from './dto/asset-search.dto'; import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; -import { CreateAssetDto, ImportAssetDto, mapToUploadFile } from './dto/create-asset.dto'; +import { CreateAssetDto, ImportAssetDto } from './dto/create-asset.dto'; import { DeleteAssetDto } from './dto/delete-asset.dto'; import { DeviceIdDto } from './dto/device-id.dto'; import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto'; @@ -56,23 +55,14 @@ interface UploadFiles { } @ApiTags('Asset') -@Controller('asset') +@Controller(Route.ASSET) @Authenticated() export class AssetController { constructor(private assetService: AssetService) {} @SharedLinkRoute() @Post('upload') - @UseInterceptors( - FileFieldsInterceptor( - [ - { name: 'assetData', maxCount: 1 }, - { name: 'livePhotoData', maxCount: 1 }, - { name: 'sidecarData', maxCount: 1 }, - ], - assetUploadOption, - ), - ) + @UseInterceptors(FileUploadInterceptor) @ApiConsumes('multipart/form-data') @ApiBody({ description: 'Asset Upload Information', diff --git a/server/src/immich/api-v1/asset/asset.core.ts b/server/src/immich/api-v1/asset/asset.core.ts index dd753b965..c05d58dc0 100644 --- a/server/src/immich/api-v1/asset/asset.core.ts +++ b/server/src/immich/api-v1/asset/asset.core.ts @@ -1,8 +1,8 @@ -import { AuthUserDto, IJobRepository, JobName } from '@app/domain'; +import { AuthUserDto, IJobRepository, JobName, UploadFile } from '@app/domain'; import { AssetEntity, UserEntity } from '@app/infra/entities'; import { parse } from 'node:path'; import { IAssetRepository } from './asset-repository'; -import { CreateAssetDto, ImportAssetDto, UploadFile } from './dto/create-asset.dto'; +import { CreateAssetDto, ImportAssetDto } from './dto/create-asset.dto'; export class AssetCore { constructor(private repository: IAssetRepository, private jobRepository: IJobRepository) {} diff --git a/server/src/immich/api-v1/asset/asset.module.ts b/server/src/immich/api-v1/asset/asset.module.ts deleted file mode 100644 index 2d9cdd4fe..000000000 --- a/server/src/immich/api-v1/asset/asset.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { AssetEntity, ExifEntity } from '@app/infra/entities'; -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { AssetRepository, IAssetRepository } from './asset-repository'; -import { AssetController } from './asset.controller'; -import { AssetService } from './asset.service'; - -@Module({ - imports: [TypeOrmModule.forFeature([AssetEntity, ExifEntity])], - controllers: [AssetController], - providers: [AssetService, { provide: IAssetRepository, useClass: AssetRepository }], -}) -export class AssetModule {} diff --git a/server/src/immich/api-v1/asset/asset.service.spec.ts b/server/src/immich/api-v1/asset/asset.service.spec.ts index 5017d5f36..2135bf27a 100644 --- a/server/src/immich/api-v1/asset/asset.service.spec.ts +++ b/server/src/immich/api-v1/asset/asset.service.spec.ts @@ -1,6 +1,16 @@ -import { ICryptoRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain'; +import { + ASSET_MIME_TYPES, + ICryptoRepository, + IJobRepository, + IStorageRepository, + JobName, + LIVE_PHOTO_MIME_TYPES, + PROFILE_MIME_TYPES, + SIDECAR_MIME_TYPES, + UploadFieldName, +} from '@app/domain'; import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { assetEntityStub, authStub, @@ -117,6 +127,43 @@ const _getArchivedAssetsCountByUserId = (): AssetCountByUserIdResponseDto => { return result; }; +const uploadFile = { + nullAuth: { + authUser: null, + fieldName: UploadFieldName.ASSET_DATA, + file: { + mimeType: 'image/jpeg', + checksum: Buffer.from('checksum', 'utf8'), + originalPath: 'upload/admin/image.jpeg', + originalName: 'image.jpeg', + }, + }, + mimeType: (fieldName: UploadFieldName, mimeType: string) => { + return { + authUser: authStub.admin, + fieldName, + file: { + mimeType, + checksum: Buffer.from('checksum', 'utf8'), + originalPath: 'upload/admin/image.jpeg', + originalName: 'image.jpeg', + }, + }; + }, + filename: (fieldName: UploadFieldName, filename: string) => { + return { + authUser: authStub.admin, + fieldName, + file: { + mimeType: 'image/jpeg', + checksum: Buffer.from('checksum', 'utf8'), + originalPath: `upload/admin/${filename}`, + originalName: filename, + }, + }; + }, +}; + describe('AssetService', () => { let sut: AssetService; let a: Repository; // TO BE DELETED AFTER FINISHED REFACTORING @@ -165,6 +212,112 @@ describe('AssetService', () => { .mockResolvedValue(assetEntityStub.livePhotoMotionAsset); }); + const tests = [ + { label: 'asset', fieldName: UploadFieldName.ASSET_DATA, mimeTypes: ASSET_MIME_TYPES }, + { label: 'live photo', fieldName: UploadFieldName.LIVE_PHOTO_DATA, mimeTypes: LIVE_PHOTO_MIME_TYPES }, + { label: 'sidecar', fieldName: UploadFieldName.SIDECAR_DATA, mimeTypes: SIDECAR_MIME_TYPES }, + { label: 'profile', fieldName: UploadFieldName.PROFILE_DATA, mimeTypes: PROFILE_MIME_TYPES }, + ]; + + for (const { label, fieldName, mimeTypes } of tests) { + describe(`${label} mime types linting`, () => { + it('should be a sorted list', () => { + expect(mimeTypes).toEqual(mimeTypes.sort()); + }); + + it('should contain only unique values', () => { + expect(mimeTypes).toEqual([...new Set(mimeTypes)]); + }); + + if (fieldName !== UploadFieldName.SIDECAR_DATA) { + it('should contain only image or video mime types', () => { + expect(mimeTypes).toEqual( + mimeTypes.filter((mimeType) => mimeType.startsWith('image/') || mimeType.startsWith('video/')), + ); + }); + } + + it('should contain only lowercase mime types', () => { + expect(mimeTypes).toEqual(mimeTypes.map((mimeType) => mimeType.toLowerCase())); + }); + }); + } + + describe('canUpload', () => { + it('should require an authenticated user', () => { + expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException); + }); + + it('should accept all accepted mime types', () => { + for (const { fieldName, mimeTypes } of tests) { + for (const mimeType of mimeTypes) { + expect(sut.canUploadFile(uploadFile.mimeType(fieldName, mimeType))).toEqual(true); + } + } + }); + + it('should reject other mime types', () => { + for (const { fieldName, mimeType } of [ + { fieldName: UploadFieldName.ASSET_DATA, mimeType: 'application/html' }, + { fieldName: UploadFieldName.LIVE_PHOTO_DATA, mimeType: 'application/html' }, + { fieldName: UploadFieldName.PROFILE_DATA, mimeType: 'application/html' }, + { fieldName: UploadFieldName.SIDECAR_DATA, mimeType: 'image/jpeg' }, + ]) { + expect(() => sut.canUploadFile(uploadFile.mimeType(fieldName, mimeType))).toThrowError(BadRequestException); + } + }); + }); + + describe('getUploadFilename', () => { + it('should require authentication', () => { + expect(() => sut.getUploadFilename(uploadFile.nullAuth)).toThrowError(UnauthorizedException); + }); + + it('should be the original extension for asset upload', () => { + expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual( + 'random-uuid.jpg', + ); + }); + + it('should be the mov extension for live photo upload', () => { + expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.LIVE_PHOTO_DATA, 'image.mp4'))).toEqual( + 'random-uuid.mov', + ); + }); + + it('should be the xmp extension for sidecar upload', () => { + expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.SIDECAR_DATA, 'image.html'))).toEqual( + 'random-uuid.xmp', + ); + }); + + it('should be the original extension for profile upload', () => { + expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual( + 'random-uuid.jpg', + ); + }); + }); + + describe('getUploadFolder', () => { + it('should require authentication', () => { + expect(() => sut.getUploadFolder(uploadFile.nullAuth)).toThrowError(UnauthorizedException); + }); + + it('should return profile for profile uploads', () => { + expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual( + 'upload/profile/admin_id', + ); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id'); + }); + + it('should return upload for everything else', () => { + expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual( + 'upload/upload/admin_id', + ); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id'); + }); + }); + describe('uploadFile', () => { it('should handle a file upload', async () => { const assetEntity = _getAsset_1(); diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index 26c0ca7bb..fdf1c5a3b 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -1,17 +1,24 @@ import { AccessCore, AssetResponseDto, + ASSET_MIME_TYPES, AuthUserDto, getLivePhotoMotionFilename, IAccessRepository, ICryptoRepository, IJobRepository, - isSupportedFileType, IStorageRepository, JobName, + LIVE_PHOTO_MIME_TYPES, mapAsset, mapAssetWithoutExif, Permission, + PROFILE_MIME_TYPES, + SIDECAR_MIME_TYPES, + StorageCore, + StorageFolder, + UploadFieldName, + UploadFile, } from '@app/domain'; import { AssetEntity, AssetType } from '@app/infra/entities'; import { @@ -27,16 +34,18 @@ import { Response as Res } from 'express'; import { constants, createReadStream } from 'fs'; import fs from 'fs/promises'; import mime from 'mime-types'; -import path from 'path'; +import path, { extname } from 'path'; +import sanitize from 'sanitize-filename'; import { pipeline } from 'stream/promises'; import { QueryFailedError, Repository } from 'typeorm'; +import { UploadRequest } from '../../app.interceptor'; import { IAssetRepository } from './asset-repository'; import { AssetCore } from './asset.core'; import { AssetBulkUploadCheckDto } from './dto/asset-check.dto'; import { AssetSearchDto } from './dto/asset-search.dto'; import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; -import { CreateAssetDto, ImportAssetDto, UploadFile } from './dto/create-asset.dto'; +import { CreateAssetDto, ImportAssetDto } from './dto/create-asset.dto'; import { DeleteAssetDto } from './dto/delete-asset.dto'; import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto'; import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto'; @@ -72,6 +81,7 @@ export class AssetService { readonly logger = new Logger(AssetService.name); private assetCore: AssetCore; private access: AccessCore; + private storageCore = new StorageCore(); constructor( @Inject(IAccessRepository) accessRepository: IAccessRepository, @@ -85,6 +95,68 @@ export class AssetService { this.access = new AccessCore(accessRepository); } + canUploadFile({ authUser, fieldName, file }: UploadRequest): true { + this.access.requireUploadAccess(authUser); + + switch (fieldName) { + case UploadFieldName.ASSET_DATA: + if (ASSET_MIME_TYPES.includes(file.mimeType)) { + return true; + } + break; + + case UploadFieldName.LIVE_PHOTO_DATA: + if (LIVE_PHOTO_MIME_TYPES.includes(file.mimeType)) { + return true; + } + break; + + case UploadFieldName.SIDECAR_DATA: + if (SIDECAR_MIME_TYPES.includes(file.mimeType)) { + return true; + } + break; + + case UploadFieldName.PROFILE_DATA: + if (PROFILE_MIME_TYPES.includes(file.mimeType)) { + return true; + } + break; + } + + const ext = extname(file.originalName); + this.logger.error(`Unsupported file type ${ext} file MIME type ${file.mimeType}`); + throw new BadRequestException(`Unsupported file type ${ext}`); + } + + getUploadFilename({ authUser, fieldName, file }: UploadRequest): string { + this.access.requireUploadAccess(authUser); + + const originalExt = extname(file.originalName); + + const lookup = { + [UploadFieldName.ASSET_DATA]: originalExt, + [UploadFieldName.LIVE_PHOTO_DATA]: '.mov', + [UploadFieldName.SIDECAR_DATA]: '.xmp', + [UploadFieldName.PROFILE_DATA]: originalExt, + }; + + return sanitize(`${this.cryptoRepository.randomUUID()}${lookup[fieldName]}`); + } + + getUploadFolder({ authUser, fieldName }: UploadRequest): string { + authUser = this.access.requireUploadAccess(authUser); + + let folder = this.storageCore.getFolderLocation(StorageFolder.UPLOAD, authUser.id); + if (fieldName === UploadFieldName.PROFILE_DATA) { + folder = this.storageCore.getFolderLocation(StorageFolder.PROFILE, authUser.id); + } + + this.storageRepository.mkdirSync(folder); + + return folder; + } + public async uploadFile( authUser: AuthUserDto, dto: CreateAssetDto, @@ -136,9 +208,9 @@ export class AssetService { sidecarPath: dto.sidecarPath ? path.resolve(dto.sidecarPath) : undefined, }; - const assetPathType = mime.lookup(dto.assetPath) as string; - if (!isSupportedFileType(assetPathType)) { - throw new BadRequestException(`Unsupported file type ${assetPathType}`); + const mimeType = mime.lookup(dto.assetPath) as string; + if (!ASSET_MIME_TYPES.includes(mimeType)) { + throw new BadRequestException(`Unsupported file type ${mimeType}`); } if (dto.sidecarPath) { @@ -164,7 +236,7 @@ export class AssetService { const assetFile: UploadFile = { checksum: await this.cryptoRepository.hashFile(dto.assetPath), - mimeType: assetPathType, + mimeType, originalPath: dto.assetPath, originalName: path.parse(dto.assetPath).name, }; diff --git a/server/src/immich/api-v1/asset/dto/create-asset.dto.ts b/server/src/immich/api-v1/asset/dto/create-asset.dto.ts index 76a24ee18..590296a1e 100644 --- a/server/src/immich/api-v1/asset/dto/create-asset.dto.ts +++ b/server/src/immich/api-v1/asset/dto/create-asset.dto.ts @@ -1,9 +1,8 @@ -import { toBoolean, toSanitized } from '@app/domain'; +import { toBoolean, toSanitized, UploadFieldName } from '@app/domain'; import { AssetType } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; -import { ImmichFile } from '../../../config/asset-upload.config'; export class CreateAssetBase { @IsNotEmpty() @@ -50,13 +49,13 @@ export class CreateAssetDto extends CreateAssetBase { // The properties below are added to correctly generate the API docs // and client SDKs. Validation should be handled in the controller. @ApiProperty({ type: 'string', format: 'binary' }) - assetData!: any; + [UploadFieldName.ASSET_DATA]!: any; - @ApiProperty({ type: 'string', format: 'binary' }) - livePhotoData?: any; + @ApiProperty({ type: 'string', format: 'binary', required: false }) + [UploadFieldName.LIVE_PHOTO_DATA]?: any; - @ApiProperty({ type: 'string', format: 'binary' }) - sidecarData?: any; + @ApiProperty({ type: 'string', format: 'binary', required: false }) + [UploadFieldName.SIDECAR_DATA]?: any; } export class ImportAssetDto extends CreateAssetBase { @@ -75,19 +74,3 @@ export class ImportAssetDto extends CreateAssetBase { @Transform(toSanitized) sidecarPath?: string; } - -export interface UploadFile { - mimeType: string; - checksum: Buffer; - originalPath: string; - originalName: string; -} - -export function mapToUploadFile(file: ImmichFile): UploadFile { - return { - checksum: file.checksum, - mimeType: file.mimetype, - originalPath: file.path, - originalName: file.originalname, - }; -} diff --git a/server/src/immich/api-v1/validation/file-not-empty-validator.ts b/server/src/immich/api-v1/validation/file-not-empty-validator.ts index f75899eec..21f93a952 100644 --- a/server/src/immich/api-v1/validation/file-not-empty-validator.ts +++ b/server/src/immich/api-v1/validation/file-not-empty-validator.ts @@ -2,9 +2,7 @@ import { FileValidator, Injectable } from '@nestjs/common'; @Injectable() export default class FileNotEmptyValidator extends FileValidator { - requiredFields: string[]; - - constructor(requiredFields: string[]) { + constructor(private requiredFields: string[]) { super({}); this.requiredFields = requiredFields; } @@ -14,9 +12,7 @@ export default class FileNotEmptyValidator extends FileValidator { return false; } - return this.requiredFields.every((field) => { - return files[field]; - }); + return this.requiredFields.every((field) => files[field]); } buildErrorMessage(): string { diff --git a/server/src/immich/app.interceptor.ts b/server/src/immich/app.interceptor.ts new file mode 100644 index 000000000..7a43ddefe --- /dev/null +++ b/server/src/immich/app.interceptor.ts @@ -0,0 +1,168 @@ +import { AuthUserDto, UploadFieldName, UploadFile } from '@app/domain'; +import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common'; +import { PATH_METADATA } from '@nestjs/common/constants'; +import { Reflector } from '@nestjs/core'; +import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils'; +import { createHash } from 'crypto'; +import { NextFunction, RequestHandler } from 'express'; +import multer, { diskStorage, StorageEngine } from 'multer'; +import { Observable } from 'rxjs'; +import { AssetService } from './api-v1/asset/asset.service'; +import { AuthRequest } from './app.guard'; + +export enum Route { + ASSET = 'asset', + USER = 'user', +} + +export interface ImmichFile extends Express.Multer.File { + /** sha1 hash of file */ + checksum: Buffer; +} + +export function mapToUploadFile(file: ImmichFile): UploadFile { + return { + checksum: file.checksum, + mimeType: file.mimetype, + originalPath: file.path, + originalName: Buffer.from(file.originalname, 'latin1').toString('utf8'), + }; +} + +type DiskStorageCallback = (error: Error | null, result: string) => void; + +interface Callback { + (error: Error): void; + (error: null, result: T): void; +} + +const callbackify = async (fn: (...args: any[]) => T, callback: Callback) => { + try { + return callback(null, await fn()); + } catch (error: Error | any) { + return callback(error); + } +}; + +export interface UploadRequest { + authUser: AuthUserDto | null; + fieldName: UploadFieldName; + file: UploadFile; +} + +const asRequest = (req: AuthRequest, file: Express.Multer.File) => { + return { + authUser: req.user || null, + fieldName: file.fieldname as UploadFieldName, + file: mapToUploadFile(file as ImmichFile), + }; +}; + +@Injectable() +export class FileUploadInterceptor implements NestInterceptor { + private logger = new Logger(FileUploadInterceptor.name); + + private handlers: { + userProfile: RequestHandler; + assetUpload: RequestHandler; + }; + private defaultStorage: StorageEngine; + + constructor(private reflect: Reflector, private assetService: AssetService) { + this.defaultStorage = diskStorage({ + filename: this.filename.bind(this), + destination: this.destination.bind(this), + }); + + const instance = multer({ + fileFilter: this.fileFilter.bind(this), + storage: { + _handleFile: this.handleFile.bind(this), + _removeFile: this.removeFile.bind(this), + }, + }); + + this.handlers = { + userProfile: instance.single(UploadFieldName.PROFILE_DATA), + assetUpload: instance.fields([ + { name: UploadFieldName.ASSET_DATA, maxCount: 1 }, + { name: UploadFieldName.LIVE_PHOTO_DATA, maxCount: 1 }, + { name: UploadFieldName.SIDECAR_DATA, maxCount: 1 }, + ]), + }; + } + + async intercept(context: ExecutionContext, next: CallHandler): Promise> { + const ctx = context.switchToHttp(); + const route = this.reflect.get(PATH_METADATA, context.getClass()); + + const handler: RequestHandler | null = this.getHandler(route as Route); + if (handler) { + await new Promise((resolve, reject) => { + const next: NextFunction = (error) => (error ? reject(transformException(error)) : resolve()); + handler(ctx.getRequest(), ctx.getResponse(), next); + }); + } else { + this.logger.warn(`Skipping invalid file upload route: ${route}`); + } + + return next.handle(); + } + + private fileFilter(req: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) { + return callbackify(() => this.assetService.canUploadFile(asRequest(req, file)), callback); + } + + private filename(req: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) { + return callbackify(() => this.assetService.getUploadFilename(asRequest(req, file)), callback as Callback); + } + + private destination(req: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) { + return callbackify(() => this.assetService.getUploadFolder(asRequest(req, file)), callback as Callback); + } + + private handleFile(req: AuthRequest, file: Express.Multer.File, callback: Callback>) { + if (!this.isAssetUploadFile(file)) { + this.defaultStorage._handleFile(req, file, callback); + return; + } + + const hash = createHash('sha1'); + file.stream.on('data', (chunk) => hash.update(chunk)); + this.defaultStorage._handleFile(req, file, (error, info) => { + if (error) { + hash.destroy(); + callback(error); + } else { + callback(null, { ...info, checksum: hash.digest() }); + } + }); + } + + private removeFile(req: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) { + this.defaultStorage._removeFile(req, file, callback); + } + + private isAssetUploadFile(file: Express.Multer.File) { + switch (file.fieldname as UploadFieldName) { + case UploadFieldName.ASSET_DATA: + case UploadFieldName.LIVE_PHOTO_DATA: + return true; + } + + return false; + } + + private getHandler(route: Route) { + switch (route) { + case Route.ASSET: + return this.handlers.assetUpload; + + case Route.USER: + return this.handlers.userProfile; + + default: + return null; + } + } +} diff --git a/server/src/immich/app.module.ts b/server/src/immich/app.module.ts index 10f30a6b2..522b2912a 100644 --- a/server/src/immich/app.module.ts +++ b/server/src/immich/app.module.ts @@ -1,11 +1,16 @@ import { DomainModule } from '@app/domain'; import { InfraModule } from '@app/infra'; +import { AssetEntity, ExifEntity } from '@app/infra/entities'; import { Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { ScheduleModule } from '@nestjs/schedule'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { AlbumModule } from './api-v1/album/album.module'; -import { AssetModule } from './api-v1/asset/asset.module'; +import { AssetRepository, IAssetRepository } from './api-v1/asset/asset-repository'; +import { AssetController as AssetControllerV1 } from './api-v1/asset/asset.controller'; +import { AssetService } from './api-v1/asset/asset.service'; import { AppGuard } from './app.guard'; +import { FileUploadInterceptor } from './app.interceptor'; import { AppService } from './app.service'; import { AlbumController, @@ -29,11 +34,12 @@ import { imports: [ // DomainModule.register({ imports: [InfraModule] }), - AssetModule, AlbumModule, ScheduleModule.forRoot(), + TypeOrmModule.forFeature([AssetEntity, ExifEntity]), ], controllers: [ + AssetControllerV1, AppController, AlbumController, APIKeyController, @@ -53,8 +59,11 @@ import { providers: [ // { provide: APP_GUARD, useExisting: AppGuard }, + { provide: IAssetRepository, useClass: AssetRepository }, AppGuard, AppService, + AssetService, + FileUploadInterceptor, ], }) export class AppModule {} diff --git a/server/src/immich/app.utils.ts b/server/src/immich/app.utils.ts index d2d1c2456..44eaa9f23 100644 --- a/server/src/immich/app.utils.ts +++ b/server/src/immich/app.utils.ts @@ -34,10 +34,6 @@ export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => return new StreamableFile(stream, { type, length }); }; -export function patchFormData(latin1: string) { - return Buffer.from(latin1, 'latin1').toString('utf8'); -} - function sortKeys(obj: T): T { if (!obj) { return obj; diff --git a/server/src/immich/config/asset-upload.config.spec.ts b/server/src/immich/config/asset-upload.config.spec.ts deleted file mode 100644 index 24e7fcb2e..000000000 --- a/server/src/immich/config/asset-upload.config.spec.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { Request } from 'express'; -import * as fs from 'fs'; -import { AuthRequest } from '../app.guard'; -import { multerUtils } from './asset-upload.config'; - -const { fileFilter, destination, filename } = multerUtils; - -const mock = { - req: {} as Request, - userRequest: { - user: { - id: 'test-user', - }, - body: { - deviceId: 'test-device', - fileExtension: '.jpg', - }, - } as AuthRequest, - file: { originalname: 'test.jpg' } as Express.Multer.File, -}; - -jest.mock('fs'); - -describe('assetUploadOption', () => { - let callback: jest.Mock; - let existsSync: jest.Mock; - let mkdirSync: jest.Mock; - - beforeEach(() => { - jest.mock('fs'); - mkdirSync = fs.mkdirSync as jest.Mock; - existsSync = fs.existsSync as jest.Mock; - callback = jest.fn(); - - existsSync.mockImplementation(() => true); - }); - - afterEach(() => { - jest.resetModules(); - }); - - describe('fileFilter', () => { - it('should require a user', () => { - fileFilter(mock.req, mock.file, callback); - - expect(callback).toHaveBeenCalled(); - const [error, name] = callback.mock.calls[0]; - expect(error).toBeDefined(); - expect(name).toBeUndefined(); - }); - - for (const { mimetype, extension } of [ - // Please ensure this list is sorted. - { mimetype: 'image/3fr', extension: '3fr' }, - { mimetype: 'image/ari', extension: 'ari' }, - { mimetype: 'image/arw', extension: 'arw' }, - { mimetype: 'image/avif', extension: 'avif' }, - { mimetype: 'image/cap', extension: 'cap' }, - { mimetype: 'image/cin', extension: 'cin' }, - { mimetype: 'image/cr2', extension: 'cr2' }, - { mimetype: 'image/cr3', extension: 'cr3' }, - { mimetype: 'image/crw', extension: 'crw' }, - { mimetype: 'image/dcr', extension: 'dcr' }, - { mimetype: 'image/dng', extension: 'dng' }, - { mimetype: 'image/erf', extension: 'erf' }, - { mimetype: 'image/fff', extension: 'fff' }, - { mimetype: 'image/gif', extension: 'gif' }, - { mimetype: 'image/heic', extension: 'heic' }, - { mimetype: 'image/heif', extension: 'heif' }, - { mimetype: 'image/iiq', extension: 'iiq' }, - { mimetype: 'image/jpeg', extension: 'jpeg' }, - { mimetype: 'image/jpeg', extension: 'jpg' }, - { mimetype: 'image/jxl', extension: 'jxl' }, - { mimetype: 'image/k25', extension: 'k25' }, - { mimetype: 'image/kdc', extension: 'kdc' }, - { mimetype: 'image/mrw', extension: 'mrw' }, - { mimetype: 'image/nef', extension: 'nef' }, - { mimetype: 'image/orf', extension: 'orf' }, - { mimetype: 'image/ori', extension: 'ori' }, - { mimetype: 'image/pef', extension: 'pef' }, - { mimetype: 'image/png', extension: 'png' }, - { mimetype: 'image/raf', extension: 'raf' }, - { mimetype: 'image/raw', extension: 'raw' }, - { mimetype: 'image/rwl', extension: 'rwl' }, - { mimetype: 'image/sr2', extension: 'sr2' }, - { mimetype: 'image/srf', extension: 'srf' }, - { mimetype: 'image/srw', extension: 'srw' }, - { mimetype: 'image/tiff', extension: 'tiff' }, - { mimetype: 'image/webp', extension: 'webp' }, - { mimetype: 'image/x-adobe-dng', extension: 'dng' }, - { mimetype: 'image/x-arriflex-ari', extension: 'ari' }, - { mimetype: 'image/x-canon-cr2', extension: 'cr2' }, - { mimetype: 'image/x-canon-cr3', extension: 'cr3' }, - { mimetype: 'image/x-canon-crw', extension: 'crw' }, - { mimetype: 'image/x-epson-erf', extension: 'erf' }, - { mimetype: 'image/x-fuji-raf', extension: 'raf' }, - { mimetype: 'image/x-hasselblad-3fr', extension: '3fr' }, - { mimetype: 'image/x-hasselblad-fff', extension: 'fff' }, - { mimetype: 'image/x-kodak-dcr', extension: 'dcr' }, - { mimetype: 'image/x-kodak-k25', extension: 'k25' }, - { mimetype: 'image/x-kodak-kdc', extension: 'kdc' }, - { mimetype: 'image/x-leica-rwl', extension: 'rwl' }, - { mimetype: 'image/x-minolta-mrw', extension: 'mrw' }, - { mimetype: 'image/x-nikon-nef', extension: 'nef' }, - { mimetype: 'image/x-olympus-orf', extension: 'orf' }, - { mimetype: 'image/x-olympus-ori', extension: 'ori' }, - { mimetype: 'image/x-panasonic-raw', extension: 'raw' }, - { mimetype: 'image/x-pentax-pef', extension: 'pef' }, - { mimetype: 'image/x-phantom-cin', extension: 'cin' }, - { mimetype: 'image/x-phaseone-cap', extension: 'cap' }, - { mimetype: 'image/x-phaseone-iiq', extension: 'iiq' }, - { mimetype: 'image/x-samsung-srw', extension: 'srw' }, - { mimetype: 'image/x-sigma-x3f', extension: 'x3f' }, - { mimetype: 'image/x-sony-arw', extension: 'arw' }, - { mimetype: 'image/x-sony-sr2', extension: 'sr2' }, - { mimetype: 'image/x-sony-srf', extension: 'srf' }, - { mimetype: 'image/x3f', extension: 'x3f' }, - { mimetype: 'video/3gpp', extension: '3gp' }, - { mimetype: 'video/avi', extension: 'avi' }, - { mimetype: 'video/mp2t', extension: 'm2ts' }, - { mimetype: 'video/mp2t', extension: 'mts' }, - { mimetype: 'video/mp4', extension: 'mp4' }, - { mimetype: 'video/mpeg', extension: 'mpg' }, - { mimetype: 'video/msvideo', extension: 'avi' }, - { mimetype: 'video/quicktime', extension: 'mov' }, - { mimetype: 'video/vnd.avi', extension: 'avi' }, - { mimetype: 'video/webm', extension: 'webm' }, - { mimetype: 'video/x-flv', extension: 'flv' }, - { mimetype: 'video/x-matroska', extension: 'mkv' }, - { mimetype: 'video/x-ms-wmv', extension: 'wmv' }, - { mimetype: 'video/x-msvideo', extension: 'avi' }, - ]) { - const name = `test.${extension}`; - it(`should allow ${name} (${mimetype})`, async () => { - fileFilter(mock.userRequest, { mimetype, originalname: name }, callback); - expect(callback).toHaveBeenCalledWith(null, true); - }); - } - - it('should not allow unknown types', async () => { - const file = { mimetype: 'application/html', originalname: 'test.html' } as any; - const callback = jest.fn(); - fileFilter(mock.userRequest, file, callback); - - expect(callback).toHaveBeenCalled(); - const [error, accepted] = callback.mock.calls[0]; - expect(error).toBeDefined(); - expect(accepted).toBe(false); - }); - }); - - describe('destination', () => { - it('should require a user', () => { - destination(mock.req, mock.file, callback); - - expect(callback).toHaveBeenCalled(); - const [error, name] = callback.mock.calls[0]; - expect(error).toBeDefined(); - expect(name).toBeUndefined(); - }); - - it('should create non-existing directories', () => { - existsSync.mockImplementation(() => false); - - destination(mock.userRequest, mock.file, callback); - - expect(existsSync).toHaveBeenCalled(); - expect(mkdirSync).toHaveBeenCalled(); - }); - - it('should return the destination', () => { - destination(mock.userRequest, mock.file, callback); - - expect(mkdirSync).not.toHaveBeenCalled(); - expect(callback).toHaveBeenCalledWith(null, 'upload/upload/test-user'); - }); - }); - - describe('filename', () => { - it('should require a user', () => { - filename(mock.req, mock.file, callback); - - expect(callback).toHaveBeenCalled(); - const [error, name] = callback.mock.calls[0]; - expect(error).toBeDefined(); - expect(name).toBeUndefined(); - }); - - it('should return the filename', () => { - filename(mock.userRequest, mock.file, callback); - - expect(callback).toHaveBeenCalled(); - const [error, name] = callback.mock.calls[0]; - expect(error).toBeNull(); - expect(name.endsWith('.jpg')).toBeTruthy(); - }); - - it('should sanitize the filename', () => { - const body = { ...mock.userRequest.body, fileExtension: '.jp\u0000g' }; - const request = { ...mock.userRequest, body } as Request; - filename(request, mock.file, callback); - - expect(callback).toHaveBeenCalled(); - const [error, name] = callback.mock.calls[0]; - expect(error).toBeNull(); - expect(name.endsWith(mock.userRequest.body.fileExtension)).toBeTruthy(); - }); - - it('should not change the casing of the extension', () => { - // Case is deliberately mixed to cover both .upper() and .lower() - const body = { ...mock.userRequest.body, fileExtension: '.JpEg' }; - const request = { ...mock.userRequest, body } as Request; - - filename(request, mock.file, callback); - - expect(callback).toHaveBeenCalled(); - const [error, name] = callback.mock.calls[0]; - expect(error).toBeNull(); - expect(name.endsWith(body.fileExtension)).toBeTruthy(); - }); - }); -}); diff --git a/server/src/immich/config/asset-upload.config.ts b/server/src/immich/config/asset-upload.config.ts deleted file mode 100644 index 9f934d934..000000000 --- a/server/src/immich/config/asset-upload.config.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { AuthUserDto, isSidecarFileType, isSupportedFileType } from '@app/domain'; -import { StorageCore, StorageFolder } from '@app/domain/storage'; -import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common'; -import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface'; -import { createHash, randomUUID } from 'crypto'; -import { existsSync, mkdirSync } from 'fs'; -import { diskStorage, StorageEngine } from 'multer'; -import { extname } from 'path'; -import sanitize from 'sanitize-filename'; -import { AuthRequest } from '../app.guard'; -import { patchFormData } from '../app.utils'; - -export interface ImmichFile extends Express.Multer.File { - /** sha1 hash of file */ - checksum: Buffer; -} - -export const assetUploadOption: MulterOptions = { - fileFilter, - storage: customStorage(), -}; - -const storageCore = new StorageCore(); - -export function customStorage(): StorageEngine { - const storage = diskStorage({ destination, filename }); - - return { - _handleFile(req, file, callback) { - const hash = createHash('sha1'); - file.stream.on('data', (chunk) => hash.update(chunk)); - - storage._handleFile(req, file, (error, response) => { - if (error) { - hash.destroy(); - callback(error); - } else { - callback(null, { ...response, checksum: hash.digest() } as ImmichFile); - } - }); - }, - - _removeFile(req, file, callback) { - storage._removeFile(req, file, callback); - }, - }; -} - -export const multerUtils = { fileFilter, filename, destination }; - -const logger = new Logger('AssetUploadConfig'); - -function fileFilter(req: AuthRequest, file: any, cb: any) { - if (!req.user || (req.user.isPublicUser && !req.user.isAllowUpload)) { - return cb(new UnauthorizedException()); - } - - if (isSupportedFileType(file.mimetype)) { - cb(null, true); - return; - } - - // Additionally support XML but only for sidecar files. - if (file.fieldname === 'sidecarData' && isSidecarFileType(file.mimetype)) { - return cb(null, true); - } - - logger.error(`Unsupported file type ${extname(file.originalname)} file MIME type ${file.mimetype}`); - cb(new BadRequestException(`Unsupported file type ${extname(file.originalname)}`), false); -} - -function destination(req: AuthRequest, file: Express.Multer.File, cb: any) { - if (!req.user || (req.user.isPublicUser && !req.user.isAllowUpload)) { - return cb(new UnauthorizedException()); - } - - const user = req.user as AuthUserDto; - - const uploadFolder = storageCore.getFolderLocation(StorageFolder.UPLOAD, user.id); - if (!existsSync(uploadFolder)) { - mkdirSync(uploadFolder, { recursive: true }); - } - - // Save original to disk - cb(null, uploadFolder); -} - -function filename(req: AuthRequest, file: Express.Multer.File, cb: any) { - if (!req.user || (req.user.isPublicUser && !req.user.isAllowUpload)) { - return cb(new UnauthorizedException()); - } - - file.originalname = patchFormData(file.originalname); - - const fileNameUUID = randomUUID(); - - if (file.fieldname === 'livePhotoData') { - const livePhotoFileName = `${fileNameUUID}.mov`; - return cb(null, sanitize(livePhotoFileName)); - } - - if (file.fieldname === 'sidecarData') { - const sidecarFileName = `${fileNameUUID}.xmp`; - return cb(null, sanitize(sidecarFileName)); - } - - const fileName = `${fileNameUUID}${req.body['fileExtension']}`; - return cb(null, sanitize(fileName)); -} diff --git a/server/src/immich/config/profile-image-upload.config.spec.ts b/server/src/immich/config/profile-image-upload.config.spec.ts deleted file mode 100644 index 06ac6814c..000000000 --- a/server/src/immich/config/profile-image-upload.config.spec.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { Request } from 'express'; -import * as fs from 'fs'; -import { AuthRequest } from '../app.guard'; -import { multerUtils } from './profile-image-upload.config'; - -const { fileFilter, destination, filename } = multerUtils; - -const mock = { - req: {} as Request, - userRequest: { - user: { - id: 'test-user', - }, - } as AuthRequest, - file: { originalname: 'test.jpg' } as Express.Multer.File, -}; - -jest.mock('fs'); - -describe('profileImageUploadOption', () => { - let callback: jest.Mock; - let existsSync: jest.Mock; - let mkdirSync: jest.Mock; - - beforeEach(() => { - jest.mock('fs'); - mkdirSync = fs.mkdirSync as jest.Mock; - existsSync = fs.existsSync as jest.Mock; - callback = jest.fn(); - - existsSync.mockImplementation(() => true); - }); - - afterEach(() => { - jest.resetModules(); - }); - - describe('fileFilter', () => { - it('should require a user', () => { - fileFilter(mock.req, mock.file, callback); - - expect(callback).toHaveBeenCalled(); - const [error, name] = callback.mock.calls[0]; - expect(error).toBeDefined(); - expect(name).toBeUndefined(); - }); - - it('should allow images', async () => { - const file = { mimetype: 'image/jpeg', originalname: 'test.jpg' } as any; - fileFilter(mock.userRequest, file, callback); - expect(callback).toHaveBeenCalledWith(null, true); - }); - - it('should not allow gifs', async () => { - const file = { mimetype: 'image/gif', originalname: 'test.gif' } as any; - const callback = jest.fn(); - fileFilter(mock.userRequest, file, callback); - - expect(callback).toHaveBeenCalled(); - const [error, accepted] = callback.mock.calls[0]; - expect(error).toBeDefined(); - expect(accepted).toBe(false); - }); - }); - - describe('destination', () => { - it('should require a user', () => { - destination(mock.req, mock.file, callback); - - expect(callback).toHaveBeenCalled(); - const [error, name] = callback.mock.calls[0]; - expect(error).toBeDefined(); - expect(name).toBeUndefined(); - }); - - it('should create non-existing directories', () => { - existsSync.mockImplementation(() => false); - - destination(mock.userRequest, mock.file, callback); - - expect(existsSync).toHaveBeenCalled(); - expect(mkdirSync).toHaveBeenCalled(); - }); - - it('should return the destination', () => { - destination(mock.userRequest, mock.file, callback); - - expect(mkdirSync).not.toHaveBeenCalled(); - expect(callback).toHaveBeenCalledWith(null, 'upload/profile/test-user'); - }); - }); - - describe('filename', () => { - it('should require a user', () => { - filename(mock.req, mock.file, callback); - - expect(callback).toHaveBeenCalled(); - const [error, name] = callback.mock.calls[0]; - expect(error).toBeDefined(); - expect(name).toBeUndefined(); - }); - - it('should return the filename', () => { - filename(mock.userRequest, mock.file, callback); - - expect(mkdirSync).not.toHaveBeenCalled(); - expect(callback).toHaveBeenCalledWith(null, 'test-user.jpg'); - }); - - it('should sanitize the filename', () => { - filename(mock.userRequest, { ...mock.file, originalname: 'test.j\u0000pg' }, callback); - expect(callback).toHaveBeenCalledWith(null, 'test-user.jpg'); - }); - }); -}); diff --git a/server/src/immich/config/profile-image-upload.config.ts b/server/src/immich/config/profile-image-upload.config.ts deleted file mode 100644 index d56ed1170..000000000 --- a/server/src/immich/config/profile-image-upload.config.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { AuthUserDto, StorageCore, StorageFolder } from '@app/domain'; -import { BadRequestException, UnauthorizedException } from '@nestjs/common'; -import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface'; -import { existsSync, mkdirSync } from 'fs'; -import { diskStorage } from 'multer'; -import { extname } from 'path'; -import sanitize from 'sanitize-filename'; -import { AuthRequest } from '../app.guard'; -import { patchFormData } from '../app.utils'; - -export const profileImageUploadOption: MulterOptions = { - fileFilter, - storage: diskStorage({ - destination, - filename, - }), -}; - -export const multerUtils = { fileFilter, filename, destination }; - -const storageCore = new StorageCore(); - -function fileFilter(req: AuthRequest, file: any, cb: any) { - if (!req.user) { - return cb(new UnauthorizedException()); - } - - if (file.mimetype.match(/\/(jpg|jpeg|png|heic|heif|dng|webp|avif)$/)) { - cb(null, true); - } else { - cb(new BadRequestException(`Unsupported file type ${extname(file.originalname)}`), false); - } -} - -function destination(req: AuthRequest, file: Express.Multer.File, cb: any) { - if (!req.user) { - return cb(new UnauthorizedException()); - } - - const user = req.user as AuthUserDto; - - const profileImageLocation = storageCore.getFolderLocation(StorageFolder.PROFILE, user.id); - if (!existsSync(profileImageLocation)) { - mkdirSync(profileImageLocation, { recursive: true }); - } - - cb(null, profileImageLocation); -} - -function filename(req: AuthRequest, file: Express.Multer.File, cb: any) { - if (!req.user) { - return cb(new UnauthorizedException()); - } - - file.originalname = patchFormData(file.originalname); - - const userId = req.user.id; - const fileName = `${userId}${extname(file.originalname)}`; - - cb(null, sanitize(String(fileName))); -} diff --git a/server/src/immich/controllers/user.controller.ts b/server/src/immich/controllers/user.controller.ts index 52b00898e..784ca0870 100644 --- a/server/src/immich/controllers/user.controller.ts +++ b/server/src/immich/controllers/user.controller.ts @@ -25,15 +25,14 @@ import { UploadedFile, UseInterceptors, } from '@nestjs/common'; -import { FileInterceptor } from '@nestjs/platform-express'; import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { Response as Res } from 'express'; import { AdminRoute, Authenticated, AuthUser, PublicRoute } from '../app.guard'; +import { FileUploadInterceptor, Route } from '../app.interceptor'; import { UseValidation } from '../app.utils'; -import { profileImageUploadOption } from '../config/profile-image-upload.config'; @ApiTags('User') -@Controller('user') +@Controller(Route.USER) @Authenticated() @UseValidation() export class UserController { @@ -83,12 +82,9 @@ export class UserController { return this.service.updateUser(authUser, updateUserDto); } - @UseInterceptors(FileInterceptor('file', profileImageUploadOption)) + @UseInterceptors(FileUploadInterceptor) @ApiConsumes('multipart/form-data') - @ApiBody({ - description: 'A new avatar for the user', - type: CreateProfileImageDto, - }) + @ApiBody({ description: 'A new avatar for the user', type: CreateProfileImageDto }) @Post('/profile-image') createProfileImage( @AuthUser() authUser: AuthUserDto, diff --git a/server/src/infra/repositories/crypto.repository.ts b/server/src/infra/repositories/crypto.repository.ts index af76d46a7..777edc299 100644 --- a/server/src/infra/repositories/crypto.repository.ts +++ b/server/src/infra/repositories/crypto.repository.ts @@ -1,11 +1,12 @@ import { ICryptoRepository } from '@app/domain'; import { Injectable } from '@nestjs/common'; import { compareSync, hash } from 'bcrypt'; -import { createHash, randomBytes } from 'crypto'; +import { createHash, randomBytes, randomUUID } from 'crypto'; import { createReadStream } from 'fs'; @Injectable() export class CryptoRepository implements ICryptoRepository { + randomUUID = randomUUID; randomBytes = randomBytes; hashBcrypt = hash; diff --git a/server/test/repositories/crypto.repository.mock.ts b/server/test/repositories/crypto.repository.mock.ts index b2f159c1e..fba15a118 100644 --- a/server/test/repositories/crypto.repository.mock.ts +++ b/server/test/repositories/crypto.repository.mock.ts @@ -2,6 +2,7 @@ import { ICryptoRepository } from '@app/domain'; export const newCryptoRepositoryMock = (): jest.Mocked => { return { + randomUUID: jest.fn().mockReturnValue('random-uuid'), randomBytes: jest.fn().mockReturnValue(Buffer.from('random-bytes', 'utf8')), compareBcrypt: jest.fn().mockReturnValue(true), hashBcrypt: jest.fn().mockImplementation((input) => Promise.resolve(`${input} (hashed)`)), From 50f26374e3cbc818e2f9716e6000ed8c0e2a15ee Mon Sep 17 00:00:00 2001 From: kasgel Date: Sun, 9 Jul 2023 22:15:34 +0200 Subject: [PATCH 10/38] fix(server): enable transcoding of audioless videos (#3147) * Fix: enable transcoding of audioless videos * Fix: enable transcoding of audioless videos & typing * Fix: enable transcoding of audioless videos & formatting * fix: do not always transcode if there is no audio stream --- server/src/domain/media/media.service.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/server/src/domain/media/media.service.ts b/server/src/domain/media/media.service.ts index cfc04fba1..943da782d 100644 --- a/server/src/domain/media/media.service.ts +++ b/server/src/domain/media/media.service.ts @@ -142,7 +142,7 @@ export class MediaService { const mainVideoStream = this.getMainVideoStream(videoStreams); const mainAudioStream = this.getMainAudioStream(audioStreams); const containerExtension = format.formatName; - if (!mainVideoStream || !mainAudioStream || !containerExtension) { + if (!mainVideoStream || !containerExtension) { return false; } @@ -182,7 +182,7 @@ export class MediaService { private isTranscodeRequired( asset: AssetEntity, videoStream: VideoStreamInfo, - audioStream: AudioStreamInfo, + audioStream: AudioStreamInfo | null, containerExtension: string, ffmpegConfig: SystemConfigFFmpegDto, ): boolean { @@ -192,12 +192,18 @@ export class MediaService { } const isTargetVideoCodec = videoStream.codecName === ffmpegConfig.targetVideoCodec; - const isTargetAudioCodec = audioStream.codecName === ffmpegConfig.targetAudioCodec; const isTargetContainer = ['mov,mp4,m4a,3gp,3g2,mj2', 'mp4', 'mov'].includes(containerExtension); + const isTargetAudioCodec = audioStream == null || audioStream.codecName === ffmpegConfig.targetAudioCodec; - this.logger.verbose( - `${asset.id}: AudioCodecName ${audioStream.codecName}, AudioStreamCodecType ${audioStream.codecType}, containerExtension ${containerExtension}`, - ); + if (audioStream != null) { + this.logger.verbose( + `${asset.id}: AudioCodecName ${audioStream.codecName}, AudioStreamCodecType ${audioStream.codecType}, containerExtension ${containerExtension}`, + ); + } else { + this.logger.verbose( + `${asset.id}: AudioCodecName None, AudioStreamCodecType None, containerExtension ${containerExtension}`, + ); + } const allTargetsMatching = isTargetVideoCodec && isTargetAudioCodec && isTargetContainer; const scalingEnabled = ffmpegConfig.targetResolution !== 'original'; From 785f61ba7075568482984b9e3bcc16794f49cd0b Mon Sep 17 00:00:00 2001 From: Johan Stenehall Date: Mon, 10 Jul 2023 03:19:48 +0200 Subject: [PATCH 11/38] Update read-only-gallery.md (#3191) Prevent Immich from having write access to volumes that should only be read. --- docs/docs/features/read-only-gallery.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/docs/features/read-only-gallery.md b/docs/docs/features/read-only-gallery.md index 6a3ca38e7..b2796c022 100644 --- a/docs/docs/features/read-only-gallery.md +++ b/docs/docs/features/read-only-gallery.md @@ -42,8 +42,8 @@ We will use those values in the steps below. command: [ "start.sh", "immich" ] volumes: - ${UPLOAD_LOCATION}:/usr/src/app/upload -+ - /mnt/media/precious-memory:/mnt/media/precious-memory -+ - /mnt/media/childhood-memory:/mnt/media/childhood-memory ++ - /mnt/media/precious-memory:/mnt/media/precious-memory:ro ++ - /mnt/media/childhood-memory:/mnt/media/childhood-memory:ro env_file: - .env depends_on: @@ -58,8 +58,8 @@ We will use those values in the steps below. command: [ "start.sh", "microservices" ] volumes: - ${UPLOAD_LOCATION}:/usr/src/app/upload -+ - /mnt/media/precious-memory:/mnt/media/precious-memory -+ - /mnt/media/childhood-memory:/mnt/media/childhood-memory ++ - /mnt/media/precious-memory:/mnt/media/precious-memory:ro ++ - /mnt/media/childhood-memory:/mnt/media/childhood-memory:ro env_file: - .env depends_on: From 6180828ed299acd5016bde6aaa2d92a457b518bb Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 10 Jul 2023 13:56:45 -0400 Subject: [PATCH 12/38] refactor(server): mime types (#3197) * refactor(server): mime type check * chore: open api * chore: remove duplicate test --- cli/src/api/open-api/api.ts | 6 - mobile/openapi/doc/AssetResponseDto.md | 1 - .../openapi/lib/model/asset_response_dto.dart | 14 +- .../openapi/test/asset_response_dto_test.dart | 5 - server/immich-openapi-specs.json | 5 - server/package-lock.json | 26 +-- server/package.json | 6 +- server/src/domain/asset/asset.service.spec.ts | 5 +- server/src/domain/asset/asset.service.ts | 4 +- .../asset/response-dto/asset-response.dto.ts | 3 - server/src/domain/domain.constant.ts | 165 ++++++++-------- .../facial-recognition.service.spec.ts | 6 +- server/src/domain/media/media.service.spec.ts | 10 +- server/src/domain/media/media.service.ts | 4 +- .../domain/metadata/metadata.service.spec.ts | 4 +- .../src/domain/person/person.service.spec.ts | 6 +- server/src/domain/person/person.service.ts | 3 +- .../storage-template.service.spec.ts | 34 ++-- server/src/immich/api-v1/asset/asset.core.ts | 1 - .../immich/api-v1/asset/asset.service.spec.ts | 181 +++++++++++------- .../src/immich/api-v1/asset/asset.service.ts | 75 +++----- server/src/immich/app.interceptor.ts | 1 - server/src/infra/entities/asset.entity.ts | 3 - .../1689001889950-DropMimeTypeColumn.ts | 14 ++ server/test/fixtures.ts | 23 +-- web/src/api/open-api/api.ts | 6 - 26 files changed, 287 insertions(+), 324 deletions(-) create mode 100644 server/src/infra/migrations/1689001889950-DropMimeTypeColumn.ts diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 3d661f991..ec57c3591 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -679,12 +679,6 @@ export interface AssetResponseDto { * @memberof AssetResponseDto */ 'isArchived': boolean; - /** - * - * @type {string} - * @memberof AssetResponseDto - */ - 'mimeType': string | null; /** * * @type {string} diff --git a/mobile/openapi/doc/AssetResponseDto.md b/mobile/openapi/doc/AssetResponseDto.md index 986ca1b53..8b0938636 100644 --- a/mobile/openapi/doc/AssetResponseDto.md +++ b/mobile/openapi/doc/AssetResponseDto.md @@ -22,7 +22,6 @@ Name | Type | Description | Notes **updatedAt** | [**DateTime**](DateTime.md) | | **isFavorite** | **bool** | | **isArchived** | **bool** | | -**mimeType** | **String** | | **duration** | **String** | | **exifInfo** | [**ExifResponseDto**](ExifResponseDto.md) | | [optional] **smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) | | [optional] diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index cd74d5721..e979e4fc3 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -27,7 +27,6 @@ class AssetResponseDto { required this.updatedAt, required this.isFavorite, required this.isArchived, - required this.mimeType, required this.duration, this.exifInfo, this.smartInfo, @@ -66,8 +65,6 @@ class AssetResponseDto { bool isArchived; - String? mimeType; - String duration; /// @@ -111,7 +108,6 @@ class AssetResponseDto { other.updatedAt == updatedAt && other.isFavorite == isFavorite && other.isArchived == isArchived && - other.mimeType == mimeType && other.duration == duration && other.exifInfo == exifInfo && other.smartInfo == smartInfo && @@ -137,7 +133,6 @@ class AssetResponseDto { (updatedAt.hashCode) + (isFavorite.hashCode) + (isArchived.hashCode) + - (mimeType == null ? 0 : mimeType!.hashCode) + (duration.hashCode) + (exifInfo == null ? 0 : exifInfo!.hashCode) + (smartInfo == null ? 0 : smartInfo!.hashCode) + @@ -147,7 +142,7 @@ class AssetResponseDto { (checksum.hashCode); @override - String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, originalFileName=$originalFileName, resized=$resized, thumbhash=$thumbhash, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, updatedAt=$updatedAt, isFavorite=$isFavorite, isArchived=$isArchived, mimeType=$mimeType, duration=$duration, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId, tags=$tags, people=$people, checksum=$checksum]'; + String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, originalFileName=$originalFileName, resized=$resized, thumbhash=$thumbhash, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, updatedAt=$updatedAt, isFavorite=$isFavorite, isArchived=$isArchived, duration=$duration, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId, tags=$tags, people=$people, checksum=$checksum]'; Map toJson() { final json = {}; @@ -169,11 +164,6 @@ class AssetResponseDto { json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); json[r'isFavorite'] = this.isFavorite; json[r'isArchived'] = this.isArchived; - if (this.mimeType != null) { - json[r'mimeType'] = this.mimeType; - } else { - // json[r'mimeType'] = null; - } json[r'duration'] = this.duration; if (this.exifInfo != null) { json[r'exifInfo'] = this.exifInfo; @@ -218,7 +208,6 @@ class AssetResponseDto { updatedAt: mapDateTime(json, r'updatedAt', r'')!, isFavorite: mapValueOfType(json, r'isFavorite')!, isArchived: mapValueOfType(json, r'isArchived')!, - mimeType: mapValueOfType(json, r'mimeType'), duration: mapValueOfType(json, r'duration')!, exifInfo: ExifResponseDto.fromJson(json[r'exifInfo']), smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']), @@ -287,7 +276,6 @@ class AssetResponseDto { 'updatedAt', 'isFavorite', 'isArchived', - 'mimeType', 'duration', 'checksum', }; diff --git a/mobile/openapi/test/asset_response_dto_test.dart b/mobile/openapi/test/asset_response_dto_test.dart index 0bbcde257..c9e33fb3b 100644 --- a/mobile/openapi/test/asset_response_dto_test.dart +++ b/mobile/openapi/test/asset_response_dto_test.dart @@ -87,11 +87,6 @@ void main() { // TODO }); - // String mimeType - test('to test the property `mimeType`', () async { - // TODO - }); - // String duration test('to test the property `duration`', () async { // TODO diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index b35660fda..8c700d669 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -4866,10 +4866,6 @@ "isArchived": { "type": "boolean" }, - "mimeType": { - "type": "string", - "nullable": true - }, "duration": { "type": "string" }, @@ -4915,7 +4911,6 @@ "updatedAt", "isFavorite", "isArchived", - "mimeType", "duration", "checksum" ] diff --git a/server/package-lock.json b/server/package-lock.json index f16a76e15..37f4bf0fa 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -21,7 +21,6 @@ "@nestjs/typeorm": "^9.0.1", "@nestjs/websockets": "^9.2.1", "@socket.io/redis-adapter": "^8.0.1", - "@types/mime-types": "^2.1.1", "archiver": "^5.3.1", "axios": "^0.26.0", "bcrypt": "^5.0.1", @@ -40,7 +39,6 @@ "local-reverse-geocoder": "0.12.5", "lodash": "^4.17.21", "luxon": "^3.0.3", - "mime-types": "^2.1.35", "mv": "^2.1.1", "nest-commander": "^3.3.0", "openid-client": "^5.2.1", @@ -55,8 +53,8 @@ "ua-parser-js": "^1.0.35" }, "bin": { - "immich": "./bin/cli.sh", - "immich-admin": "./bin/admin-cli.sh" + "immich": "bin/cli.sh", + "immich-admin": "bin/admin-cli.sh" }, "devDependencies": { "@nestjs/cli": "^9.1.8", @@ -3022,11 +3020,6 @@ "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", "dev": true }, - "node_modules/@types/mime-types": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz", - "integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==" - }, "node_modules/@types/multer": { "version": "1.4.7", "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz", @@ -9079,7 +9072,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -9327,7 +9319,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, + "devOptional": true, "engines": { "node": ">=8.6" }, @@ -12213,7 +12205,6 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, "engines": { "node": ">=10" }, @@ -14458,11 +14449,6 @@ "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", "dev": true }, - "@types/mime-types": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz", - "integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==" - }, "@types/multer": { "version": "1.4.7", "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz", @@ -19088,7 +19074,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, "requires": { "yocto-queue": "^0.1.0" } @@ -19271,7 +19256,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true + "devOptional": true }, "pirates": { "version": "4.0.5", @@ -21284,8 +21269,7 @@ "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" }, "zip-stream": { "version": "4.1.0", diff --git a/server/package.json b/server/package.json index 9a8bd5298..9b5662c99 100644 --- a/server/package.json +++ b/server/package.json @@ -51,7 +51,6 @@ "@nestjs/typeorm": "^9.0.1", "@nestjs/websockets": "^9.2.1", "@socket.io/redis-adapter": "^8.0.1", - "@types/mime-types": "^2.1.1", "archiver": "^5.3.1", "axios": "^0.26.0", "bcrypt": "^5.0.1", @@ -64,12 +63,12 @@ "fluent-ffmpeg": "^2.1.2", "handlebars": "^4.7.7", "i18n-iso-countries": "^7.5.0", + "immich": "^0.39.0", "ioredis": "^5.3.1", "joi": "^17.5.0", "local-reverse-geocoder": "0.12.5", "lodash": "^4.17.21", "luxon": "^3.0.3", - "mime-types": "^2.1.35", "mv": "^2.1.1", "nest-commander": "^3.3.0", "openid-client": "^5.2.1", @@ -81,8 +80,7 @@ "thumbhash": "^0.1.1", "typeorm": "^0.3.11", "typesense": "^1.5.3", - "ua-parser-js": "^1.0.35", - "immich": "^0.39.0" + "ua-parser-js": "^1.0.35" }, "devDependencies": { "@nestjs/cli": "^9.1.8", diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index ed155c148..ef51c8831 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -156,10 +156,7 @@ describe(AssetService.name, () => { await expect(sut.downloadFile(authStub.admin, 'asset-1')).resolves.toEqual({ stream }); - expect(storageMock.createReadStream).toHaveBeenCalledWith( - assetEntityStub.image.originalPath, - assetEntityStub.image.mimeType, - ); + expect(storageMock.createReadStream).toHaveBeenCalledWith(assetEntityStub.image.originalPath, 'image/jpeg'); }); it('should download an archive', async () => { diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 10e1718c6..5a84a4a35 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -4,6 +4,7 @@ import { DateTime } from 'luxon'; import { extname } from 'path'; import { AccessCore, IAccessRepository, Permission } from '../access'; import { AuthUserDto } from '../auth'; +import { mimeTypes } from '../domain.constant'; import { HumanReadableSize, usePagination } from '../domain.util'; import { ImmichReadStream, IStorageRepository } from '../storage'; import { IAssetRepository } from './asset.repository'; @@ -20,7 +21,6 @@ export enum UploadFieldName { } export interface UploadFile { - mimeType: string; checksum: Buffer; originalPath: string; originalName: string; @@ -68,7 +68,7 @@ export class AssetService { throw new BadRequestException('Asset not found'); } - return this.storageRepository.createReadStream(asset.originalPath, asset.mimeType); + return this.storageRepository.createReadStream(asset.originalPath, mimeTypes.lookup(asset.originalPath)); } async getDownloadInfo(authUser: AuthUserDto, dto: DownloadDto): Promise { diff --git a/server/src/domain/asset/response-dto/asset-response.dto.ts b/server/src/domain/asset/response-dto/asset-response.dto.ts index 1df6b47e8..f5284f390 100644 --- a/server/src/domain/asset/response-dto/asset-response.dto.ts +++ b/server/src/domain/asset/response-dto/asset-response.dto.ts @@ -23,7 +23,6 @@ export class AssetResponseDto { updatedAt!: Date; isFavorite!: boolean; isArchived!: boolean; - mimeType!: string | null; duration!: string; exifInfo?: ExifResponseDto; smartInfo?: SmartInfoResponseDto; @@ -50,7 +49,6 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto { updatedAt: entity.updatedAt, isFavorite: entity.isFavorite, isArchived: entity.isArchived, - mimeType: entity.mimeType, duration: entity.duration ?? '0:00:00.00000', exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined, smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined, @@ -77,7 +75,6 @@ export function mapAssetWithoutExif(entity: AssetEntity): AssetResponseDto { updatedAt: entity.updatedAt, isFavorite: entity.isFavorite, isArchived: entity.isArchived, - mimeType: entity.mimeType, duration: entity.duration ?? '0:00:00.00000', exifInfo: undefined, smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined, diff --git a/server/src/domain/domain.constant.ts b/server/src/domain/domain.constant.ts index fd04381a0..e61174747 100644 --- a/server/src/domain/domain.constant.ts +++ b/server/src/domain/domain.constant.ts @@ -1,4 +1,5 @@ import { BadRequestException } from '@nestjs/common'; +import { extname } from 'node:path'; import pkg from 'src/../../package.json'; const [major, minor, patch] = pkg.version.split('.'); @@ -28,92 +29,78 @@ export function assertMachineLearningEnabled() { } } -export const ASSET_MIME_TYPES = [ - 'image/3fr', - 'image/ari', - 'image/arw', - 'image/avif', - 'image/cap', - 'image/cin', - 'image/cr2', - 'image/cr3', - 'image/crw', - 'image/dcr', - 'image/dng', - 'image/erf', - 'image/fff', - 'image/gif', - 'image/heic', - 'image/heif', - 'image/iiq', - 'image/jpeg', - 'image/jxl', - 'image/k25', - 'image/kdc', - 'image/mrw', - 'image/nef', - 'image/orf', - 'image/ori', - 'image/pef', - 'image/png', - 'image/raf', - 'image/raw', - 'image/rwl', - 'image/sr2', - 'image/srf', - 'image/srw', - 'image/tiff', - 'image/webp', - 'image/x-adobe-dng', - 'image/x-arriflex-ari', - 'image/x-canon-cr2', - 'image/x-canon-cr3', - 'image/x-canon-crw', - 'image/x-epson-erf', - 'image/x-fuji-raf', - 'image/x-hasselblad-3fr', - 'image/x-hasselblad-fff', - 'image/x-kodak-dcr', - 'image/x-kodak-k25', - 'image/x-kodak-kdc', - 'image/x-leica-rwl', - 'image/x-minolta-mrw', - 'image/x-nikon-nef', - 'image/x-olympus-orf', - 'image/x-olympus-ori', - 'image/x-panasonic-raw', - 'image/x-pentax-pef', - 'image/x-phantom-cin', - 'image/x-phaseone-cap', - 'image/x-phaseone-iiq', - 'image/x-samsung-srw', - 'image/x-sigma-x3f', - 'image/x-sony-arw', - 'image/x-sony-sr2', - 'image/x-sony-srf', - 'image/x3f', - 'video/3gpp', - 'video/avi', - 'video/mp2t', - 'video/mp4', - 'video/mpeg', - 'video/msvideo', - 'video/quicktime', - 'video/vnd.avi', - 'video/webm', - 'video/x-flv', - 'video/x-matroska', - 'video/x-ms-wmv', - 'video/x-msvideo', -]; -export const LIVE_PHOTO_MIME_TYPES = ASSET_MIME_TYPES; -export const SIDECAR_MIME_TYPES = ['application/xml', 'text/xml']; -export const PROFILE_MIME_TYPES = [ - 'image/jpeg', - 'image/png', - 'image/heic', - 'image/heif', - 'image/dng', - 'image/webp', - 'image/avif', -]; +const profile: Record = { + '.avif': 'image/avif', + '.dng': 'image/x-adobe-dng', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp', +}; + +const image: Record = { + ...profile, + '.3fr': 'image/x-hasselblad-3fr', + '.ari': 'image/x-arriflex-ari', + '.arw': 'image/x-sony-arw', + '.cap': 'image/x-phaseone-cap', + '.cin': 'image/x-phantom-cin', + '.cr2': 'image/x-canon-cr2', + '.cr3': 'image/x-canon-cr3', + '.crw': 'image/x-canon-crw', + '.dcr': 'image/x-kodak-dcr', + '.erf': 'image/x-epson-erf', + '.fff': 'image/x-hasselblad-fff', + '.gif': 'image/gif', + '.iiq': 'image/x-phaseone-iiq', + '.k25': 'image/x-kodak-k25', + '.kdc': 'image/x-kodak-kdc', + '.mrw': 'image/x-minolta-mrw', + '.nef': 'image/x-nikon-nef', + '.orf': 'image/x-olympus-orf', + '.ori': 'image/x-olympus-ori', + '.pef': 'image/x-pentax-pef', + '.raf': 'image/x-fuji-raf', + '.raw': 'image/x-panasonic-raw', + '.rwl': 'image/x-leica-rwl', + '.sr2': 'image/x-sony-sr2', + '.srf': 'image/x-sony-srf', + '.srw': 'image/x-samsung-srw', + '.tiff': 'image/tiff', + '.x3f': 'image/x-sigma-x3f', +}; + +const video: Record = { + '.3gp': 'video/3gpp', + '.avi': 'video/x-msvideo', + '.flv': 'video/x-flv', + '.mkv': 'video/x-matroska', + '.mov': 'video/quicktime', + '.mp2t': 'video/mp2t', + '.mp4': 'video/mp4', + '.mpeg': 'video/mpeg', + '.webm': 'video/webm', + '.wmv': 'video/x-ms-wmv', +}; + +const sidecar: Record = { + '.xmp': 'application/xml', +}; + +const isType = (filename: string, lookup: Record) => !!lookup[extname(filename).toLowerCase()]; +const getType = (filename: string, lookup: Record) => lookup[extname(filename).toLowerCase()]; + +export const mimeTypes = { + image, + profile, + sidecar, + video, + + isAsset: (filename: string) => isType(filename, image) || isType(filename, video), + isProfile: (filename: string) => isType(filename, profile), + isSidecar: (filename: string) => isType(filename, sidecar), + isVideo: (filename: string) => isType(filename, video), + lookup: (filename: string) => getType(filename, { ...image, ...video, ...sidecar }) || 'application/octet-stream', +}; diff --git a/server/src/domain/facial-recognition/facial-recognition.service.spec.ts b/server/src/domain/facial-recognition/facial-recognition.service.spec.ts index 2d4de5637..2c737bf62 100644 --- a/server/src/domain/facial-recognition/facial-recognition.service.spec.ts +++ b/server/src/domain/facial-recognition/facial-recognition.service.spec.ts @@ -268,7 +268,7 @@ describe(FacialRecognitionService.name, () => { expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1']); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id'); - expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.ext', { + expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg', { left: 95, top: 95, width: 110, @@ -289,7 +289,7 @@ describe(FacialRecognitionService.name, () => { await sut.handleGenerateFaceThumbnail(face.start); - expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.ext', { + expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg', { left: 0, top: 0, width: 510, @@ -306,7 +306,7 @@ describe(FacialRecognitionService.name, () => { await sut.handleGenerateFaceThumbnail(face.end); - expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.ext', { + expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg', { left: 297, top: 297, width: 202, diff --git a/server/src/domain/media/media.service.spec.ts b/server/src/domain/media/media.service.spec.ts index 8a5f1e297..f67bc40c9 100644 --- a/server/src/domain/media/media.service.spec.ts +++ b/server/src/domain/media/media.service.spec.ts @@ -116,7 +116,7 @@ describe(MediaService.name, () => { await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id }); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id'); - expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.ext', 'upload/thumbs/user-id/asset-id.jpeg', { + expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', 'upload/thumbs/user-id/asset-id.jpeg', { size: 1440, format: 'jpeg', }); @@ -167,11 +167,11 @@ describe(MediaService.name, () => { await sut.handleGenerateWebpThumbnail({ id: assetEntityStub.image.id }); expect(mediaMock.resize).toHaveBeenCalledWith( - '/uploads/user-id/thumbs/path.ext', - '/uploads/user-id/thumbs/path.ext', + '/uploads/user-id/thumbs/path.jpg', + '/uploads/user-id/thumbs/path.webp', { format: 'webp', size: 250 }, ); - expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', webpPath: '/uploads/user-id/thumbs/path.ext' }); + expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', webpPath: '/uploads/user-id/thumbs/path.webp' }); }); }); @@ -195,7 +195,7 @@ describe(MediaService.name, () => { await sut.handleGenerateThumbhashThumbnail({ id: assetEntityStub.image.id }); - expect(mediaMock.generateThumbhash).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.ext'); + expect(mediaMock.generateThumbhash).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); }); }); diff --git a/server/src/domain/media/media.service.ts b/server/src/domain/media/media.service.ts index 943da782d..98800d5fc 100644 --- a/server/src/domain/media/media.service.ts +++ b/server/src/domain/media/media.service.ts @@ -89,10 +89,10 @@ export class MediaService { return false; } - const webpPath = asset.resizePath.replace('jpeg', 'webp'); + const webpPath = asset.resizePath.replace('jpeg', 'webp').replace('jpg', 'webp'); await this.mediaRepository.resize(asset.resizePath, webpPath, { size: WEBP_THUMBNAIL_SIZE, format: 'webp' }); - await this.assetRepository.save({ id: asset.id, webpPath: webpPath }); + await this.assetRepository.save({ id: asset.id, webpPath }); return true; } diff --git a/server/src/domain/metadata/metadata.service.spec.ts b/server/src/domain/metadata/metadata.service.spec.ts index 1931e0b9c..19cae7cfb 100644 --- a/server/src/domain/metadata/metadata.service.spec.ts +++ b/server/src/domain/metadata/metadata.service.spec.ts @@ -82,10 +82,10 @@ describe(MetadataService.name, () => { assetMock.save.mockResolvedValue(assetEntityStub.image); storageMock.checkFileExists.mockResolvedValue(true); await sut.handleSidecarDiscovery({ id: assetEntityStub.image.id }); - expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.R_OK); + expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.jpg.xmp', constants.R_OK); expect(assetMock.save).toHaveBeenCalledWith({ id: assetEntityStub.image.id, - sidecarPath: '/original/path.ext.xmp', + sidecarPath: '/original/path.jpg.xmp', }); }); diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index 3d786f9e2..d598f1293 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -17,7 +17,7 @@ import { PersonService } from './person.service'; const responseDto: PersonResponseDto = { id: 'person-1', name: 'Person 1', - thumbnailPath: '/path/to/thumbnail', + thumbnailPath: '/path/to/thumbnail.jpg', }; describe(PersonService.name, () => { @@ -74,7 +74,7 @@ describe(PersonService.name, () => { it('should serve the thumbnail', async () => { personMock.getById.mockResolvedValue(personStub.noName); await sut.getThumbnail(authStub.admin, 'person-1'); - expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/thumbnail', 'image/jpeg'); + expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/thumbnail.jpg', 'image/jpeg'); }); }); @@ -150,7 +150,7 @@ describe(PersonService.name, () => { expect(personMock.delete).toHaveBeenCalledWith(personStub.noName); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.DELETE_FILES, - data: { files: ['/path/to/thumbnail'] }, + data: { files: ['/path/to/thumbnail.jpg'] }, }); }); }); diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index ed443f765..ce009f143 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -2,6 +2,7 @@ import { PersonEntity } from '@app/infra/entities'; import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { AssetResponseDto, mapAsset } from '../asset'; import { AuthUserDto } from '../auth'; +import { mimeTypes } from '../domain.constant'; import { IJobRepository, JobName } from '../job'; import { ImmichReadStream, IStorageRepository } from '../storage'; import { mapPerson, PersonResponseDto, PersonUpdateDto } from './person.dto'; @@ -44,7 +45,7 @@ export class PersonService { throw new NotFoundException(); } - return this.storageRepository.createReadStream(person.thumbnailPath, 'image/jpeg'); + return this.storageRepository.createReadStream(person.thumbnailPath, mimeTypes.lookup(person.thumbnailPath)); } async getAssets(authUser: AuthUserDto, personId: string): Promise { diff --git a/server/src/domain/storage-template/storage-template.service.spec.ts b/server/src/domain/storage-template/storage-template.service.spec.ts index 8c6a8ebc5..b34808e40 100644 --- a/server/src/domain/storage-template/storage-template.service.spec.ts +++ b/server/src/domain/storage-template/storage-template.service.spec.ts @@ -56,11 +56,11 @@ describe(StorageTemplateService.name, () => { userMock.getList.mockResolvedValue([userEntityStub.user1]); when(storageMock.checkFileExists) - .calledWith('upload/library/user-id/2023/2023-02-23/asset-id.ext') + .calledWith('upload/library/user-id/2023/2023-02-23/asset-id.jpg') .mockResolvedValue(true); when(storageMock.checkFileExists) - .calledWith('upload/library/user-id/2023/2023-02-23/asset-id+1.ext') + .calledWith('upload/library/user-id/2023/2023-02-23/asset-id+1.jpg') .mockResolvedValue(false); await sut.handleMigration(); @@ -69,7 +69,7 @@ describe(StorageTemplateService.name, () => { expect(storageMock.checkFileExists).toHaveBeenCalledTimes(2); expect(assetMock.save).toHaveBeenCalledWith({ id: assetEntityStub.image.id, - originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext', + originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg', }); expect(userMock.getList).toHaveBeenCalled(); }); @@ -79,7 +79,7 @@ describe(StorageTemplateService.name, () => { items: [ { ...assetEntityStub.image, - originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.ext', + originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', }, ], hasNextPage: false, @@ -99,7 +99,7 @@ describe(StorageTemplateService.name, () => { items: [ { ...assetEntityStub.image, - originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext', + originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg', }, ], hasNextPage: false, @@ -126,12 +126,12 @@ describe(StorageTemplateService.name, () => { expect(assetMock.getAll).toHaveBeenCalled(); expect(storageMock.moveFile).toHaveBeenCalledWith( - '/original/path.ext', - 'upload/library/user-id/2023/2023-02-23/asset-id.ext', + '/original/path.jpg', + 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', ); expect(assetMock.save).toHaveBeenCalledWith({ id: assetEntityStub.image.id, - originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.ext', + originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', }); }); @@ -147,12 +147,12 @@ describe(StorageTemplateService.name, () => { expect(assetMock.getAll).toHaveBeenCalled(); expect(storageMock.moveFile).toHaveBeenCalledWith( - '/original/path.ext', - 'upload/library/label-1/2023/2023-02-23/asset-id.ext', + '/original/path.jpg', + 'upload/library/label-1/2023/2023-02-23/asset-id.jpg', ); expect(assetMock.save).toHaveBeenCalledWith({ id: assetEntityStub.image.id, - originalPath: 'upload/library/label-1/2023/2023-02-23/asset-id.ext', + originalPath: 'upload/library/label-1/2023/2023-02-23/asset-id.jpg', }); }); @@ -168,8 +168,8 @@ describe(StorageTemplateService.name, () => { expect(assetMock.getAll).toHaveBeenCalled(); expect(storageMock.moveFile).toHaveBeenCalledWith( - '/original/path.ext', - 'upload/library/user-id/2023/2023-02-23/asset-id.ext', + '/original/path.jpg', + 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', ); expect(assetMock.save).not.toHaveBeenCalled(); }); @@ -187,11 +187,11 @@ describe(StorageTemplateService.name, () => { expect(assetMock.getAll).toHaveBeenCalled(); expect(assetMock.save).toHaveBeenCalledWith({ id: assetEntityStub.image.id, - originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.ext', + originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg', }); expect(storageMock.moveFile.mock.calls).toEqual([ - ['/original/path.ext', 'upload/library/user-id/2023/2023-02-23/asset-id.ext'], - ['upload/library/user-id/2023/2023-02-23/asset-id.ext', '/original/path.ext'], + ['/original/path.jpg', 'upload/library/user-id/2023/2023-02-23/asset-id.jpg'], + ['upload/library/user-id/2023/2023-02-23/asset-id.jpg', '/original/path.jpg'], ]); }); @@ -200,7 +200,7 @@ describe(StorageTemplateService.name, () => { items: [ { ...assetEntityStub.image, - originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext', + originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg', isReadOnly: true, }, ], diff --git a/server/src/immich/api-v1/asset/asset.core.ts b/server/src/immich/api-v1/asset/asset.core.ts index c05d58dc0..7508e4f6c 100644 --- a/server/src/immich/api-v1/asset/asset.core.ts +++ b/server/src/immich/api-v1/asset/asset.core.ts @@ -17,7 +17,6 @@ export class AssetCore { const asset = await this.repository.create({ owner: { id: authUser.id } as UserEntity, - mimeType: file.mimeType, checksum: file.checksum, originalPath: file.originalPath, diff --git a/server/src/immich/api-v1/asset/asset.service.spec.ts b/server/src/immich/api-v1/asset/asset.service.spec.ts index 2135bf27a..f0115a34b 100644 --- a/server/src/immich/api-v1/asset/asset.service.spec.ts +++ b/server/src/immich/api-v1/asset/asset.service.spec.ts @@ -1,12 +1,9 @@ import { - ASSET_MIME_TYPES, ICryptoRepository, IJobRepository, IStorageRepository, JobName, - LIVE_PHOTO_MIME_TYPES, - PROFILE_MIME_TYPES, - SIDECAR_MIME_TYPES, + mimeTypes, UploadFieldName, } from '@app/domain'; import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; @@ -60,7 +57,6 @@ const _getAsset_1 = () => { asset_1.updatedAt = new Date('2022-06-19T23:41:36.910Z'); asset_1.isFavorite = false; asset_1.isArchived = false; - asset_1.mimeType = 'image/jpeg'; asset_1.webpPath = ''; asset_1.encodedVideoPath = ''; asset_1.duration = '0:00:00.000000'; @@ -85,7 +81,6 @@ const _getAsset_2 = () => { asset_2.updatedAt = new Date('2022-06-19T23:41:36.910Z'); asset_2.isFavorite = false; asset_2.isArchived = false; - asset_2.mimeType = 'image/jpeg'; asset_2.webpPath = ''; asset_2.encodedVideoPath = ''; asset_2.duration = '0:00:00.000000'; @@ -132,24 +127,11 @@ const uploadFile = { authUser: null, fieldName: UploadFieldName.ASSET_DATA, file: { - mimeType: 'image/jpeg', checksum: Buffer.from('checksum', 'utf8'), originalPath: 'upload/admin/image.jpeg', originalName: 'image.jpeg', }, }, - mimeType: (fieldName: UploadFieldName, mimeType: string) => { - return { - authUser: authStub.admin, - fieldName, - file: { - mimeType, - checksum: Buffer.from('checksum', 'utf8'), - originalPath: 'upload/admin/image.jpeg', - originalName: 'image.jpeg', - }, - }; - }, filename: (fieldName: UploadFieldName, filename: string) => { return { authUser: authStub.admin, @@ -164,6 +146,33 @@ const uploadFile = { }, }; +const uploadTests = [ + { + label: 'asset', + fieldName: UploadFieldName.ASSET_DATA, + filetypes: Object.keys({ ...mimeTypes.image, ...mimeTypes.video }), + invalid: ['.xml', '.html'], + }, + { + label: 'live photo', + fieldName: UploadFieldName.LIVE_PHOTO_DATA, + filetypes: Object.keys(mimeTypes.video), + invalid: ['.xml', '.html', '.jpg', '.jpeg'], + }, + { + label: 'sidecar', + fieldName: UploadFieldName.SIDECAR_DATA, + filetypes: Object.keys(mimeTypes.sidecar), + invalid: ['.xml', '.html', '.jpg', '.jpeg', '.mov', '.mp4'], + }, + { + label: 'profile', + fieldName: UploadFieldName.PROFILE_DATA, + filetypes: Object.keys(mimeTypes.profile), + invalid: ['.xml', '.html', '.cr2', '.arf', '.mov', '.mp4'], + }, +]; + describe('AssetService', () => { let sut: AssetService; let a: Repository; // TO BE DELETED AFTER FINISHED REFACTORING @@ -195,8 +204,6 @@ describe('AssetService', () => { getByOriginalPath: jest.fn(), }; - cryptoMock = newCryptoRepositoryMock(); - accessMock = newAccessRepositoryMock(); cryptoMock = newCryptoRepositoryMock(); jobMock = newJobRepositoryMock(); @@ -212,60 +219,106 @@ describe('AssetService', () => { .mockResolvedValue(assetEntityStub.livePhotoMotionAsset); }); - const tests = [ - { label: 'asset', fieldName: UploadFieldName.ASSET_DATA, mimeTypes: ASSET_MIME_TYPES }, - { label: 'live photo', fieldName: UploadFieldName.LIVE_PHOTO_DATA, mimeTypes: LIVE_PHOTO_MIME_TYPES }, - { label: 'sidecar', fieldName: UploadFieldName.SIDECAR_DATA, mimeTypes: SIDECAR_MIME_TYPES }, - { label: 'profile', fieldName: UploadFieldName.PROFILE_DATA, mimeTypes: PROFILE_MIME_TYPES }, - ]; - - for (const { label, fieldName, mimeTypes } of tests) { - describe(`${label} mime types linting`, () => { - it('should be a sorted list', () => { - expect(mimeTypes).toEqual(mimeTypes.sort()); - }); - - it('should contain only unique values', () => { - expect(mimeTypes).toEqual([...new Set(mimeTypes)]); - }); - - if (fieldName !== UploadFieldName.SIDECAR_DATA) { - it('should contain only image or video mime types', () => { - expect(mimeTypes).toEqual( - mimeTypes.filter((mimeType) => mimeType.startsWith('image/') || mimeType.startsWith('video/')), - ); - }); - } - + describe('mime types linting', () => { + describe('profile', () => { it('should contain only lowercase mime types', () => { - expect(mimeTypes).toEqual(mimeTypes.map((mimeType) => mimeType.toLowerCase())); + const keys = Object.keys(mimeTypes.profile); + expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase())); + const values = Object.values(mimeTypes.profile); + expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase())); + }); + + it('should be a sorted list', () => { + const keys = Object.keys(mimeTypes.profile); + expect(keys).toEqual([...keys].sort()); }); }); - } + + describe('image', () => { + it('should contain only lowercase mime types', () => { + const keys = Object.keys(mimeTypes.image); + expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase())); + const values = Object.values(mimeTypes.image); + expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase())); + }); + + it('should be a sorted list', () => { + const keys = Object.keys(mimeTypes.image).filter((key) => key in mimeTypes.profile === false); + expect(keys).toEqual([...keys].sort()); + }); + + it('should contain only image mime types', () => { + expect(Object.values(mimeTypes.image)).toEqual( + Object.values(mimeTypes.image).filter((mimeType) => mimeType.startsWith('image/')), + ); + }); + }); + + describe('video', () => { + it('should contain only lowercase mime types', () => { + const keys = Object.keys(mimeTypes.video); + expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase())); + const values = Object.values(mimeTypes.video); + expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase())); + }); + + it('should be a sorted list', () => { + const keys = Object.keys(mimeTypes.video); + expect(keys).toEqual([...keys].sort()); + }); + + it('should contain only video mime types', () => { + expect(Object.values(mimeTypes.video)).toEqual( + Object.values(mimeTypes.video).filter((mimeType) => mimeType.startsWith('video/')), + ); + }); + }); + + describe('sidecar', () => { + it('should contain only lowercase mime types', () => { + const keys = Object.keys(mimeTypes.sidecar); + expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase())); + const values = Object.values(mimeTypes.sidecar); + expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase())); + }); + + it('should be a sorted list', () => { + const keys = Object.keys(mimeTypes.sidecar); + expect(keys).toEqual([...keys].sort()); + }); + }); + + describe('sidecar', () => { + it('should contain only be xml mime type', () => { + expect(Object.values(mimeTypes.sidecar)).toEqual( + Object.values(mimeTypes.sidecar).filter((mimeType) => mimeType === 'application/xml'), + ); + }); + }); + }); describe('canUpload', () => { it('should require an authenticated user', () => { expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException); }); - it('should accept all accepted mime types', () => { - for (const { fieldName, mimeTypes } of tests) { - for (const mimeType of mimeTypes) { - expect(sut.canUploadFile(uploadFile.mimeType(fieldName, mimeType))).toEqual(true); + for (const { fieldName, filetypes, invalid } of uploadTests) { + describe(`${fieldName}`, () => { + for (const filetype of filetypes) { + it(`should accept ${filetype}`, () => { + expect(sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toEqual(true); + }); } - } - }); - it('should reject other mime types', () => { - for (const { fieldName, mimeType } of [ - { fieldName: UploadFieldName.ASSET_DATA, mimeType: 'application/html' }, - { fieldName: UploadFieldName.LIVE_PHOTO_DATA, mimeType: 'application/html' }, - { fieldName: UploadFieldName.PROFILE_DATA, mimeType: 'application/html' }, - { fieldName: UploadFieldName.SIDECAR_DATA, mimeType: 'image/jpeg' }, - ]) { - expect(() => sut.canUploadFile(uploadFile.mimeType(fieldName, mimeType))).toThrowError(BadRequestException); - } - }); + for (const filetype of invalid) { + it(`should reject ${filetype}`, () => { + expect(() => sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toThrowError( + BadRequestException, + ); + }); + } + }); + } }); describe('getUploadFilename', () => { diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index fdf1c5a3b..5fe7df0d2 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -1,7 +1,6 @@ import { AccessCore, AssetResponseDto, - ASSET_MIME_TYPES, AuthUserDto, getLivePhotoMotionFilename, IAccessRepository, @@ -9,12 +8,10 @@ import { IJobRepository, IStorageRepository, JobName, - LIVE_PHOTO_MIME_TYPES, mapAsset, mapAssetWithoutExif, + mimeTypes, Permission, - PROFILE_MIME_TYPES, - SIDECAR_MIME_TYPES, StorageCore, StorageFolder, UploadFieldName, @@ -33,7 +30,6 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Response as Res } from 'express'; import { constants, createReadStream } from 'fs'; import fs from 'fs/promises'; -import mime from 'mime-types'; import path, { extname } from 'path'; import sanitize from 'sanitize-filename'; import { pipeline } from 'stream/promises'; @@ -71,11 +67,6 @@ import { CuratedLocationsResponseDto } from './response-dto/curated-locations-re import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto'; import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto'; -interface ServableFile { - filepath: string; - contentType: string; -} - @Injectable() export class AssetService { readonly logger = new Logger(AssetService.name); @@ -98,35 +89,36 @@ export class AssetService { canUploadFile({ authUser, fieldName, file }: UploadRequest): true { this.access.requireUploadAccess(authUser); + const filename = file.originalName; + switch (fieldName) { case UploadFieldName.ASSET_DATA: - if (ASSET_MIME_TYPES.includes(file.mimeType)) { + if (mimeTypes.isAsset(filename)) { return true; } break; case UploadFieldName.LIVE_PHOTO_DATA: - if (LIVE_PHOTO_MIME_TYPES.includes(file.mimeType)) { + if (mimeTypes.isVideo(filename)) { return true; } break; case UploadFieldName.SIDECAR_DATA: - if (SIDECAR_MIME_TYPES.includes(file.mimeType)) { + if (mimeTypes.isSidecar(filename)) { return true; } break; case UploadFieldName.PROFILE_DATA: - if (PROFILE_MIME_TYPES.includes(file.mimeType)) { + if (mimeTypes.isProfile(filename)) { return true; } break; } - const ext = extname(file.originalName); - this.logger.error(`Unsupported file type ${ext} file MIME type ${file.mimeType}`); - throw new BadRequestException(`Unsupported file type ${ext}`); + this.logger.error(`Unsupported file type ${filename}`); + throw new BadRequestException(`Unsupported file type ${filename}`); } getUploadFilename({ authUser, fieldName, file }: UploadRequest): string { @@ -208,15 +200,12 @@ export class AssetService { sidecarPath: dto.sidecarPath ? path.resolve(dto.sidecarPath) : undefined, }; - const mimeType = mime.lookup(dto.assetPath) as string; - if (!ASSET_MIME_TYPES.includes(mimeType)) { - throw new BadRequestException(`Unsupported file type ${mimeType}`); + if (!mimeTypes.isAsset(dto.assetPath)) { + throw new BadRequestException(`Unsupported file type ${dto.assetPath}`); } - if (dto.sidecarPath) { - if (path.extname(dto.sidecarPath).toLowerCase() !== '.xmp') { - throw new BadRequestException(`Unsupported sidecar file type`); - } + if (dto.sidecarPath && !mimeTypes.isSidecar(dto.sidecarPath)) { + throw new BadRequestException(`Unsupported sidecar file type`); } for (const filepath of [dto.assetPath, dto.sidecarPath]) { @@ -236,7 +225,6 @@ export class AssetService { const assetFile: UploadFile = { checksum: await this.cryptoRepository.hashFile(dto.assetPath), - mimeType, originalPath: dto.assetPath, originalName: path.parse(dto.assetPath).name, }; @@ -328,8 +316,7 @@ export class AssetService { } try { - const [thumbnailPath, contentType] = this.getThumbnailPath(asset, query.format); - return this.streamFile(thumbnailPath, res, headers, contentType); + return this.streamFile(this.getThumbnailPath(asset, query.format), res, headers); } catch (e) { res.header('Cache-Control', 'none'); this.logger.error(`Cannot create read stream for asset ${asset.id}`, 'getAssetThumbnail'); @@ -360,8 +347,7 @@ export class AssetService { // Handle Sending Images if (asset.type == AssetType.IMAGE) { try { - const { filepath, contentType } = this.getServePath(asset, query, allowOriginalFile); - return this.streamFile(filepath, res, headers, contentType); + return this.streamFile(this.getServePath(asset, query, allowOriginalFile), res, headers); } catch (e) { this.logger.error(`Cannot create read stream for asset ${asset.id} ${JSON.stringify(e)}`, 'serveFile[IMAGE]'); throw new InternalServerErrorException( @@ -371,10 +357,7 @@ export class AssetService { } } else { try { - const videoPath = asset.encodedVideoPath ? asset.encodedVideoPath : asset.originalPath; - const mimeType = asset.encodedVideoPath ? 'video/mp4' : asset.mimeType; - - return this.streamFile(videoPath, res, headers, mimeType); + return this.streamFile(asset.encodedVideoPath || asset.originalPath, res, headers); } catch (e: Error | any) { this.logger.error(`Error serving VIDEO asset=${asset.id}`, e?.stack); throw new InternalServerErrorException(`Failed to serve video asset ${e}`, 'ServeFile'); @@ -595,7 +578,7 @@ export class AssetService { switch (format) { case GetAssetThumbnailFormatEnum.WEBP: if (asset.webpPath) { - return [asset.webpPath, 'image/webp']; + return asset.webpPath; } this.logger.warn(`WebP thumbnail requested but not found for asset ${asset.id}, falling back to JPEG`); @@ -604,48 +587,48 @@ export class AssetService { if (!asset.resizePath) { throw new NotFoundException(`No thumbnail found for asset ${asset.id}`); } - return [asset.resizePath, 'image/jpeg']; + return asset.resizePath; } } - private getServePath(asset: AssetEntity, query: ServeFileDto, allowOriginalFile: boolean): ServableFile { + private getServePath(asset: AssetEntity, query: ServeFileDto, allowOriginalFile: boolean): string { + const mimeType = mimeTypes.lookup(asset.originalPath); + /** * Serve file viewer on the web */ - if (query.isWeb && asset.mimeType != 'image/gif') { + if (query.isWeb && mimeType != 'image/gif') { if (!asset.resizePath) { this.logger.error('Error serving IMAGE asset for web'); throw new InternalServerErrorException(`Failed to serve image asset for web`, 'ServeFile'); } - return { filepath: asset.resizePath, contentType: 'image/jpeg' }; + return asset.resizePath; } /** * Serve thumbnail image for both web and mobile app */ - if ((!query.isThumb && allowOriginalFile) || (query.isWeb && asset.mimeType === 'image/gif')) { - return { filepath: asset.originalPath, contentType: asset.mimeType as string }; + if ((!query.isThumb && allowOriginalFile) || (query.isWeb && mimeType === 'image/gif')) { + return asset.originalPath; } if (asset.webpPath && asset.webpPath.length > 0) { - return { filepath: asset.webpPath, contentType: 'image/webp' }; + return asset.webpPath; } if (!asset.resizePath) { throw new Error('resizePath not set'); } - return { filepath: asset.resizePath, contentType: 'image/jpeg' }; + return asset.resizePath; } - private async streamFile(filepath: string, res: Res, headers: Record, contentType?: string | null) { + private async streamFile(filepath: string, res: Res, headers: Record) { await fs.access(filepath, constants.R_OK); const { size, mtimeNs } = await fs.stat(filepath, { bigint: true }); - if (contentType) { - res.header('Content-Type', contentType); - } + res.header('Content-Type', mimeTypes.lookup(filepath)); const range = this.setResRange(res, headers, Number(size)); diff --git a/server/src/immich/app.interceptor.ts b/server/src/immich/app.interceptor.ts index 7a43ddefe..d6c4a3a7e 100644 --- a/server/src/immich/app.interceptor.ts +++ b/server/src/immich/app.interceptor.ts @@ -23,7 +23,6 @@ export interface ImmichFile extends Express.Multer.File { export function mapToUploadFile(file: ImmichFile): UploadFile { return { checksum: file.checksum, - mimeType: file.mimetype, originalPath: file.path, originalName: Buffer.from(file.originalname, 'latin1').toString('utf8'), }; diff --git a/server/src/infra/entities/asset.entity.ts b/server/src/infra/entities/asset.entity.ts index c070b5cd1..27d040bbb 100644 --- a/server/src/infra/entities/asset.entity.ts +++ b/server/src/infra/entities/asset.entity.ts @@ -78,9 +78,6 @@ export class AssetEntity { @Column({ type: 'boolean', default: false }) isReadOnly!: boolean; - @Column({ type: 'varchar', nullable: true }) - mimeType!: string | null; - @Column({ type: 'bytea' }) @Index() checksum!: Buffer; // sha1 checksum diff --git a/server/src/infra/migrations/1689001889950-DropMimeTypeColumn.ts b/server/src/infra/migrations/1689001889950-DropMimeTypeColumn.ts new file mode 100644 index 000000000..45559313a --- /dev/null +++ b/server/src/infra/migrations/1689001889950-DropMimeTypeColumn.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class DropMimeTypeColumn1689001889950 implements MigrationInterface { + name = 'DropMimeTypeColumn1689001889950' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "mimeType"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" ADD "mimeType" character varying`); + } + +} diff --git a/server/test/fixtures.ts b/server/test/fixtures.ts index 72a05a328..1c6f87e02 100644 --- a/server/test/fixtures.ts +++ b/server/test/fixtures.ts @@ -190,13 +190,11 @@ export const userEntityStub = { export const fileStub = { livePhotoStill: Object.freeze({ originalPath: 'fake_path/asset_1.jpeg', - mimeType: 'image/jpg', checksum: Buffer.from('file hash', 'utf8'), originalName: 'asset_1.jpeg', }), livePhotoMotion: Object.freeze({ originalPath: 'fake_path/asset_1.mp4', - mimeType: 'image/jpeg', checksum: Buffer.from('live photo file hash', 'utf8'), originalName: 'asset_1.mp4', }), @@ -221,7 +219,6 @@ export const assetEntityStub = { encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'), - mimeType: null, isFavorite: true, isArchived: false, duration: null, @@ -251,7 +248,6 @@ export const assetEntityStub = { encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'), - mimeType: null, isFavorite: true, isArchived: false, duration: null, @@ -285,7 +281,6 @@ export const assetEntityStub = { encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'), - mimeType: null, isFavorite: true, isArchived: false, isReadOnly: false, @@ -307,8 +302,8 @@ export const assetEntityStub = { owner: userEntityStub.user1, ownerId: 'user-id', deviceId: 'device-id', - originalPath: '/original/path.ext', - resizePath: '/uploads/user-id/thumbs/path.ext', + originalPath: '/original/path.jpg', + resizePath: '/uploads/user-id/thumbs/path.jpg', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, webpPath: '/uploads/user-id/webp/path.ext', @@ -316,7 +311,6 @@ export const assetEntityStub = { encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'), - mimeType: null, isFavorite: true, isArchived: false, isReadOnly: false, @@ -326,7 +320,7 @@ export const assetEntityStub = { livePhotoVideoId: null, tags: [], sharedLinks: [], - originalFileName: 'asset-id.ext', + originalFileName: 'asset-id.jpg', faces: [], sidecarPath: null, exifInfo: { @@ -351,7 +345,6 @@ export const assetEntityStub = { encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'), - mimeType: null, isFavorite: true, isArchived: false, isReadOnly: false, @@ -412,7 +405,6 @@ export const assetEntityStub = { encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'), - mimeType: null, isFavorite: false, isArchived: false, isReadOnly: false, @@ -447,7 +439,6 @@ export const assetEntityStub = { encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'), - mimeType: null, isFavorite: true, isArchived: false, isReadOnly: false, @@ -621,7 +612,6 @@ const assetResponse: AssetResponseDto = { updatedAt: today, isFavorite: false, isArchived: false, - mimeType: 'image/jpeg', smartInfo: { tags: [], objects: ['a', 'b', 'c'], @@ -909,7 +899,6 @@ export const sharedLinkStub = { isFavorite: false, isArchived: false, isReadOnly: false, - mimeType: 'image/jpeg', smartInfo: { assetId: 'id_1', tags: [], @@ -1136,7 +1125,7 @@ export const personStub = { ownerId: userEntityStub.admin.id, owner: userEntityStub.admin, name: '', - thumbnailPath: '/path/to/thumbnail', + thumbnailPath: '/path/to/thumbnail.jpg', faces: [], }), withName: Object.freeze({ @@ -1146,7 +1135,7 @@ export const personStub = { ownerId: userEntityStub.admin.id, owner: userEntityStub.admin, name: 'Person 1', - thumbnailPath: '/path/to/thumbnail', + thumbnailPath: '/path/to/thumbnail.jpg', faces: [], }), noThumbnail: Object.freeze({ @@ -1166,7 +1155,7 @@ export const personStub = { ownerId: userEntityStub.admin.id, owner: userEntityStub.admin, name: '', - thumbnailPath: '/new/path/to/thumbnail', + thumbnailPath: '/new/path/to/thumbnail.jpg', faces: [], }), }; diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 1292c7481..da6a8d174 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -679,12 +679,6 @@ export interface AssetResponseDto { * @memberof AssetResponseDto */ 'isArchived': boolean; - /** - * - * @type {string} - * @memberof AssetResponseDto - */ - 'mimeType': string | null; /** * * @type {string} From 1e7b657156575535f5152b8b30e2a84b16321cf9 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 11 Jul 2023 11:09:19 -0400 Subject: [PATCH 13/38] docs: upgrade deps (#3215) --- docs/package-lock.json | 1494 +++++++++++++++++++++------------------- docs/package.json | 10 +- 2 files changed, 798 insertions(+), 706 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 836c7e2e9..0e40f4689 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -8,14 +8,14 @@ "name": "documentation", "version": "0.0.0", "dependencies": { - "@docusaurus/core": "2.1.0", - "@docusaurus/preset-classic": "2.1.0", + "@docusaurus/core": "^2.4.1", + "@docusaurus/preset-classic": "^2.4.1", "@mdx-js/react": "^1.6.22", "autoprefixer": "^10.4.13", "clsx": "^1.2.1", "docusaurus-lunr-search": "^2.3.2", "docusaurus-preset-openapi": "^0.6.3", - "postcss": "^8.4.20", + "postcss": "^8.4.25", "prism-react-renderer": "^1.3.5", "react": "^17.0.2", "react-dom": "^17.0.2", @@ -23,29 +23,41 @@ "url": "^0.11.0" }, "devDependencies": { - "@docusaurus/module-type-aliases": "2.1.0", + "@docusaurus/module-type-aliases": "^2.4.1", "@tsconfig/docusaurus": "^1.0.5", "prettier": "^2.8.8", - "typescript": "^5.0.0" + "typescript": "^5.1.6" }, "engines": { "node": ">=16.14" } }, "node_modules/@algolia/autocomplete-core": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.7.2.tgz", - "integrity": "sha512-eclwUDC6qfApNnEfu1uWcL/rudQsn59tjEoUYZYE2JSXZrHLRjBUGMxiCoknobU2Pva8ejb0eRxpIYDtVVqdsw==", + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.9.3.tgz", + "integrity": "sha512-009HdfugtGCdC4JdXUbVJClA0q0zh24yyePn+KUGk3rP7j8FEe/m5Yo/z65gn6nP/cM39PxpzqKrL7A6fP6PPw==", "dependencies": { - "@algolia/autocomplete-shared": "1.7.2" + "@algolia/autocomplete-plugin-algolia-insights": "1.9.3", + "@algolia/autocomplete-shared": "1.9.3" + } + }, + "node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.9.3.tgz", + "integrity": "sha512-a/yTUkcO/Vyy+JffmAnTWbr4/90cLzw+CC3bRbhnULr/EM0fGNvM13oQQ14f2moLMcVDyAx/leczLlAOovhSZg==", + "dependencies": { + "@algolia/autocomplete-shared": "1.9.3" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" } }, "node_modules/@algolia/autocomplete-preset-algolia": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.7.2.tgz", - "integrity": "sha512-+RYEG6B0QiGGfRb2G3MtPfyrl0dALF3cQNTWBzBX6p5o01vCCGTTinAm2UKG3tfc2CnOMAtnPLkzNZyJUpnVJw==", + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.9.3.tgz", + "integrity": "sha512-d4qlt6YmrLMYy95n5TB52wtNDr6EgAIPH81dvvvW8UmuWRgxEtY0NJiPwl/h95JtG2vmRM804M0DSwMCNZlzRA==", "dependencies": { - "@algolia/autocomplete-shared": "1.7.2" + "@algolia/autocomplete-shared": "1.9.3" }, "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", @@ -53,79 +65,83 @@ } }, "node_modules/@algolia/autocomplete-shared": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.7.2.tgz", - "integrity": "sha512-QCckjiC7xXHIUaIL3ektBtjJ0w7tTA3iqKcAE/Hjn1lZ5omp7i3Y4e09rAr9ZybqirL7AbxCLLq0Ra5DDPKeug==" + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.9.3.tgz", + "integrity": "sha512-Wnm9E4Ye6Rl6sTTqjoymD+l8DjSTHsHboVRYrKgEt8Q7UHm9nYbqhN/i0fhUYA3OAEH7WA8x3jfpnmJm3rKvaQ==", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } }, "node_modules/@algolia/cache-browser-local-storage": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.14.2.tgz", - "integrity": "sha512-FRweBkK/ywO+GKYfAWbrepewQsPTIEirhi1BdykX9mxvBPtGNKccYAxvGdDCumU1jL4r3cayio4psfzKMejBlA==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.18.0.tgz", + "integrity": "sha512-rUAs49NLlO8LVLgGzM4cLkw8NJLKguQLgvFmBEe3DyzlinoqxzQMHfKZs6TSq4LZfw/z8qHvRo8NcTAAUJQLcw==", "dependencies": { - "@algolia/cache-common": "4.14.2" + "@algolia/cache-common": "4.18.0" } }, "node_modules/@algolia/cache-common": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/cache-common/-/cache-common-4.14.2.tgz", - "integrity": "sha512-SbvAlG9VqNanCErr44q6lEKD2qoK4XtFNx9Qn8FK26ePCI8I9yU7pYB+eM/cZdS9SzQCRJBbHUumVr4bsQ4uxg==" + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/cache-common/-/cache-common-4.18.0.tgz", + "integrity": "sha512-BmxsicMR4doGbeEXQu8yqiGmiyvpNvejYJtQ7rvzttEAMxOPoWEHrWyzBQw4x7LrBY9pMrgv4ZlUaF8PGzewHg==" }, "node_modules/@algolia/cache-in-memory": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/cache-in-memory/-/cache-in-memory-4.14.2.tgz", - "integrity": "sha512-HrOukWoop9XB/VFojPv1R5SVXowgI56T9pmezd/djh2JnVN/vXswhXV51RKy4nCpqxyHt/aGFSq2qkDvj6KiuQ==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/cache-in-memory/-/cache-in-memory-4.18.0.tgz", + "integrity": "sha512-evD4dA1nd5HbFdufBxLqlJoob7E2ozlqJZuV3YlirNx5Na4q1LckIuzjNYZs2ddLzuTc/Xd5O3Ibf7OwPskHxw==", "dependencies": { - "@algolia/cache-common": "4.14.2" + "@algolia/cache-common": "4.18.0" } }, "node_modules/@algolia/client-account": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/client-account/-/client-account-4.14.2.tgz", - "integrity": "sha512-WHtriQqGyibbb/Rx71YY43T0cXqyelEU0lB2QMBRXvD2X0iyeGl4qMxocgEIcbHyK7uqE7hKgjT8aBrHqhgc1w==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/client-account/-/client-account-4.18.0.tgz", + "integrity": "sha512-XsDnlROr3+Z1yjxBJjUMfMazi1V155kVdte6496atvBgOEtwCzTs3A+qdhfsAnGUvaYfBrBkL0ThnhMIBCGcew==", "dependencies": { - "@algolia/client-common": "4.14.2", - "@algolia/client-search": "4.14.2", - "@algolia/transporter": "4.14.2" + "@algolia/client-common": "4.18.0", + "@algolia/client-search": "4.18.0", + "@algolia/transporter": "4.18.0" } }, "node_modules/@algolia/client-analytics": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-4.14.2.tgz", - "integrity": "sha512-yBvBv2mw+HX5a+aeR0dkvUbFZsiC4FKSnfqk9rrfX+QrlNOKEhCG0tJzjiOggRW4EcNqRmaTULIYvIzQVL2KYQ==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-4.18.0.tgz", + "integrity": "sha512-chEUSN4ReqU7uRQ1C8kDm0EiPE+eJeAXiWcBwLhEynfNuTfawN9P93rSZktj7gmExz0C8XmkbBU19IQ05wCNrQ==", "dependencies": { - "@algolia/client-common": "4.14.2", - "@algolia/client-search": "4.14.2", - "@algolia/requester-common": "4.14.2", - "@algolia/transporter": "4.14.2" + "@algolia/client-common": "4.18.0", + "@algolia/client-search": "4.18.0", + "@algolia/requester-common": "4.18.0", + "@algolia/transporter": "4.18.0" } }, "node_modules/@algolia/client-common": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.14.2.tgz", - "integrity": "sha512-43o4fslNLcktgtDMVaT5XwlzsDPzlqvqesRi4MjQz2x4/Sxm7zYg5LRYFol1BIhG6EwxKvSUq8HcC/KxJu3J0Q==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.18.0.tgz", + "integrity": "sha512-7N+soJFP4wn8tjTr3MSUT/U+4xVXbz4jmeRfWfVAzdAbxLAQbHa0o/POSdTvQ8/02DjCLelloZ1bb4ZFVKg7Wg==", "dependencies": { - "@algolia/requester-common": "4.14.2", - "@algolia/transporter": "4.14.2" + "@algolia/requester-common": "4.18.0", + "@algolia/transporter": "4.18.0" } }, "node_modules/@algolia/client-personalization": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-4.14.2.tgz", - "integrity": "sha512-ACCoLi0cL8CBZ1W/2juehSltrw2iqsQBnfiu/Rbl9W2yE6o2ZUb97+sqN/jBqYNQBS+o0ekTMKNkQjHHAcEXNw==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-4.18.0.tgz", + "integrity": "sha512-+PeCjODbxtamHcPl+couXMeHEefpUpr7IHftj4Y4Nia1hj8gGq4VlIcqhToAw8YjLeCTfOR7r7xtj3pJcYdP8A==", "dependencies": { - "@algolia/client-common": "4.14.2", - "@algolia/requester-common": "4.14.2", - "@algolia/transporter": "4.14.2" + "@algolia/client-common": "4.18.0", + "@algolia/requester-common": "4.18.0", + "@algolia/transporter": "4.18.0" } }, "node_modules/@algolia/client-search": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.14.2.tgz", - "integrity": "sha512-L5zScdOmcZ6NGiVbLKTvP02UbxZ0njd5Vq9nJAmPFtjffUSOGEp11BmD2oMJ5QvARgx2XbX4KzTTNS5ECYIMWw==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.18.0.tgz", + "integrity": "sha512-F9xzQXTjm6UuZtnsLIew6KSraXQ0AzS/Ee+OD+mQbtcA/K1sg89tqb8TkwjtiYZ0oij13u3EapB3gPZwm+1Y6g==", "dependencies": { - "@algolia/client-common": "4.14.2", - "@algolia/requester-common": "4.14.2", - "@algolia/transporter": "4.14.2" + "@algolia/client-common": "4.18.0", + "@algolia/requester-common": "4.18.0", + "@algolia/transporter": "4.18.0" } }, "node_modules/@algolia/events": { @@ -134,47 +150,47 @@ "integrity": "sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ==" }, "node_modules/@algolia/logger-common": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.14.2.tgz", - "integrity": "sha512-/JGlYvdV++IcMHBnVFsqEisTiOeEr6cUJtpjz8zc0A9c31JrtLm318Njc72p14Pnkw3A/5lHHh+QxpJ6WFTmsA==" + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.18.0.tgz", + "integrity": "sha512-46etYgSlkoKepkMSyaoriSn2JDgcrpc/nkOgou/lm0y17GuMl9oYZxwKKTSviLKI5Irk9nSKGwnBTQYwXOYdRg==" }, "node_modules/@algolia/logger-console": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/logger-console/-/logger-console-4.14.2.tgz", - "integrity": "sha512-8S2PlpdshbkwlLCSAB5f8c91xyc84VM9Ar9EdfE9UmX+NrKNYnWR1maXXVDQQoto07G1Ol/tYFnFVhUZq0xV/g==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/logger-console/-/logger-console-4.18.0.tgz", + "integrity": "sha512-3P3VUYMl9CyJbi/UU1uUNlf6Z8N2ltW3Oqhq/nR7vH0CjWv32YROq3iGWGxB2xt3aXobdUPXs6P0tHSKRmNA6g==", "dependencies": { - "@algolia/logger-common": "4.14.2" + "@algolia/logger-common": "4.18.0" } }, "node_modules/@algolia/requester-browser-xhr": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.14.2.tgz", - "integrity": "sha512-CEh//xYz/WfxHFh7pcMjQNWgpl4wFB85lUMRyVwaDPibNzQRVcV33YS+63fShFWc2+42YEipFGH2iPzlpszmDw==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.18.0.tgz", + "integrity": "sha512-/AcWHOBub2U4TE/bPi4Gz1XfuLK6/7dj4HJG+Z2SfQoS1RjNLshZclU3OoKIkFp8D2NC7+BNsPvr9cPLyW8nyQ==", "dependencies": { - "@algolia/requester-common": "4.14.2" + "@algolia/requester-common": "4.18.0" } }, "node_modules/@algolia/requester-common": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-common/-/requester-common-4.14.2.tgz", - "integrity": "sha512-73YQsBOKa5fvVV3My7iZHu1sUqmjjfs9TteFWwPwDmnad7T0VTCopttcsM3OjLxZFtBnX61Xxl2T2gmG2O4ehg==" + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-common/-/requester-common-4.18.0.tgz", + "integrity": "sha512-xlT8R1qYNRBCi1IYLsx7uhftzdfsLPDGudeQs+xvYB4sQ3ya7+ppolB/8m/a4F2gCkEO6oxpp5AGemM7kD27jA==" }, "node_modules/@algolia/requester-node-http": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-4.14.2.tgz", - "integrity": "sha512-oDbb02kd1o5GTEld4pETlPZLY0e+gOSWjWMJHWTgDXbv9rm/o2cF7japO6Vj1ENnrqWvLBmW1OzV9g6FUFhFXg==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-4.18.0.tgz", + "integrity": "sha512-TGfwj9aeTVgOUhn5XrqBhwUhUUDnGIKlI0kCBMdR58XfXcfdwomka+CPIgThRbfYw04oQr31A6/95ZH2QVJ9UQ==", "dependencies": { - "@algolia/requester-common": "4.14.2" + "@algolia/requester-common": "4.18.0" } }, "node_modules/@algolia/transporter": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/transporter/-/transporter-4.14.2.tgz", - "integrity": "sha512-t89dfQb2T9MFQHidjHcfhh6iGMNwvuKUvojAj+JsrHAGbuSy7yE4BylhLX6R0Q1xYRoC4Vvv+O5qIw/LdnQfsQ==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/transporter/-/transporter-4.18.0.tgz", + "integrity": "sha512-xbw3YRUGtXQNG1geYFEDDuFLZt4Z8YNKbamHPkzr3rWc6qp4/BqEeXcI2u/P/oMq2yxtXgMxrCxOPA8lyIe5jw==", "dependencies": { - "@algolia/cache-common": "4.14.2", - "@algolia/logger-common": "4.14.2", - "@algolia/requester-common": "4.14.2" + "@algolia/cache-common": "4.18.0", + "@algolia/logger-common": "4.18.0", + "@algolia/requester-common": "4.18.0" } }, "node_modules/@alloc/quick-lru": { @@ -1902,11 +1918,11 @@ } }, "node_modules/@babel/runtime": { - "version": "7.19.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.4.tgz", - "integrity": "sha512-EXpLCrk55f+cYqmHsSR+yD/0gAIMxxA9QK9lnQWzhMCvt+YmoBN7Zx94s++Kv0+unHk39vxNO8t+CMA2WSS3wA==", + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.6.tgz", + "integrity": "sha512-wDb5pWm4WDdF6LFUde3Jl8WzPA+3ZbxYqkC6xAXuD3irdEHN1k0NfTRrJD8ZD378SJ61miMLCqIOXYhd8x+AJQ==", "dependencies": { - "regenerator-runtime": "^0.13.4" + "regenerator-runtime": "^0.13.11" }, "engines": { "node": ">=6.9.0" @@ -1980,18 +1996,18 @@ } }, "node_modules/@docsearch/css": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.3.0.tgz", - "integrity": "sha512-rODCdDtGyudLj+Va8b6w6Y85KE85bXRsps/R4Yjwt5vueXKXZQKYw0aA9knxLBT6a/bI/GMrAcmCR75KYOM6hg==" + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.5.1.tgz", + "integrity": "sha512-2Pu9HDg/uP/IT10rbQ+4OrTQuxIWdKVUEdcw9/w7kZJv9NeHS6skJx1xuRiFyoGKwAzcHXnLp7csE99sj+O1YA==" }, "node_modules/@docsearch/react": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.3.0.tgz", - "integrity": "sha512-fhS5adZkae2SSdMYEMVg6pxI5a/cE+tW16ki1V0/ur4Fdok3hBRkmN/H8VvlXnxzggkQIIRIVvYPn00JPjen3A==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.5.1.tgz", + "integrity": "sha512-t5mEODdLzZq4PTFAm/dvqcvZFdPDMdfPE5rJS5SC8OUq9mPzxEy6b+9THIqNM9P0ocCb4UC5jqBrxKclnuIbzQ==", "dependencies": { - "@algolia/autocomplete-core": "1.7.2", - "@algolia/autocomplete-preset-algolia": "1.7.2", - "@docsearch/css": "3.3.0", + "@algolia/autocomplete-core": "1.9.3", + "@algolia/autocomplete-preset-algolia": "1.9.3", + "@docsearch/css": "3.5.1", "algoliasearch": "^4.0.0" }, "peerDependencies": { @@ -2012,9 +2028,9 @@ } }, "node_modules/@docusaurus/core": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-2.1.0.tgz", - "integrity": "sha512-/ZJ6xmm+VB9Izbn0/s6h6289cbPy2k4iYFwWDhjiLsVqwa/Y0YBBcXvStfaHccudUC3OfP+26hMk7UCjc50J6Q==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-2.4.1.tgz", + "integrity": "sha512-SNsY7PshK3Ri7vtsLXVeAJGS50nJN3RgF836zkyUfAD01Fq+sAk5EwWgLw+nnm5KVNGDu7PRR2kRGDsWvqpo0g==", "dependencies": { "@babel/core": "^7.18.6", "@babel/generator": "^7.18.7", @@ -2026,13 +2042,13 @@ "@babel/runtime": "^7.18.6", "@babel/runtime-corejs3": "^7.18.6", "@babel/traverse": "^7.18.8", - "@docusaurus/cssnano-preset": "2.1.0", - "@docusaurus/logger": "2.1.0", - "@docusaurus/mdx-loader": "2.1.0", + "@docusaurus/cssnano-preset": "2.4.1", + "@docusaurus/logger": "2.4.1", + "@docusaurus/mdx-loader": "2.4.1", "@docusaurus/react-loadable": "5.5.2", - "@docusaurus/utils": "2.1.0", - "@docusaurus/utils-common": "2.1.0", - "@docusaurus/utils-validation": "2.1.0", + "@docusaurus/utils": "2.4.1", + "@docusaurus/utils-common": "2.4.1", + "@docusaurus/utils-validation": "2.4.1", "@slorber/static-site-generator-webpack-plugin": "^4.0.7", "@svgr/webpack": "^6.2.1", "autoprefixer": "^10.4.7", @@ -2053,7 +2069,7 @@ "del": "^6.1.1", "detect-port": "^1.3.0", "escape-html": "^1.0.3", - "eta": "^1.12.3", + "eta": "^2.0.0", "file-loader": "^6.2.0", "fs-extra": "^10.1.0", "html-minifier-terser": "^6.1.0", @@ -2100,9 +2116,9 @@ } }, "node_modules/@docusaurus/cssnano-preset": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-2.1.0.tgz", - "integrity": "sha512-pRLewcgGhOies6pzsUROfmPStDRdFw+FgV5sMtLr5+4Luv2rty5+b/eSIMMetqUsmg3A9r9bcxHk9bKAKvx3zQ==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-2.4.1.tgz", + "integrity": "sha512-ka+vqXwtcW1NbXxWsh6yA1Ckii1klY9E53cJ4O9J09nkMBgrNX3iEFED1fWdv8wf4mJjvGi5RLZ2p9hJNjsLyQ==", "dependencies": { "cssnano-preset-advanced": "^5.3.8", "postcss": "^8.4.14", @@ -2114,9 +2130,9 @@ } }, "node_modules/@docusaurus/logger": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-2.1.0.tgz", - "integrity": "sha512-uuJx2T6hDBg82joFeyobywPjSOIfeq05GfyKGHThVoXuXsu1KAzMDYcjoDxarb9CoHCI/Dor8R2MoL6zII8x1Q==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-2.4.1.tgz", + "integrity": "sha512-5h5ysIIWYIDHyTVd8BjheZmQZmEgWDR54aQ1BX9pjFfpyzFo5puKXKYrYJXbjEHGyVhEzmB9UXwbxGfaZhOjcg==", "dependencies": { "chalk": "^4.1.2", "tslib": "^2.4.0" @@ -2126,14 +2142,14 @@ } }, "node_modules/@docusaurus/mdx-loader": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-2.1.0.tgz", - "integrity": "sha512-i97hi7hbQjsD3/8OSFhLy7dbKGH8ryjEzOfyhQIn2CFBYOY3ko0vMVEf3IY9nD3Ld7amYzsZ8153RPkcnXA+Lg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-2.4.1.tgz", + "integrity": "sha512-4KhUhEavteIAmbBj7LVFnrVYDiU51H5YWW1zY6SmBSte/YLhDutztLTBE0PQl1Grux1jzUJeaSvAzHpTn6JJDQ==", "dependencies": { "@babel/parser": "^7.18.8", "@babel/traverse": "^7.18.8", - "@docusaurus/logger": "2.1.0", - "@docusaurus/utils": "2.1.0", + "@docusaurus/logger": "2.4.1", + "@docusaurus/utils": "2.4.1", "@mdx-js/mdx": "^1.6.22", "escape-html": "^1.0.3", "file-loader": "^6.2.0", @@ -2157,12 +2173,12 @@ } }, "node_modules/@docusaurus/module-type-aliases": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-2.1.0.tgz", - "integrity": "sha512-Z8WZaK5cis3xEtyfOT817u9xgGUauT0PuuVo85ysnFRX8n7qLN1lTPCkC+aCmFm/UcV8h/W5T4NtIsst94UntQ==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-2.4.1.tgz", + "integrity": "sha512-gLBuIFM8Dp2XOCWffUDSjtxY7jQgKvYujt7Mx5s4FCTfoL5dN1EVbnrn+O2Wvh8b0a77D57qoIDY7ghgmatR1A==", "dependencies": { "@docusaurus/react-loadable": "5.5.2", - "@docusaurus/types": "2.1.0", + "@docusaurus/types": "2.4.1", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -2176,17 +2192,17 @@ } }, "node_modules/@docusaurus/plugin-content-blog": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-2.1.0.tgz", - "integrity": "sha512-xEp6jlu92HMNUmyRBEeJ4mCW1s77aAEQO4Keez94cUY/Ap7G/r0Awa6xSLff7HL0Fjg8KK1bEbDy7q9voIavdg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-2.4.1.tgz", + "integrity": "sha512-E2i7Knz5YIbE1XELI6RlTnZnGgS52cUO4BlCiCUCvQHbR+s1xeIWz4C6BtaVnlug0Ccz7nFSksfwDpVlkujg5Q==", "dependencies": { - "@docusaurus/core": "2.1.0", - "@docusaurus/logger": "2.1.0", - "@docusaurus/mdx-loader": "2.1.0", - "@docusaurus/types": "2.1.0", - "@docusaurus/utils": "2.1.0", - "@docusaurus/utils-common": "2.1.0", - "@docusaurus/utils-validation": "2.1.0", + "@docusaurus/core": "2.4.1", + "@docusaurus/logger": "2.4.1", + "@docusaurus/mdx-loader": "2.4.1", + "@docusaurus/types": "2.4.1", + "@docusaurus/utils": "2.4.1", + "@docusaurus/utils-common": "2.4.1", + "@docusaurus/utils-validation": "2.4.1", "cheerio": "^1.0.0-rc.12", "feed": "^4.2.2", "fs-extra": "^10.1.0", @@ -2206,17 +2222,17 @@ } }, "node_modules/@docusaurus/plugin-content-docs": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-2.1.0.tgz", - "integrity": "sha512-Rup5pqXrXlKGIC4VgwvioIhGWF7E/NNSlxv+JAxRYpik8VKlWsk9ysrdHIlpX+KJUCO9irnY21kQh2814mlp/Q==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-2.4.1.tgz", + "integrity": "sha512-Lo7lSIcpswa2Kv4HEeUcGYqaasMUQNpjTXpV0N8G6jXgZaQurqp7E8NGYeGbDXnb48czmHWbzDL4S3+BbK0VzA==", "dependencies": { - "@docusaurus/core": "2.1.0", - "@docusaurus/logger": "2.1.0", - "@docusaurus/mdx-loader": "2.1.0", - "@docusaurus/module-type-aliases": "2.1.0", - "@docusaurus/types": "2.1.0", - "@docusaurus/utils": "2.1.0", - "@docusaurus/utils-validation": "2.1.0", + "@docusaurus/core": "2.4.1", + "@docusaurus/logger": "2.4.1", + "@docusaurus/mdx-loader": "2.4.1", + "@docusaurus/module-type-aliases": "2.4.1", + "@docusaurus/types": "2.4.1", + "@docusaurus/utils": "2.4.1", + "@docusaurus/utils-validation": "2.4.1", "@types/react-router-config": "^5.0.6", "combine-promises": "^1.1.0", "fs-extra": "^10.1.0", @@ -2236,15 +2252,15 @@ } }, "node_modules/@docusaurus/plugin-content-pages": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-2.1.0.tgz", - "integrity": "sha512-SwZdDZRlObHNKXTnFo7W2aF6U5ZqNVI55Nw2GCBryL7oKQSLeI0lsrMlMXdzn+fS7OuBTd3MJBO1T4Zpz0i/+g==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-2.4.1.tgz", + "integrity": "sha512-/UjuH/76KLaUlL+o1OvyORynv6FURzjurSjvn2lbWTFc4tpYY2qLYTlKpTCBVPhlLUQsfyFnshEJDLmPneq2oA==", "dependencies": { - "@docusaurus/core": "2.1.0", - "@docusaurus/mdx-loader": "2.1.0", - "@docusaurus/types": "2.1.0", - "@docusaurus/utils": "2.1.0", - "@docusaurus/utils-validation": "2.1.0", + "@docusaurus/core": "2.4.1", + "@docusaurus/mdx-loader": "2.4.1", + "@docusaurus/types": "2.4.1", + "@docusaurus/utils": "2.4.1", + "@docusaurus/utils-validation": "2.4.1", "fs-extra": "^10.1.0", "tslib": "^2.4.0", "webpack": "^5.73.0" @@ -2258,13 +2274,13 @@ } }, "node_modules/@docusaurus/plugin-debug": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-2.1.0.tgz", - "integrity": "sha512-8wsDq3OIfiy6440KLlp/qT5uk+WRHQXIXklNHEeZcar+Of0TZxCNe2FBpv+bzb/0qcdP45ia5i5WmR5OjN6DPw==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-2.4.1.tgz", + "integrity": "sha512-7Yu9UPzRShlrH/G8btOpR0e6INFZr0EegWplMjOqelIwAcx3PKyR8mgPTxGTxcqiYj6hxSCRN0D8R7YrzImwNA==", "dependencies": { - "@docusaurus/core": "2.1.0", - "@docusaurus/types": "2.1.0", - "@docusaurus/utils": "2.1.0", + "@docusaurus/core": "2.4.1", + "@docusaurus/types": "2.4.1", + "@docusaurus/utils": "2.4.1", "fs-extra": "^10.1.0", "react-json-view": "^1.21.3", "tslib": "^2.4.0" @@ -2278,13 +2294,13 @@ } }, "node_modules/@docusaurus/plugin-google-analytics": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-2.1.0.tgz", - "integrity": "sha512-4cgeqIly/wcFVbbWP03y1QJJBgH8W+Bv6AVbWnsXNOZa1yB3AO6hf3ZdeQH9x20v9T2pREogVgAH0rSoVnNsgg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-2.4.1.tgz", + "integrity": "sha512-dyZJdJiCoL+rcfnm0RPkLt/o732HvLiEwmtoNzOoz9MSZz117UH2J6U2vUDtzUzwtFLIf32KkeyzisbwUCgcaQ==", "dependencies": { - "@docusaurus/core": "2.1.0", - "@docusaurus/types": "2.1.0", - "@docusaurus/utils-validation": "2.1.0", + "@docusaurus/core": "2.4.1", + "@docusaurus/types": "2.4.1", + "@docusaurus/utils-validation": "2.4.1", "tslib": "^2.4.0" }, "engines": { @@ -2296,13 +2312,31 @@ } }, "node_modules/@docusaurus/plugin-google-gtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-2.1.0.tgz", - "integrity": "sha512-/3aDlv2dMoCeiX2e+DTGvvrdTA+v3cKQV3DbmfsF4ENhvc5nKV23nth04Z3Vq0Ci1ui6Sn80TkhGk/tiCMW2AA==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-2.4.1.tgz", + "integrity": "sha512-mKIefK+2kGTQBYvloNEKtDmnRD7bxHLsBcxgnbt4oZwzi2nxCGjPX6+9SQO2KCN5HZbNrYmGo5GJfMgoRvy6uA==", "dependencies": { - "@docusaurus/core": "2.1.0", - "@docusaurus/types": "2.1.0", - "@docusaurus/utils-validation": "2.1.0", + "@docusaurus/core": "2.4.1", + "@docusaurus/types": "2.4.1", + "@docusaurus/utils-validation": "2.4.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.14" + }, + "peerDependencies": { + "react": "^16.8.4 || ^17.0.0", + "react-dom": "^16.8.4 || ^17.0.0" + } + }, + "node_modules/@docusaurus/plugin-google-tag-manager": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-2.4.1.tgz", + "integrity": "sha512-Zg4Ii9CMOLfpeV2nG74lVTWNtisFaH9QNtEw48R5QE1KIwDBdTVaiSA18G1EujZjrzJJzXN79VhINSbOJO/r3g==", + "dependencies": { + "@docusaurus/core": "2.4.1", + "@docusaurus/types": "2.4.1", + "@docusaurus/utils-validation": "2.4.1", "tslib": "^2.4.0" }, "engines": { @@ -2314,16 +2348,16 @@ } }, "node_modules/@docusaurus/plugin-sitemap": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-2.1.0.tgz", - "integrity": "sha512-2Y6Br8drlrZ/jN9MwMBl0aoi9GAjpfyfMBYpaQZXimbK+e9VjYnujXlvQ4SxtM60ASDgtHIAzfVFBkSR/MwRUw==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-2.4.1.tgz", + "integrity": "sha512-lZx+ijt/+atQ3FVE8FOHV/+X3kuok688OydDXrqKRJyXBJZKgGjA2Qa8RjQ4f27V2woaXhtnyrdPop/+OjVMRg==", "dependencies": { - "@docusaurus/core": "2.1.0", - "@docusaurus/logger": "2.1.0", - "@docusaurus/types": "2.1.0", - "@docusaurus/utils": "2.1.0", - "@docusaurus/utils-common": "2.1.0", - "@docusaurus/utils-validation": "2.1.0", + "@docusaurus/core": "2.4.1", + "@docusaurus/logger": "2.4.1", + "@docusaurus/types": "2.4.1", + "@docusaurus/utils": "2.4.1", + "@docusaurus/utils-common": "2.4.1", + "@docusaurus/utils-validation": "2.4.1", "fs-extra": "^10.1.0", "sitemap": "^7.1.1", "tslib": "^2.4.0" @@ -2337,22 +2371,23 @@ } }, "node_modules/@docusaurus/preset-classic": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-2.1.0.tgz", - "integrity": "sha512-NQMnaq974K4BcSMXFSJBQ5itniw6RSyW+VT+6i90kGZzTwiuKZmsp0r9lC6BYAvvVMQUNJQwrETmlu7y2XKW7w==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-2.4.1.tgz", + "integrity": "sha512-P4//+I4zDqQJ+UDgoFrjIFaQ1MeS9UD1cvxVQaI6O7iBmiHQm0MGROP1TbE7HlxlDPXFJjZUK3x3cAoK63smGQ==", "dependencies": { - "@docusaurus/core": "2.1.0", - "@docusaurus/plugin-content-blog": "2.1.0", - "@docusaurus/plugin-content-docs": "2.1.0", - "@docusaurus/plugin-content-pages": "2.1.0", - "@docusaurus/plugin-debug": "2.1.0", - "@docusaurus/plugin-google-analytics": "2.1.0", - "@docusaurus/plugin-google-gtag": "2.1.0", - "@docusaurus/plugin-sitemap": "2.1.0", - "@docusaurus/theme-classic": "2.1.0", - "@docusaurus/theme-common": "2.1.0", - "@docusaurus/theme-search-algolia": "2.1.0", - "@docusaurus/types": "2.1.0" + "@docusaurus/core": "2.4.1", + "@docusaurus/plugin-content-blog": "2.4.1", + "@docusaurus/plugin-content-docs": "2.4.1", + "@docusaurus/plugin-content-pages": "2.4.1", + "@docusaurus/plugin-debug": "2.4.1", + "@docusaurus/plugin-google-analytics": "2.4.1", + "@docusaurus/plugin-google-gtag": "2.4.1", + "@docusaurus/plugin-google-tag-manager": "2.4.1", + "@docusaurus/plugin-sitemap": "2.4.1", + "@docusaurus/theme-classic": "2.4.1", + "@docusaurus/theme-common": "2.4.1", + "@docusaurus/theme-search-algolia": "2.4.1", + "@docusaurus/types": "2.4.1" }, "engines": { "node": ">=16.14" @@ -2375,26 +2410,26 @@ } }, "node_modules/@docusaurus/theme-classic": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-2.1.0.tgz", - "integrity": "sha512-xn8ZfNMsf7gaSy9+ClFnUu71o7oKgMo5noYSS1hy3svNifRTkrBp6+MReLDsmIaj3mLf2e7+JCBYKBFbaGzQng==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-2.4.1.tgz", + "integrity": "sha512-Rz0wKUa+LTW1PLXmwnf8mn85EBzaGSt6qamqtmnh9Hflkc+EqiYMhtUJeLdV+wsgYq4aG0ANc+bpUDpsUhdnwg==", "dependencies": { - "@docusaurus/core": "2.1.0", - "@docusaurus/mdx-loader": "2.1.0", - "@docusaurus/module-type-aliases": "2.1.0", - "@docusaurus/plugin-content-blog": "2.1.0", - "@docusaurus/plugin-content-docs": "2.1.0", - "@docusaurus/plugin-content-pages": "2.1.0", - "@docusaurus/theme-common": "2.1.0", - "@docusaurus/theme-translations": "2.1.0", - "@docusaurus/types": "2.1.0", - "@docusaurus/utils": "2.1.0", - "@docusaurus/utils-common": "2.1.0", - "@docusaurus/utils-validation": "2.1.0", + "@docusaurus/core": "2.4.1", + "@docusaurus/mdx-loader": "2.4.1", + "@docusaurus/module-type-aliases": "2.4.1", + "@docusaurus/plugin-content-blog": "2.4.1", + "@docusaurus/plugin-content-docs": "2.4.1", + "@docusaurus/plugin-content-pages": "2.4.1", + "@docusaurus/theme-common": "2.4.1", + "@docusaurus/theme-translations": "2.4.1", + "@docusaurus/types": "2.4.1", + "@docusaurus/utils": "2.4.1", + "@docusaurus/utils-common": "2.4.1", + "@docusaurus/utils-validation": "2.4.1", "@mdx-js/react": "^1.6.22", "clsx": "^1.2.1", "copy-text-to-clipboard": "^3.0.1", - "infima": "0.2.0-alpha.42", + "infima": "0.2.0-alpha.43", "lodash": "^4.17.21", "nprogress": "^0.2.0", "postcss": "^8.4.14", @@ -2414,16 +2449,17 @@ } }, "node_modules/@docusaurus/theme-common": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-2.1.0.tgz", - "integrity": "sha512-vT1otpVPbKux90YpZUnvknsn5zvpLf+AW1W0EDcpE9up4cDrPqfsh0QoxGHFJnobE2/qftsBFC19BneN4BH8Ag==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-2.4.1.tgz", + "integrity": "sha512-G7Zau1W5rQTaFFB3x3soQoZpkgMbl/SYNG8PfMFIjKa3M3q8n0m/GRf5/H/e5BqOvt8c+ZWIXGCiz+kUCSHovA==", "dependencies": { - "@docusaurus/mdx-loader": "2.1.0", - "@docusaurus/module-type-aliases": "2.1.0", - "@docusaurus/plugin-content-blog": "2.1.0", - "@docusaurus/plugin-content-docs": "2.1.0", - "@docusaurus/plugin-content-pages": "2.1.0", - "@docusaurus/utils": "2.1.0", + "@docusaurus/mdx-loader": "2.4.1", + "@docusaurus/module-type-aliases": "2.4.1", + "@docusaurus/plugin-content-blog": "2.4.1", + "@docusaurus/plugin-content-docs": "2.4.1", + "@docusaurus/plugin-content-pages": "2.4.1", + "@docusaurus/utils": "2.4.1", + "@docusaurus/utils-common": "2.4.1", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -2431,6 +2467,7 @@ "parse-numeric-range": "^1.3.0", "prism-react-renderer": "^1.3.5", "tslib": "^2.4.0", + "use-sync-external-store": "^1.2.0", "utility-types": "^3.10.0" }, "engines": { @@ -2442,22 +2479,22 @@ } }, "node_modules/@docusaurus/theme-search-algolia": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-2.1.0.tgz", - "integrity": "sha512-rNBvi35VvENhucslEeVPOtbAzBdZY/9j55gdsweGV5bYoAXy4mHB6zTGjealcB4pJ6lJY4a5g75fXXMOlUqPfg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-2.4.1.tgz", + "integrity": "sha512-6BcqW2lnLhZCXuMAvPRezFs1DpmEKzXFKlYjruuas+Xy3AQeFzDJKTJFIm49N77WFCTyxff8d3E4Q9pi/+5McQ==", "dependencies": { "@docsearch/react": "^3.1.1", - "@docusaurus/core": "2.1.0", - "@docusaurus/logger": "2.1.0", - "@docusaurus/plugin-content-docs": "2.1.0", - "@docusaurus/theme-common": "2.1.0", - "@docusaurus/theme-translations": "2.1.0", - "@docusaurus/utils": "2.1.0", - "@docusaurus/utils-validation": "2.1.0", + "@docusaurus/core": "2.4.1", + "@docusaurus/logger": "2.4.1", + "@docusaurus/plugin-content-docs": "2.4.1", + "@docusaurus/theme-common": "2.4.1", + "@docusaurus/theme-translations": "2.4.1", + "@docusaurus/utils": "2.4.1", + "@docusaurus/utils-validation": "2.4.1", "algoliasearch": "^4.13.1", "algoliasearch-helper": "^3.10.0", "clsx": "^1.2.1", - "eta": "^1.12.3", + "eta": "^2.0.0", "fs-extra": "^10.1.0", "lodash": "^4.17.21", "tslib": "^2.4.0", @@ -2472,9 +2509,9 @@ } }, "node_modules/@docusaurus/theme-translations": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-2.1.0.tgz", - "integrity": "sha512-07n2akf2nqWvtJeMy3A+7oSGMuu5F673AovXVwY0aGAux1afzGCiqIFlYW3EP0CujvDJAEFSQi/Tetfh+95JNg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-2.4.1.tgz", + "integrity": "sha512-T1RAGP+f86CA1kfE8ejZ3T3pUU3XcyvrGMfC/zxCtc2BsnoexuNI9Vk2CmuKCb+Tacvhxjv5unhxXce0+NKyvA==", "dependencies": { "fs-extra": "^10.1.0", "tslib": "^2.4.0" @@ -2484,9 +2521,9 @@ } }, "node_modules/@docusaurus/types": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-2.1.0.tgz", - "integrity": "sha512-BS1ebpJZnGG6esKqsjtEC9U9qSaPylPwlO7cQ1GaIE7J/kMZI3FITnNn0otXXu7c7ZTqhb6+8dOrG6fZn6fqzQ==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-2.4.1.tgz", + "integrity": "sha512-0R+cbhpMkhbRXX138UOc/2XZFF8hiZa6ooZAEEJFp5scytzCw4tC1gChMFXrpa3d2tYE6AX8IrOEpSonLmfQuQ==", "dependencies": { "@types/history": "^4.7.11", "@types/react": "*", @@ -2503,12 +2540,13 @@ } }, "node_modules/@docusaurus/utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-2.1.0.tgz", - "integrity": "sha512-fPvrfmAuC54n8MjZuG4IysaMdmvN5A/qr7iFLbSGSyDrsbP4fnui6KdZZIa/YOLIPLec8vjZ8RIITJqF18mx4A==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-2.4.1.tgz", + "integrity": "sha512-1lvEZdAQhKNht9aPXPoh69eeKnV0/62ROhQeFKKxmzd0zkcuE/Oc5Gpnt00y/f5bIsmOsYMY7Pqfm/5rteT5GA==", "dependencies": { - "@docusaurus/logger": "2.1.0", + "@docusaurus/logger": "2.4.1", "@svgr/webpack": "^6.2.1", + "escape-string-regexp": "^4.0.0", "file-loader": "^6.2.0", "fs-extra": "^10.1.0", "github-slugger": "^1.4.0", @@ -2536,9 +2574,9 @@ } }, "node_modules/@docusaurus/utils-common": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-2.1.0.tgz", - "integrity": "sha512-F2vgmt4yRFgRQR2vyEFGTWeyAdmgKbtmu3sjHObF0tjjx/pN0Iw/c6eCopaH34E6tc9nO0nvp01pwW+/86d1fg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-2.4.1.tgz", + "integrity": "sha512-bCVGdZU+z/qVcIiEQdyx0K13OC5mYwxhSuDUR95oFbKVuXYRrTVrwZIqQljuo1fyJvFTKHiL9L9skQOPokuFNQ==", "dependencies": { "tslib": "^2.4.0" }, @@ -2555,12 +2593,12 @@ } }, "node_modules/@docusaurus/utils-validation": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-2.1.0.tgz", - "integrity": "sha512-AMJzWYKL3b7FLltKtDXNLO9Y649V2BXvrnRdnW2AA+PpBnYV78zKLSCz135cuWwRj1ajNtP4onbXdlnyvCijGQ==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-2.4.1.tgz", + "integrity": "sha512-unII3hlJlDwZ3w8U+pMO3Lx3RhI4YEbY3YNsQj4yzrkZzlpqZOLuAiZK2JyULnD+TKbceKU0WyWkQXtYbLNDFA==", "dependencies": { - "@docusaurus/logger": "2.1.0", - "@docusaurus/utils": "2.1.0", + "@docusaurus/logger": "2.4.1", + "@docusaurus/utils": "2.4.1", "joi": "^17.6.0", "js-yaml": "^4.1.0", "tslib": "^2.4.0" @@ -3790,30 +3828,30 @@ } }, "node_modules/algoliasearch": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.14.2.tgz", - "integrity": "sha512-ngbEQonGEmf8dyEh5f+uOIihv4176dgbuOZspiuhmTTBRBuzWu3KCGHre6uHj5YyuC7pNvQGzB6ZNJyZi0z+Sg==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.18.0.tgz", + "integrity": "sha512-pCuVxC1SVcpc08ENH32T4sLKSyzoU7TkRIDBMwSLfIiW+fq4znOmWDkAygHZ6pRcO9I1UJdqlfgnV7TRj+MXrA==", "dependencies": { - "@algolia/cache-browser-local-storage": "4.14.2", - "@algolia/cache-common": "4.14.2", - "@algolia/cache-in-memory": "4.14.2", - "@algolia/client-account": "4.14.2", - "@algolia/client-analytics": "4.14.2", - "@algolia/client-common": "4.14.2", - "@algolia/client-personalization": "4.14.2", - "@algolia/client-search": "4.14.2", - "@algolia/logger-common": "4.14.2", - "@algolia/logger-console": "4.14.2", - "@algolia/requester-browser-xhr": "4.14.2", - "@algolia/requester-common": "4.14.2", - "@algolia/requester-node-http": "4.14.2", - "@algolia/transporter": "4.14.2" + "@algolia/cache-browser-local-storage": "4.18.0", + "@algolia/cache-common": "4.18.0", + "@algolia/cache-in-memory": "4.18.0", + "@algolia/client-account": "4.18.0", + "@algolia/client-analytics": "4.18.0", + "@algolia/client-common": "4.18.0", + "@algolia/client-personalization": "4.18.0", + "@algolia/client-search": "4.18.0", + "@algolia/logger-common": "4.18.0", + "@algolia/logger-console": "4.18.0", + "@algolia/requester-browser-xhr": "4.18.0", + "@algolia/requester-common": "4.18.0", + "@algolia/requester-node-http": "4.18.0", + "@algolia/transporter": "4.18.0" } }, "node_modules/algoliasearch-helper": { - "version": "3.11.1", - "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.11.1.tgz", - "integrity": "sha512-mvsPN3eK4E0bZG0/WlWJjeqe/bUD2KOEVOl0GyL/TGXn6wcpZU8NOuztGHCUKXkyg5gq6YzUakVTmnmSSO5Yiw==", + "version": "3.13.3", + "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.13.3.tgz", + "integrity": "sha512-jhbbuYZ+fheXpaJlqdJdFa1jOsrTWKmRRTYDM3oVTto5VodZzM7tT+BHzslAotaJf/81CKrm6yLRQn8WIr/K4A==", "dependencies": { "@algolia/events": "^4.0.1" }, @@ -4969,9 +5007,9 @@ "integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==" }, "node_modules/copy-text-to-clipboard": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.0.1.tgz", - "integrity": "sha512-rvVsHrpFcL4F2P8ihsoLdFHmd404+CMg71S756oRSeQgqk51U3kicGdnvfkrxva0xXH92SjGS62B0XIJsbh+9Q==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.0.tgz", + "integrity": "sha512-RnJFp1XR/LOBDckxTib5Qjr/PMfkatD0MUCQgdpqS8MdKiNUzBjAQBEN6oUy+jW7LI93BBG3DtMB2KOOKpGs2Q==", "engines": { "node": ">=12" }, @@ -5144,11 +5182,11 @@ } }, "node_modules/cross-fetch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", - "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", + "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", "dependencies": { - "node-fetch": "2.6.7" + "node-fetch": "^2.6.12" } }, "node_modules/cross-spawn": { @@ -5380,12 +5418,12 @@ } }, "node_modules/cssnano-preset-advanced": { - "version": "5.3.8", - "resolved": "https://registry.npmjs.org/cssnano-preset-advanced/-/cssnano-preset-advanced-5.3.8.tgz", - "integrity": "sha512-xUlLLnEB1LjpEik+zgRNlk8Y/koBPPtONZjp7JKbXigeAmCrFvq9H0pXW5jJV45bQWAlmJ0sKy+IMr0XxLYQZg==", + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/cssnano-preset-advanced/-/cssnano-preset-advanced-5.3.10.tgz", + "integrity": "sha512-fnYJyCS9jgMU+cmHO1rPSPf9axbQyD7iUhLO5Df6O4G+fKIOMps+ZbU0PdGFejFBBZ3Pftf18fn1eG7MAPUSWQ==", "dependencies": { - "autoprefixer": "^10.3.7", - "cssnano-preset-default": "^5.2.12", + "autoprefixer": "^10.4.12", + "cssnano-preset-default": "^5.2.14", "postcss-discard-unused": "^5.1.0", "postcss-merge-idents": "^5.1.1", "postcss-reduce-idents": "^5.2.0", @@ -5399,24 +5437,24 @@ } }, "node_modules/cssnano-preset-default": { - "version": "5.2.12", - "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.12.tgz", - "integrity": "sha512-OyCBTZi+PXgylz9HAA5kHyoYhfGcYdwFmyaJzWnzxuGRtnMw/kR6ilW9XzlzlRAtB6PLT/r+prYgkef7hngFew==", + "version": "5.2.14", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.14.tgz", + "integrity": "sha512-t0SFesj/ZV2OTylqQVOrFgEh5uanxbO6ZAdeCrNsUQ6fVuXwYTxJPNAGvGTxHbD68ldIJNec7PyYZDBrfDQ+6A==", "dependencies": { - "css-declaration-sorter": "^6.3.0", + "css-declaration-sorter": "^6.3.1", "cssnano-utils": "^3.1.0", "postcss-calc": "^8.2.3", - "postcss-colormin": "^5.3.0", - "postcss-convert-values": "^5.1.2", + "postcss-colormin": "^5.3.1", + "postcss-convert-values": "^5.1.3", "postcss-discard-comments": "^5.1.2", "postcss-discard-duplicates": "^5.1.0", "postcss-discard-empty": "^5.1.1", "postcss-discard-overridden": "^5.1.0", - "postcss-merge-longhand": "^5.1.6", - "postcss-merge-rules": "^5.1.2", + "postcss-merge-longhand": "^5.1.7", + "postcss-merge-rules": "^5.1.4", "postcss-minify-font-values": "^5.1.0", "postcss-minify-gradients": "^5.1.1", - "postcss-minify-params": "^5.1.3", + "postcss-minify-params": "^5.1.4", "postcss-minify-selectors": "^5.2.1", "postcss-normalize-charset": "^5.1.0", "postcss-normalize-display-values": "^5.1.0", @@ -5424,11 +5462,11 @@ "postcss-normalize-repeat-style": "^5.1.1", "postcss-normalize-string": "^5.1.0", "postcss-normalize-timing-functions": "^5.1.0", - "postcss-normalize-unicode": "^5.1.0", + "postcss-normalize-unicode": "^5.1.1", "postcss-normalize-url": "^5.1.0", "postcss-normalize-whitespace": "^5.1.1", "postcss-ordered-values": "^5.1.3", - "postcss-reduce-initial": "^5.1.0", + "postcss-reduce-initial": "^5.1.2", "postcss-reduce-transforms": "^5.1.0", "postcss-svgo": "^5.1.0", "postcss-unique-selectors": "^5.1.1" @@ -6156,9 +6194,9 @@ } }, "node_modules/eta": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/eta/-/eta-1.12.3.tgz", - "integrity": "sha512-qHixwbDLtekO/d51Yr4glcaUJCIjGVJyTzuqV4GPlgZo1YpgOKG+avQynErZIYrfM6JIJdtiG2Kox8tbb+DoGg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-2.2.0.tgz", + "integrity": "sha512-UVQ72Rqjy/ZKQalzV5dCCJP80GrmPrMxh6NlNf+erV6ObL0ZFkhCstWRawS85z3smdr3d2wXPsZEY7rDPfGd2g==", "engines": { "node": ">=6.0.0" }, @@ -6402,9 +6440,9 @@ } }, "node_modules/fbjs": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.4.tgz", - "integrity": "sha512-ucV0tDODnGV3JCnnkmoszb5lf4bNpzjv80K41wd4k798Etq+UYD0y0TIfalLjZoKgjive6/adkRnszwapiDgBQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", + "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", "dependencies": { "cross-fetch": "^3.1.5", "fbjs-css-vars": "^1.0.0", @@ -6412,7 +6450,7 @@ "object-assign": "^4.1.0", "promise": "^7.1.1", "setimmediate": "^1.0.5", - "ua-parser-js": "^0.7.30" + "ua-parser-js": "^1.0.35" } }, "node_modules/fbjs-css-vars": { @@ -6553,9 +6591,9 @@ } }, "node_modules/flux": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/flux/-/flux-4.0.3.tgz", - "integrity": "sha512-yKAbrp7JhZhj6uiT1FTuVMlIAT1J4jqEyBpFApi1kxpGZCvacMVc/t1pMQyotqHhAgvoE3bNvAykhCo2CLjnYw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/flux/-/flux-4.0.4.tgz", + "integrity": "sha512-NCj3XlayA2UsapRpM7va6wU1+9rE5FIL7qoMcmxWHRzbp0yujihMBm9BBHZ1MDIk5h5o2Bl6eGiCe8rYELAmYw==", "dependencies": { "fbemitter": "^3.0.0", "fbjs": "^3.0.1" @@ -7700,9 +7738,9 @@ } }, "node_modules/infima": { - "version": "0.2.0-alpha.42", - "resolved": "https://registry.npmjs.org/infima/-/infima-0.2.0-alpha.42.tgz", - "integrity": "sha512-ift8OXNbQQwtbIt6z16KnSWP7uJ/SysSMFI4F87MNRTicypfl4Pv3E2OGVv6N3nSZFJvA8imYulCBS64iyHYww==", + "version": "0.2.0-alpha.43", + "resolved": "https://registry.npmjs.org/infima/-/infima-0.2.0-alpha.43.tgz", + "integrity": "sha512-2uw57LvUqW0rK/SWYnd/2rRfxNA5DDNOh33jxF7fy46VWoNhGxiUQyVZHbBMjQ33mQem0cjdDVwgWVAmlRfgyQ==", "engines": { "node": ">=12" } @@ -8874,9 +8912,9 @@ } }, "node_modules/node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", "dependencies": { "whatwg-url": "^5.0.0" }, @@ -9675,9 +9713,9 @@ } }, "node_modules/postcss": { - "version": "8.4.24", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.24.tgz", - "integrity": "sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==", + "version": "8.4.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.25.tgz", + "integrity": "sha512-7taJ/8t2av0Z+sQEvNzCkpDynl0tX3uJMCODi6nT3PfASC7dYCWV9aQ+uiCf+KBD4SEFcu+GvJdGdwzQ6OSjCw==", "funding": [ { "type": "opencollective", @@ -9714,11 +9752,11 @@ } }, "node_modules/postcss-colormin": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.3.0.tgz", - "integrity": "sha512-WdDO4gOFG2Z8n4P8TWBpshnL3JpmNmJwdnfP2gbk2qBA8PWwOYcmjmI/t3CmMeL72a7Hkd+x/Mg9O2/0rD54Pg==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.3.1.tgz", + "integrity": "sha512-UsWQG0AqTFQmpBegeLLc1+c3jIqBNB0zlDGRWR+dQ3pRKJL1oeMzyqmH3o2PIfn9MBdNrVPWhDbT769LxCTLJQ==", "dependencies": { - "browserslist": "^4.16.6", + "browserslist": "^4.21.4", "caniuse-api": "^3.0.0", "colord": "^2.9.1", "postcss-value-parser": "^4.2.0" @@ -9731,11 +9769,11 @@ } }, "node_modules/postcss-convert-values": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.2.tgz", - "integrity": "sha512-c6Hzc4GAv95B7suy4udszX9Zy4ETyMCgFPUDtWjdFTKH1SE9eFY/jEpHSwTH1QPuwxHpWslhckUQWbNRM4ho5g==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.3.tgz", + "integrity": "sha512-82pC1xkJZtcJEfiLw6UXnXVXScgtBrjlO5CBmuDQc+dlb88ZYheFsjTn40+zBVi3DkfF7iezO0nJUPLcJK3pvA==", "dependencies": { - "browserslist": "^4.20.3", + "browserslist": "^4.21.4", "postcss-value-parser": "^4.2.0" }, "engines": { @@ -9910,12 +9948,12 @@ } }, "node_modules/postcss-merge-longhand": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.6.tgz", - "integrity": "sha512-6C/UGF/3T5OE2CEbOuX7iNO63dnvqhGZeUnKkDeifebY0XqkkvrctYSZurpNE902LDf2yKwwPFgotnfSoPhQiw==", + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.7.tgz", + "integrity": "sha512-YCI9gZB+PLNskrK0BB3/2OzPnGhPkBEwmwhfYk1ilBHYVAZB7/tkTHFBAnCrvBBOmeYyMYw3DMjT55SyxMBzjQ==", "dependencies": { "postcss-value-parser": "^4.2.0", - "stylehacks": "^5.1.0" + "stylehacks": "^5.1.1" }, "engines": { "node": "^10 || ^12 || >=14.0" @@ -9925,11 +9963,11 @@ } }, "node_modules/postcss-merge-rules": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.2.tgz", - "integrity": "sha512-zKMUlnw+zYCWoPN6yhPjtcEdlJaMUZ0WyVcxTAmw3lkkN/NDMRkOkiuctQEoWAOvH7twaxUUdvBWl0d4+hifRQ==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.4.tgz", + "integrity": "sha512-0R2IuYpgU93y9lhVbO/OylTtKMVcHb67zjWIfCiKR9rWL3GUk1677LAqD/BcHizukdZEjT8Ru3oHRoAYoJy44g==", "dependencies": { - "browserslist": "^4.16.6", + "browserslist": "^4.21.4", "caniuse-api": "^3.0.0", "cssnano-utils": "^3.1.0", "postcss-selector-parser": "^6.0.5" @@ -9972,11 +10010,11 @@ } }, "node_modules/postcss-minify-params": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.1.3.tgz", - "integrity": "sha512-bkzpWcjykkqIujNL+EVEPOlLYi/eZ050oImVtHU7b4lFS82jPnsCb44gvC6pxaNt38Els3jWYDHTjHKf0koTgg==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.1.4.tgz", + "integrity": "sha512-+mePA3MgdmVmv6g+30rn57USjOGSAyuxUmkfiWpzalZ8aiBkdPYjXWtHuwJGm1v5Ojy0Z0LaSYhHaLJQB0P8Jw==", "dependencies": { - "browserslist": "^4.16.6", + "browserslist": "^4.21.4", "cssnano-utils": "^3.1.0", "postcss-value-parser": "^4.2.0" }, @@ -10156,11 +10194,11 @@ } }, "node_modules/postcss-normalize-unicode": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.0.tgz", - "integrity": "sha512-J6M3MizAAZ2dOdSjy2caayJLQT8E8K9XjLce8AUQMwOrCvjCHv24aLC/Lps1R1ylOfol5VIDMaM/Lo9NGlk1SQ==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.1.tgz", + "integrity": "sha512-qnCL5jzkNUmKVhZoENp1mJiGNPcsJCs1aaRmURmeJGES23Z/ajaln+EPTD+rBeNkSryI+2WTdW+lwcVdOikrpA==", "dependencies": { - "browserslist": "^4.16.6", + "browserslist": "^4.21.4", "postcss-value-parser": "^4.2.0" }, "engines": { @@ -10229,11 +10267,11 @@ } }, "node_modules/postcss-reduce-initial": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.0.tgz", - "integrity": "sha512-5OgTUviz0aeH6MtBjHfbr57tml13PuedK/Ecg8szzd4XRMbYxH4572JFG067z+FqBIf6Zp/d+0581glkvvWMFw==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.2.tgz", + "integrity": "sha512-dE/y2XRaqAi6OvjzD22pjTUQ8eOfc6m/natGHgKFBK9DxFmIm69YmaRVQrGgFlEfc1HePIurY0TmDeROK05rIg==", "dependencies": { - "browserslist": "^4.16.6", + "browserslist": "^4.21.4", "caniuse-api": "^3.0.0" }, "engines": { @@ -10270,9 +10308,9 @@ } }, "node_modules/postcss-sort-media-queries": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/postcss-sort-media-queries/-/postcss-sort-media-queries-4.3.0.tgz", - "integrity": "sha512-jAl8gJM2DvuIJiI9sL1CuiHtKM4s5aEIomkU8G3LFvbP+p8i7Sz8VV63uieTgoewGqKbi+hxBTiOKJlB35upCg==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/postcss-sort-media-queries/-/postcss-sort-media-queries-4.4.1.tgz", + "integrity": "sha512-QDESFzDDGKgpiIh4GYXsSy6sek2yAwQx1JASl5AxBtU1Lq2JfKBljIPNdil989NcSKRQX1ToiaKphImtBuhXWw==", "dependencies": { "sort-css-media-queries": "2.1.0" }, @@ -11105,11 +11143,11 @@ } }, "node_modules/react-textarea-autosize": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.3.4.tgz", - "integrity": "sha512-CdtmP8Dc19xL8/R6sWvtknD/eCXkQr30dtvC4VmGInhRsfF8X/ihXCq6+9l9qbxmKRiq407/7z5fxE7cVWQNgQ==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.2.tgz", + "integrity": "sha512-uOkyjkEl0ByEK21eCJMHDGBAAd/BoFQBawYK5XItjAmCTeSbjxghd8qnt7nzsLYzidjnoObu6M26xts0YGKsGg==", "dependencies": { - "@babel/runtime": "^7.10.2", + "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, @@ -11229,9 +11267,9 @@ } }, "node_modules/regenerator-runtime": { - "version": "0.13.10", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz", - "integrity": "sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw==" + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, "node_modules/regenerator-transform": { "version": "0.15.0", @@ -12095,6 +12133,15 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/search-insights": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.7.0.tgz", + "integrity": "sha512-GLbVaGgzYEKMvuJbHRhLi1qoBFnjXZGZ6l4LxOYPCp4lI2jDRB3jPU9/XNhMwv6kvnA9slTreq6pvK+b3o3aqg==", + "peer": true, + "engines": { + "node": ">=8.16.0" + } + }, "node_modules/section-matter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", @@ -12697,11 +12744,11 @@ } }, "node_modules/stylehacks": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.0.tgz", - "integrity": "sha512-SzLmvHQTrIWfSgljkQCw2++C9+Ne91d/6Sp92I8c5uHTcy/PgeHamwITIbBW9wnFTY/3ZfSXR9HIL6Ikqmcu6Q==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", + "integrity": "sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw==", "dependencies": { - "browserslist": "^4.16.6", + "browserslist": "^4.21.4", "postcss-selector-parser": "^6.0.4" }, "engines": { @@ -13262,9 +13309,9 @@ } }, "node_modules/typescript": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.3.tgz", - "integrity": "sha512-XH627E9vkeqhlZFQuL+UsyAXEnibT0kWR2FWONlr4sTjvxyJYnyefgrkyECLzM5NenmKzRAy2rR/OlYLA1HkZw==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13274,9 +13321,9 @@ } }, "node_modules/ua-parser-js": { - "version": "0.7.32", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.32.tgz", - "integrity": "sha512-f9BESNVhzlhEFf2CHMSj40NWOjYPl1YKYbrvIr/hFTDEmLq7SRbWvm7FcdcpCYT95zrOhC7gZSxjdnnTpBcwVw==", + "version": "1.0.35", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz", + "integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==", "funding": [ { "type": "opencollective", @@ -13774,6 +13821,14 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", @@ -14602,95 +14657,105 @@ }, "dependencies": { "@algolia/autocomplete-core": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.7.2.tgz", - "integrity": "sha512-eclwUDC6qfApNnEfu1uWcL/rudQsn59tjEoUYZYE2JSXZrHLRjBUGMxiCoknobU2Pva8ejb0eRxpIYDtVVqdsw==", + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.9.3.tgz", + "integrity": "sha512-009HdfugtGCdC4JdXUbVJClA0q0zh24yyePn+KUGk3rP7j8FEe/m5Yo/z65gn6nP/cM39PxpzqKrL7A6fP6PPw==", "requires": { - "@algolia/autocomplete-shared": "1.7.2" + "@algolia/autocomplete-plugin-algolia-insights": "1.9.3", + "@algolia/autocomplete-shared": "1.9.3" + } + }, + "@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.9.3.tgz", + "integrity": "sha512-a/yTUkcO/Vyy+JffmAnTWbr4/90cLzw+CC3bRbhnULr/EM0fGNvM13oQQ14f2moLMcVDyAx/leczLlAOovhSZg==", + "requires": { + "@algolia/autocomplete-shared": "1.9.3" } }, "@algolia/autocomplete-preset-algolia": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.7.2.tgz", - "integrity": "sha512-+RYEG6B0QiGGfRb2G3MtPfyrl0dALF3cQNTWBzBX6p5o01vCCGTTinAm2UKG3tfc2CnOMAtnPLkzNZyJUpnVJw==", + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.9.3.tgz", + "integrity": "sha512-d4qlt6YmrLMYy95n5TB52wtNDr6EgAIPH81dvvvW8UmuWRgxEtY0NJiPwl/h95JtG2vmRM804M0DSwMCNZlzRA==", "requires": { - "@algolia/autocomplete-shared": "1.7.2" + "@algolia/autocomplete-shared": "1.9.3" } }, "@algolia/autocomplete-shared": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.7.2.tgz", - "integrity": "sha512-QCckjiC7xXHIUaIL3ektBtjJ0w7tTA3iqKcAE/Hjn1lZ5omp7i3Y4e09rAr9ZybqirL7AbxCLLq0Ra5DDPKeug==" + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.9.3.tgz", + "integrity": "sha512-Wnm9E4Ye6Rl6sTTqjoymD+l8DjSTHsHboVRYrKgEt8Q7UHm9nYbqhN/i0fhUYA3OAEH7WA8x3jfpnmJm3rKvaQ==", + "requires": {} }, "@algolia/cache-browser-local-storage": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.14.2.tgz", - "integrity": "sha512-FRweBkK/ywO+GKYfAWbrepewQsPTIEirhi1BdykX9mxvBPtGNKccYAxvGdDCumU1jL4r3cayio4psfzKMejBlA==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.18.0.tgz", + "integrity": "sha512-rUAs49NLlO8LVLgGzM4cLkw8NJLKguQLgvFmBEe3DyzlinoqxzQMHfKZs6TSq4LZfw/z8qHvRo8NcTAAUJQLcw==", "requires": { - "@algolia/cache-common": "4.14.2" + "@algolia/cache-common": "4.18.0" } }, "@algolia/cache-common": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/cache-common/-/cache-common-4.14.2.tgz", - "integrity": "sha512-SbvAlG9VqNanCErr44q6lEKD2qoK4XtFNx9Qn8FK26ePCI8I9yU7pYB+eM/cZdS9SzQCRJBbHUumVr4bsQ4uxg==" + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/cache-common/-/cache-common-4.18.0.tgz", + "integrity": "sha512-BmxsicMR4doGbeEXQu8yqiGmiyvpNvejYJtQ7rvzttEAMxOPoWEHrWyzBQw4x7LrBY9pMrgv4ZlUaF8PGzewHg==" }, "@algolia/cache-in-memory": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/cache-in-memory/-/cache-in-memory-4.14.2.tgz", - "integrity": "sha512-HrOukWoop9XB/VFojPv1R5SVXowgI56T9pmezd/djh2JnVN/vXswhXV51RKy4nCpqxyHt/aGFSq2qkDvj6KiuQ==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/cache-in-memory/-/cache-in-memory-4.18.0.tgz", + "integrity": "sha512-evD4dA1nd5HbFdufBxLqlJoob7E2ozlqJZuV3YlirNx5Na4q1LckIuzjNYZs2ddLzuTc/Xd5O3Ibf7OwPskHxw==", "requires": { - "@algolia/cache-common": "4.14.2" + "@algolia/cache-common": "4.18.0" } }, "@algolia/client-account": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/client-account/-/client-account-4.14.2.tgz", - "integrity": "sha512-WHtriQqGyibbb/Rx71YY43T0cXqyelEU0lB2QMBRXvD2X0iyeGl4qMxocgEIcbHyK7uqE7hKgjT8aBrHqhgc1w==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/client-account/-/client-account-4.18.0.tgz", + "integrity": "sha512-XsDnlROr3+Z1yjxBJjUMfMazi1V155kVdte6496atvBgOEtwCzTs3A+qdhfsAnGUvaYfBrBkL0ThnhMIBCGcew==", "requires": { - "@algolia/client-common": "4.14.2", - "@algolia/client-search": "4.14.2", - "@algolia/transporter": "4.14.2" + "@algolia/client-common": "4.18.0", + "@algolia/client-search": "4.18.0", + "@algolia/transporter": "4.18.0" } }, "@algolia/client-analytics": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-4.14.2.tgz", - "integrity": "sha512-yBvBv2mw+HX5a+aeR0dkvUbFZsiC4FKSnfqk9rrfX+QrlNOKEhCG0tJzjiOggRW4EcNqRmaTULIYvIzQVL2KYQ==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-4.18.0.tgz", + "integrity": "sha512-chEUSN4ReqU7uRQ1C8kDm0EiPE+eJeAXiWcBwLhEynfNuTfawN9P93rSZktj7gmExz0C8XmkbBU19IQ05wCNrQ==", "requires": { - "@algolia/client-common": "4.14.2", - "@algolia/client-search": "4.14.2", - "@algolia/requester-common": "4.14.2", - "@algolia/transporter": "4.14.2" + "@algolia/client-common": "4.18.0", + "@algolia/client-search": "4.18.0", + "@algolia/requester-common": "4.18.0", + "@algolia/transporter": "4.18.0" } }, "@algolia/client-common": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.14.2.tgz", - "integrity": "sha512-43o4fslNLcktgtDMVaT5XwlzsDPzlqvqesRi4MjQz2x4/Sxm7zYg5LRYFol1BIhG6EwxKvSUq8HcC/KxJu3J0Q==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.18.0.tgz", + "integrity": "sha512-7N+soJFP4wn8tjTr3MSUT/U+4xVXbz4jmeRfWfVAzdAbxLAQbHa0o/POSdTvQ8/02DjCLelloZ1bb4ZFVKg7Wg==", "requires": { - "@algolia/requester-common": "4.14.2", - "@algolia/transporter": "4.14.2" + "@algolia/requester-common": "4.18.0", + "@algolia/transporter": "4.18.0" } }, "@algolia/client-personalization": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-4.14.2.tgz", - "integrity": "sha512-ACCoLi0cL8CBZ1W/2juehSltrw2iqsQBnfiu/Rbl9W2yE6o2ZUb97+sqN/jBqYNQBS+o0ekTMKNkQjHHAcEXNw==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-4.18.0.tgz", + "integrity": "sha512-+PeCjODbxtamHcPl+couXMeHEefpUpr7IHftj4Y4Nia1hj8gGq4VlIcqhToAw8YjLeCTfOR7r7xtj3pJcYdP8A==", "requires": { - "@algolia/client-common": "4.14.2", - "@algolia/requester-common": "4.14.2", - "@algolia/transporter": "4.14.2" + "@algolia/client-common": "4.18.0", + "@algolia/requester-common": "4.18.0", + "@algolia/transporter": "4.18.0" } }, "@algolia/client-search": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.14.2.tgz", - "integrity": "sha512-L5zScdOmcZ6NGiVbLKTvP02UbxZ0njd5Vq9nJAmPFtjffUSOGEp11BmD2oMJ5QvARgx2XbX4KzTTNS5ECYIMWw==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.18.0.tgz", + "integrity": "sha512-F9xzQXTjm6UuZtnsLIew6KSraXQ0AzS/Ee+OD+mQbtcA/K1sg89tqb8TkwjtiYZ0oij13u3EapB3gPZwm+1Y6g==", "requires": { - "@algolia/client-common": "4.14.2", - "@algolia/requester-common": "4.14.2", - "@algolia/transporter": "4.14.2" + "@algolia/client-common": "4.18.0", + "@algolia/requester-common": "4.18.0", + "@algolia/transporter": "4.18.0" } }, "@algolia/events": { @@ -14699,47 +14764,47 @@ "integrity": "sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ==" }, "@algolia/logger-common": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.14.2.tgz", - "integrity": "sha512-/JGlYvdV++IcMHBnVFsqEisTiOeEr6cUJtpjz8zc0A9c31JrtLm318Njc72p14Pnkw3A/5lHHh+QxpJ6WFTmsA==" + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.18.0.tgz", + "integrity": "sha512-46etYgSlkoKepkMSyaoriSn2JDgcrpc/nkOgou/lm0y17GuMl9oYZxwKKTSviLKI5Irk9nSKGwnBTQYwXOYdRg==" }, "@algolia/logger-console": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/logger-console/-/logger-console-4.14.2.tgz", - "integrity": "sha512-8S2PlpdshbkwlLCSAB5f8c91xyc84VM9Ar9EdfE9UmX+NrKNYnWR1maXXVDQQoto07G1Ol/tYFnFVhUZq0xV/g==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/logger-console/-/logger-console-4.18.0.tgz", + "integrity": "sha512-3P3VUYMl9CyJbi/UU1uUNlf6Z8N2ltW3Oqhq/nR7vH0CjWv32YROq3iGWGxB2xt3aXobdUPXs6P0tHSKRmNA6g==", "requires": { - "@algolia/logger-common": "4.14.2" + "@algolia/logger-common": "4.18.0" } }, "@algolia/requester-browser-xhr": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.14.2.tgz", - "integrity": "sha512-CEh//xYz/WfxHFh7pcMjQNWgpl4wFB85lUMRyVwaDPibNzQRVcV33YS+63fShFWc2+42YEipFGH2iPzlpszmDw==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.18.0.tgz", + "integrity": "sha512-/AcWHOBub2U4TE/bPi4Gz1XfuLK6/7dj4HJG+Z2SfQoS1RjNLshZclU3OoKIkFp8D2NC7+BNsPvr9cPLyW8nyQ==", "requires": { - "@algolia/requester-common": "4.14.2" + "@algolia/requester-common": "4.18.0" } }, "@algolia/requester-common": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-common/-/requester-common-4.14.2.tgz", - "integrity": "sha512-73YQsBOKa5fvVV3My7iZHu1sUqmjjfs9TteFWwPwDmnad7T0VTCopttcsM3OjLxZFtBnX61Xxl2T2gmG2O4ehg==" + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-common/-/requester-common-4.18.0.tgz", + "integrity": "sha512-xlT8R1qYNRBCi1IYLsx7uhftzdfsLPDGudeQs+xvYB4sQ3ya7+ppolB/8m/a4F2gCkEO6oxpp5AGemM7kD27jA==" }, "@algolia/requester-node-http": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-4.14.2.tgz", - "integrity": "sha512-oDbb02kd1o5GTEld4pETlPZLY0e+gOSWjWMJHWTgDXbv9rm/o2cF7japO6Vj1ENnrqWvLBmW1OzV9g6FUFhFXg==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-4.18.0.tgz", + "integrity": "sha512-TGfwj9aeTVgOUhn5XrqBhwUhUUDnGIKlI0kCBMdR58XfXcfdwomka+CPIgThRbfYw04oQr31A6/95ZH2QVJ9UQ==", "requires": { - "@algolia/requester-common": "4.14.2" + "@algolia/requester-common": "4.18.0" } }, "@algolia/transporter": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@algolia/transporter/-/transporter-4.14.2.tgz", - "integrity": "sha512-t89dfQb2T9MFQHidjHcfhh6iGMNwvuKUvojAj+JsrHAGbuSy7yE4BylhLX6R0Q1xYRoC4Vvv+O5qIw/LdnQfsQ==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@algolia/transporter/-/transporter-4.18.0.tgz", + "integrity": "sha512-xbw3YRUGtXQNG1geYFEDDuFLZt4Z8YNKbamHPkzr3rWc6qp4/BqEeXcI2u/P/oMq2yxtXgMxrCxOPA8lyIe5jw==", "requires": { - "@algolia/cache-common": "4.14.2", - "@algolia/logger-common": "4.14.2", - "@algolia/requester-common": "4.14.2" + "@algolia/cache-common": "4.18.0", + "@algolia/logger-common": "4.18.0", + "@algolia/requester-common": "4.18.0" } }, "@alloc/quick-lru": { @@ -15901,11 +15966,11 @@ } }, "@babel/runtime": { - "version": "7.19.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.4.tgz", - "integrity": "sha512-EXpLCrk55f+cYqmHsSR+yD/0gAIMxxA9QK9lnQWzhMCvt+YmoBN7Zx94s++Kv0+unHk39vxNO8t+CMA2WSS3wA==", + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.6.tgz", + "integrity": "sha512-wDb5pWm4WDdF6LFUde3Jl8WzPA+3ZbxYqkC6xAXuD3irdEHN1k0NfTRrJD8ZD378SJ61miMLCqIOXYhd8x+AJQ==", "requires": { - "regenerator-runtime": "^0.13.4" + "regenerator-runtime": "^0.13.11" } }, "@babel/runtime-corejs3": { @@ -15961,25 +16026,25 @@ "optional": true }, "@docsearch/css": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.3.0.tgz", - "integrity": "sha512-rODCdDtGyudLj+Va8b6w6Y85KE85bXRsps/R4Yjwt5vueXKXZQKYw0aA9knxLBT6a/bI/GMrAcmCR75KYOM6hg==" + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.5.1.tgz", + "integrity": "sha512-2Pu9HDg/uP/IT10rbQ+4OrTQuxIWdKVUEdcw9/w7kZJv9NeHS6skJx1xuRiFyoGKwAzcHXnLp7csE99sj+O1YA==" }, "@docsearch/react": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.3.0.tgz", - "integrity": "sha512-fhS5adZkae2SSdMYEMVg6pxI5a/cE+tW16ki1V0/ur4Fdok3hBRkmN/H8VvlXnxzggkQIIRIVvYPn00JPjen3A==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.5.1.tgz", + "integrity": "sha512-t5mEODdLzZq4PTFAm/dvqcvZFdPDMdfPE5rJS5SC8OUq9mPzxEy6b+9THIqNM9P0ocCb4UC5jqBrxKclnuIbzQ==", "requires": { - "@algolia/autocomplete-core": "1.7.2", - "@algolia/autocomplete-preset-algolia": "1.7.2", - "@docsearch/css": "3.3.0", + "@algolia/autocomplete-core": "1.9.3", + "@algolia/autocomplete-preset-algolia": "1.9.3", + "@docsearch/css": "3.5.1", "algoliasearch": "^4.0.0" } }, "@docusaurus/core": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-2.1.0.tgz", - "integrity": "sha512-/ZJ6xmm+VB9Izbn0/s6h6289cbPy2k4iYFwWDhjiLsVqwa/Y0YBBcXvStfaHccudUC3OfP+26hMk7UCjc50J6Q==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-2.4.1.tgz", + "integrity": "sha512-SNsY7PshK3Ri7vtsLXVeAJGS50nJN3RgF836zkyUfAD01Fq+sAk5EwWgLw+nnm5KVNGDu7PRR2kRGDsWvqpo0g==", "requires": { "@babel/core": "^7.18.6", "@babel/generator": "^7.18.7", @@ -15991,13 +16056,13 @@ "@babel/runtime": "^7.18.6", "@babel/runtime-corejs3": "^7.18.6", "@babel/traverse": "^7.18.8", - "@docusaurus/cssnano-preset": "2.1.0", - "@docusaurus/logger": "2.1.0", - "@docusaurus/mdx-loader": "2.1.0", + "@docusaurus/cssnano-preset": "2.4.1", + "@docusaurus/logger": "2.4.1", + "@docusaurus/mdx-loader": "2.4.1", "@docusaurus/react-loadable": "5.5.2", - "@docusaurus/utils": "2.1.0", - "@docusaurus/utils-common": "2.1.0", - "@docusaurus/utils-validation": "2.1.0", + "@docusaurus/utils": "2.4.1", + "@docusaurus/utils-common": "2.4.1", + "@docusaurus/utils-validation": "2.4.1", "@slorber/static-site-generator-webpack-plugin": "^4.0.7", "@svgr/webpack": "^6.2.1", "autoprefixer": "^10.4.7", @@ -16018,7 +16083,7 @@ "del": "^6.1.1", "detect-port": "^1.3.0", "escape-html": "^1.0.3", - "eta": "^1.12.3", + "eta": "^2.0.0", "file-loader": "^6.2.0", "fs-extra": "^10.1.0", "html-minifier-terser": "^6.1.0", @@ -16055,9 +16120,9 @@ } }, "@docusaurus/cssnano-preset": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-2.1.0.tgz", - "integrity": "sha512-pRLewcgGhOies6pzsUROfmPStDRdFw+FgV5sMtLr5+4Luv2rty5+b/eSIMMetqUsmg3A9r9bcxHk9bKAKvx3zQ==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-2.4.1.tgz", + "integrity": "sha512-ka+vqXwtcW1NbXxWsh6yA1Ckii1klY9E53cJ4O9J09nkMBgrNX3iEFED1fWdv8wf4mJjvGi5RLZ2p9hJNjsLyQ==", "requires": { "cssnano-preset-advanced": "^5.3.8", "postcss": "^8.4.14", @@ -16066,23 +16131,23 @@ } }, "@docusaurus/logger": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-2.1.0.tgz", - "integrity": "sha512-uuJx2T6hDBg82joFeyobywPjSOIfeq05GfyKGHThVoXuXsu1KAzMDYcjoDxarb9CoHCI/Dor8R2MoL6zII8x1Q==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-2.4.1.tgz", + "integrity": "sha512-5h5ysIIWYIDHyTVd8BjheZmQZmEgWDR54aQ1BX9pjFfpyzFo5puKXKYrYJXbjEHGyVhEzmB9UXwbxGfaZhOjcg==", "requires": { "chalk": "^4.1.2", "tslib": "^2.4.0" } }, "@docusaurus/mdx-loader": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-2.1.0.tgz", - "integrity": "sha512-i97hi7hbQjsD3/8OSFhLy7dbKGH8ryjEzOfyhQIn2CFBYOY3ko0vMVEf3IY9nD3Ld7amYzsZ8153RPkcnXA+Lg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-2.4.1.tgz", + "integrity": "sha512-4KhUhEavteIAmbBj7LVFnrVYDiU51H5YWW1zY6SmBSte/YLhDutztLTBE0PQl1Grux1jzUJeaSvAzHpTn6JJDQ==", "requires": { "@babel/parser": "^7.18.8", "@babel/traverse": "^7.18.8", - "@docusaurus/logger": "2.1.0", - "@docusaurus/utils": "2.1.0", + "@docusaurus/logger": "2.4.1", + "@docusaurus/utils": "2.4.1", "@mdx-js/mdx": "^1.6.22", "escape-html": "^1.0.3", "file-loader": "^6.2.0", @@ -16099,12 +16164,12 @@ } }, "@docusaurus/module-type-aliases": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-2.1.0.tgz", - "integrity": "sha512-Z8WZaK5cis3xEtyfOT817u9xgGUauT0PuuVo85ysnFRX8n7qLN1lTPCkC+aCmFm/UcV8h/W5T4NtIsst94UntQ==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-2.4.1.tgz", + "integrity": "sha512-gLBuIFM8Dp2XOCWffUDSjtxY7jQgKvYujt7Mx5s4FCTfoL5dN1EVbnrn+O2Wvh8b0a77D57qoIDY7ghgmatR1A==", "requires": { "@docusaurus/react-loadable": "5.5.2", - "@docusaurus/types": "2.1.0", + "@docusaurus/types": "2.4.1", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -16114,17 +16179,17 @@ } }, "@docusaurus/plugin-content-blog": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-2.1.0.tgz", - "integrity": "sha512-xEp6jlu92HMNUmyRBEeJ4mCW1s77aAEQO4Keez94cUY/Ap7G/r0Awa6xSLff7HL0Fjg8KK1bEbDy7q9voIavdg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-2.4.1.tgz", + "integrity": "sha512-E2i7Knz5YIbE1XELI6RlTnZnGgS52cUO4BlCiCUCvQHbR+s1xeIWz4C6BtaVnlug0Ccz7nFSksfwDpVlkujg5Q==", "requires": { - "@docusaurus/core": "2.1.0", - "@docusaurus/logger": "2.1.0", - "@docusaurus/mdx-loader": "2.1.0", - "@docusaurus/types": "2.1.0", - "@docusaurus/utils": "2.1.0", - "@docusaurus/utils-common": "2.1.0", - "@docusaurus/utils-validation": "2.1.0", + "@docusaurus/core": "2.4.1", + "@docusaurus/logger": "2.4.1", + "@docusaurus/mdx-loader": "2.4.1", + "@docusaurus/types": "2.4.1", + "@docusaurus/utils": "2.4.1", + "@docusaurus/utils-common": "2.4.1", + "@docusaurus/utils-validation": "2.4.1", "cheerio": "^1.0.0-rc.12", "feed": "^4.2.2", "fs-extra": "^10.1.0", @@ -16137,17 +16202,17 @@ } }, "@docusaurus/plugin-content-docs": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-2.1.0.tgz", - "integrity": "sha512-Rup5pqXrXlKGIC4VgwvioIhGWF7E/NNSlxv+JAxRYpik8VKlWsk9ysrdHIlpX+KJUCO9irnY21kQh2814mlp/Q==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-2.4.1.tgz", + "integrity": "sha512-Lo7lSIcpswa2Kv4HEeUcGYqaasMUQNpjTXpV0N8G6jXgZaQurqp7E8NGYeGbDXnb48czmHWbzDL4S3+BbK0VzA==", "requires": { - "@docusaurus/core": "2.1.0", - "@docusaurus/logger": "2.1.0", - "@docusaurus/mdx-loader": "2.1.0", - "@docusaurus/module-type-aliases": "2.1.0", - "@docusaurus/types": "2.1.0", - "@docusaurus/utils": "2.1.0", - "@docusaurus/utils-validation": "2.1.0", + "@docusaurus/core": "2.4.1", + "@docusaurus/logger": "2.4.1", + "@docusaurus/mdx-loader": "2.4.1", + "@docusaurus/module-type-aliases": "2.4.1", + "@docusaurus/types": "2.4.1", + "@docusaurus/utils": "2.4.1", + "@docusaurus/utils-validation": "2.4.1", "@types/react-router-config": "^5.0.6", "combine-promises": "^1.1.0", "fs-extra": "^10.1.0", @@ -16160,88 +16225,100 @@ } }, "@docusaurus/plugin-content-pages": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-2.1.0.tgz", - "integrity": "sha512-SwZdDZRlObHNKXTnFo7W2aF6U5ZqNVI55Nw2GCBryL7oKQSLeI0lsrMlMXdzn+fS7OuBTd3MJBO1T4Zpz0i/+g==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-2.4.1.tgz", + "integrity": "sha512-/UjuH/76KLaUlL+o1OvyORynv6FURzjurSjvn2lbWTFc4tpYY2qLYTlKpTCBVPhlLUQsfyFnshEJDLmPneq2oA==", "requires": { - "@docusaurus/core": "2.1.0", - "@docusaurus/mdx-loader": "2.1.0", - "@docusaurus/types": "2.1.0", - "@docusaurus/utils": "2.1.0", - "@docusaurus/utils-validation": "2.1.0", + "@docusaurus/core": "2.4.1", + "@docusaurus/mdx-loader": "2.4.1", + "@docusaurus/types": "2.4.1", + "@docusaurus/utils": "2.4.1", + "@docusaurus/utils-validation": "2.4.1", "fs-extra": "^10.1.0", "tslib": "^2.4.0", "webpack": "^5.73.0" } }, "@docusaurus/plugin-debug": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-2.1.0.tgz", - "integrity": "sha512-8wsDq3OIfiy6440KLlp/qT5uk+WRHQXIXklNHEeZcar+Of0TZxCNe2FBpv+bzb/0qcdP45ia5i5WmR5OjN6DPw==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-2.4.1.tgz", + "integrity": "sha512-7Yu9UPzRShlrH/G8btOpR0e6INFZr0EegWplMjOqelIwAcx3PKyR8mgPTxGTxcqiYj6hxSCRN0D8R7YrzImwNA==", "requires": { - "@docusaurus/core": "2.1.0", - "@docusaurus/types": "2.1.0", - "@docusaurus/utils": "2.1.0", + "@docusaurus/core": "2.4.1", + "@docusaurus/types": "2.4.1", + "@docusaurus/utils": "2.4.1", "fs-extra": "^10.1.0", "react-json-view": "^1.21.3", "tslib": "^2.4.0" } }, "@docusaurus/plugin-google-analytics": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-2.1.0.tgz", - "integrity": "sha512-4cgeqIly/wcFVbbWP03y1QJJBgH8W+Bv6AVbWnsXNOZa1yB3AO6hf3ZdeQH9x20v9T2pREogVgAH0rSoVnNsgg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-2.4.1.tgz", + "integrity": "sha512-dyZJdJiCoL+rcfnm0RPkLt/o732HvLiEwmtoNzOoz9MSZz117UH2J6U2vUDtzUzwtFLIf32KkeyzisbwUCgcaQ==", "requires": { - "@docusaurus/core": "2.1.0", - "@docusaurus/types": "2.1.0", - "@docusaurus/utils-validation": "2.1.0", + "@docusaurus/core": "2.4.1", + "@docusaurus/types": "2.4.1", + "@docusaurus/utils-validation": "2.4.1", "tslib": "^2.4.0" } }, "@docusaurus/plugin-google-gtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-2.1.0.tgz", - "integrity": "sha512-/3aDlv2dMoCeiX2e+DTGvvrdTA+v3cKQV3DbmfsF4ENhvc5nKV23nth04Z3Vq0Ci1ui6Sn80TkhGk/tiCMW2AA==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-2.4.1.tgz", + "integrity": "sha512-mKIefK+2kGTQBYvloNEKtDmnRD7bxHLsBcxgnbt4oZwzi2nxCGjPX6+9SQO2KCN5HZbNrYmGo5GJfMgoRvy6uA==", "requires": { - "@docusaurus/core": "2.1.0", - "@docusaurus/types": "2.1.0", - "@docusaurus/utils-validation": "2.1.0", + "@docusaurus/core": "2.4.1", + "@docusaurus/types": "2.4.1", + "@docusaurus/utils-validation": "2.4.1", + "tslib": "^2.4.0" + } + }, + "@docusaurus/plugin-google-tag-manager": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-2.4.1.tgz", + "integrity": "sha512-Zg4Ii9CMOLfpeV2nG74lVTWNtisFaH9QNtEw48R5QE1KIwDBdTVaiSA18G1EujZjrzJJzXN79VhINSbOJO/r3g==", + "requires": { + "@docusaurus/core": "2.4.1", + "@docusaurus/types": "2.4.1", + "@docusaurus/utils-validation": "2.4.1", "tslib": "^2.4.0" } }, "@docusaurus/plugin-sitemap": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-2.1.0.tgz", - "integrity": "sha512-2Y6Br8drlrZ/jN9MwMBl0aoi9GAjpfyfMBYpaQZXimbK+e9VjYnujXlvQ4SxtM60ASDgtHIAzfVFBkSR/MwRUw==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-2.4.1.tgz", + "integrity": "sha512-lZx+ijt/+atQ3FVE8FOHV/+X3kuok688OydDXrqKRJyXBJZKgGjA2Qa8RjQ4f27V2woaXhtnyrdPop/+OjVMRg==", "requires": { - "@docusaurus/core": "2.1.0", - "@docusaurus/logger": "2.1.0", - "@docusaurus/types": "2.1.0", - "@docusaurus/utils": "2.1.0", - "@docusaurus/utils-common": "2.1.0", - "@docusaurus/utils-validation": "2.1.0", + "@docusaurus/core": "2.4.1", + "@docusaurus/logger": "2.4.1", + "@docusaurus/types": "2.4.1", + "@docusaurus/utils": "2.4.1", + "@docusaurus/utils-common": "2.4.1", + "@docusaurus/utils-validation": "2.4.1", "fs-extra": "^10.1.0", "sitemap": "^7.1.1", "tslib": "^2.4.0" } }, "@docusaurus/preset-classic": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-2.1.0.tgz", - "integrity": "sha512-NQMnaq974K4BcSMXFSJBQ5itniw6RSyW+VT+6i90kGZzTwiuKZmsp0r9lC6BYAvvVMQUNJQwrETmlu7y2XKW7w==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-2.4.1.tgz", + "integrity": "sha512-P4//+I4zDqQJ+UDgoFrjIFaQ1MeS9UD1cvxVQaI6O7iBmiHQm0MGROP1TbE7HlxlDPXFJjZUK3x3cAoK63smGQ==", "requires": { - "@docusaurus/core": "2.1.0", - "@docusaurus/plugin-content-blog": "2.1.0", - "@docusaurus/plugin-content-docs": "2.1.0", - "@docusaurus/plugin-content-pages": "2.1.0", - "@docusaurus/plugin-debug": "2.1.0", - "@docusaurus/plugin-google-analytics": "2.1.0", - "@docusaurus/plugin-google-gtag": "2.1.0", - "@docusaurus/plugin-sitemap": "2.1.0", - "@docusaurus/theme-classic": "2.1.0", - "@docusaurus/theme-common": "2.1.0", - "@docusaurus/theme-search-algolia": "2.1.0", - "@docusaurus/types": "2.1.0" + "@docusaurus/core": "2.4.1", + "@docusaurus/plugin-content-blog": "2.4.1", + "@docusaurus/plugin-content-docs": "2.4.1", + "@docusaurus/plugin-content-pages": "2.4.1", + "@docusaurus/plugin-debug": "2.4.1", + "@docusaurus/plugin-google-analytics": "2.4.1", + "@docusaurus/plugin-google-gtag": "2.4.1", + "@docusaurus/plugin-google-tag-manager": "2.4.1", + "@docusaurus/plugin-sitemap": "2.4.1", + "@docusaurus/theme-classic": "2.4.1", + "@docusaurus/theme-common": "2.4.1", + "@docusaurus/theme-search-algolia": "2.4.1", + "@docusaurus/types": "2.4.1" } }, "@docusaurus/react-loadable": { @@ -16254,26 +16331,26 @@ } }, "@docusaurus/theme-classic": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-2.1.0.tgz", - "integrity": "sha512-xn8ZfNMsf7gaSy9+ClFnUu71o7oKgMo5noYSS1hy3svNifRTkrBp6+MReLDsmIaj3mLf2e7+JCBYKBFbaGzQng==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-2.4.1.tgz", + "integrity": "sha512-Rz0wKUa+LTW1PLXmwnf8mn85EBzaGSt6qamqtmnh9Hflkc+EqiYMhtUJeLdV+wsgYq4aG0ANc+bpUDpsUhdnwg==", "requires": { - "@docusaurus/core": "2.1.0", - "@docusaurus/mdx-loader": "2.1.0", - "@docusaurus/module-type-aliases": "2.1.0", - "@docusaurus/plugin-content-blog": "2.1.0", - "@docusaurus/plugin-content-docs": "2.1.0", - "@docusaurus/plugin-content-pages": "2.1.0", - "@docusaurus/theme-common": "2.1.0", - "@docusaurus/theme-translations": "2.1.0", - "@docusaurus/types": "2.1.0", - "@docusaurus/utils": "2.1.0", - "@docusaurus/utils-common": "2.1.0", - "@docusaurus/utils-validation": "2.1.0", + "@docusaurus/core": "2.4.1", + "@docusaurus/mdx-loader": "2.4.1", + "@docusaurus/module-type-aliases": "2.4.1", + "@docusaurus/plugin-content-blog": "2.4.1", + "@docusaurus/plugin-content-docs": "2.4.1", + "@docusaurus/plugin-content-pages": "2.4.1", + "@docusaurus/theme-common": "2.4.1", + "@docusaurus/theme-translations": "2.4.1", + "@docusaurus/types": "2.4.1", + "@docusaurus/utils": "2.4.1", + "@docusaurus/utils-common": "2.4.1", + "@docusaurus/utils-validation": "2.4.1", "@mdx-js/react": "^1.6.22", "clsx": "^1.2.1", "copy-text-to-clipboard": "^3.0.1", - "infima": "0.2.0-alpha.42", + "infima": "0.2.0-alpha.43", "lodash": "^4.17.21", "nprogress": "^0.2.0", "postcss": "^8.4.14", @@ -16286,16 +16363,17 @@ } }, "@docusaurus/theme-common": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-2.1.0.tgz", - "integrity": "sha512-vT1otpVPbKux90YpZUnvknsn5zvpLf+AW1W0EDcpE9up4cDrPqfsh0QoxGHFJnobE2/qftsBFC19BneN4BH8Ag==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-2.4.1.tgz", + "integrity": "sha512-G7Zau1W5rQTaFFB3x3soQoZpkgMbl/SYNG8PfMFIjKa3M3q8n0m/GRf5/H/e5BqOvt8c+ZWIXGCiz+kUCSHovA==", "requires": { - "@docusaurus/mdx-loader": "2.1.0", - "@docusaurus/module-type-aliases": "2.1.0", - "@docusaurus/plugin-content-blog": "2.1.0", - "@docusaurus/plugin-content-docs": "2.1.0", - "@docusaurus/plugin-content-pages": "2.1.0", - "@docusaurus/utils": "2.1.0", + "@docusaurus/mdx-loader": "2.4.1", + "@docusaurus/module-type-aliases": "2.4.1", + "@docusaurus/plugin-content-blog": "2.4.1", + "@docusaurus/plugin-content-docs": "2.4.1", + "@docusaurus/plugin-content-pages": "2.4.1", + "@docusaurus/utils": "2.4.1", + "@docusaurus/utils-common": "2.4.1", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -16303,26 +16381,27 @@ "parse-numeric-range": "^1.3.0", "prism-react-renderer": "^1.3.5", "tslib": "^2.4.0", + "use-sync-external-store": "^1.2.0", "utility-types": "^3.10.0" } }, "@docusaurus/theme-search-algolia": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-2.1.0.tgz", - "integrity": "sha512-rNBvi35VvENhucslEeVPOtbAzBdZY/9j55gdsweGV5bYoAXy4mHB6zTGjealcB4pJ6lJY4a5g75fXXMOlUqPfg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-2.4.1.tgz", + "integrity": "sha512-6BcqW2lnLhZCXuMAvPRezFs1DpmEKzXFKlYjruuas+Xy3AQeFzDJKTJFIm49N77WFCTyxff8d3E4Q9pi/+5McQ==", "requires": { "@docsearch/react": "^3.1.1", - "@docusaurus/core": "2.1.0", - "@docusaurus/logger": "2.1.0", - "@docusaurus/plugin-content-docs": "2.1.0", - "@docusaurus/theme-common": "2.1.0", - "@docusaurus/theme-translations": "2.1.0", - "@docusaurus/utils": "2.1.0", - "@docusaurus/utils-validation": "2.1.0", + "@docusaurus/core": "2.4.1", + "@docusaurus/logger": "2.4.1", + "@docusaurus/plugin-content-docs": "2.4.1", + "@docusaurus/theme-common": "2.4.1", + "@docusaurus/theme-translations": "2.4.1", + "@docusaurus/utils": "2.4.1", + "@docusaurus/utils-validation": "2.4.1", "algoliasearch": "^4.13.1", "algoliasearch-helper": "^3.10.0", "clsx": "^1.2.1", - "eta": "^1.12.3", + "eta": "^2.0.0", "fs-extra": "^10.1.0", "lodash": "^4.17.21", "tslib": "^2.4.0", @@ -16330,18 +16409,18 @@ } }, "@docusaurus/theme-translations": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-2.1.0.tgz", - "integrity": "sha512-07n2akf2nqWvtJeMy3A+7oSGMuu5F673AovXVwY0aGAux1afzGCiqIFlYW3EP0CujvDJAEFSQi/Tetfh+95JNg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-2.4.1.tgz", + "integrity": "sha512-T1RAGP+f86CA1kfE8ejZ3T3pUU3XcyvrGMfC/zxCtc2BsnoexuNI9Vk2CmuKCb+Tacvhxjv5unhxXce0+NKyvA==", "requires": { "fs-extra": "^10.1.0", "tslib": "^2.4.0" } }, "@docusaurus/types": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-2.1.0.tgz", - "integrity": "sha512-BS1ebpJZnGG6esKqsjtEC9U9qSaPylPwlO7cQ1GaIE7J/kMZI3FITnNn0otXXu7c7ZTqhb6+8dOrG6fZn6fqzQ==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-2.4.1.tgz", + "integrity": "sha512-0R+cbhpMkhbRXX138UOc/2XZFF8hiZa6ooZAEEJFp5scytzCw4tC1gChMFXrpa3d2tYE6AX8IrOEpSonLmfQuQ==", "requires": { "@types/history": "^4.7.11", "@types/react": "*", @@ -16354,12 +16433,13 @@ } }, "@docusaurus/utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-2.1.0.tgz", - "integrity": "sha512-fPvrfmAuC54n8MjZuG4IysaMdmvN5A/qr7iFLbSGSyDrsbP4fnui6KdZZIa/YOLIPLec8vjZ8RIITJqF18mx4A==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-2.4.1.tgz", + "integrity": "sha512-1lvEZdAQhKNht9aPXPoh69eeKnV0/62ROhQeFKKxmzd0zkcuE/Oc5Gpnt00y/f5bIsmOsYMY7Pqfm/5rteT5GA==", "requires": { - "@docusaurus/logger": "2.1.0", + "@docusaurus/logger": "2.4.1", "@svgr/webpack": "^6.2.1", + "escape-string-regexp": "^4.0.0", "file-loader": "^6.2.0", "fs-extra": "^10.1.0", "github-slugger": "^1.4.0", @@ -16376,20 +16456,20 @@ } }, "@docusaurus/utils-common": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-2.1.0.tgz", - "integrity": "sha512-F2vgmt4yRFgRQR2vyEFGTWeyAdmgKbtmu3sjHObF0tjjx/pN0Iw/c6eCopaH34E6tc9nO0nvp01pwW+/86d1fg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-2.4.1.tgz", + "integrity": "sha512-bCVGdZU+z/qVcIiEQdyx0K13OC5mYwxhSuDUR95oFbKVuXYRrTVrwZIqQljuo1fyJvFTKHiL9L9skQOPokuFNQ==", "requires": { "tslib": "^2.4.0" } }, "@docusaurus/utils-validation": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-2.1.0.tgz", - "integrity": "sha512-AMJzWYKL3b7FLltKtDXNLO9Y649V2BXvrnRdnW2AA+PpBnYV78zKLSCz135cuWwRj1ajNtP4onbXdlnyvCijGQ==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-2.4.1.tgz", + "integrity": "sha512-unII3hlJlDwZ3w8U+pMO3Lx3RhI4YEbY3YNsQj4yzrkZzlpqZOLuAiZK2JyULnD+TKbceKU0WyWkQXtYbLNDFA==", "requires": { - "@docusaurus/logger": "2.1.0", - "@docusaurus/utils": "2.1.0", + "@docusaurus/logger": "2.4.1", + "@docusaurus/utils": "2.4.1", "joi": "^17.6.0", "js-yaml": "^4.1.0", "tslib": "^2.4.0" @@ -17364,30 +17444,30 @@ "requires": {} }, "algoliasearch": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.14.2.tgz", - "integrity": "sha512-ngbEQonGEmf8dyEh5f+uOIihv4176dgbuOZspiuhmTTBRBuzWu3KCGHre6uHj5YyuC7pNvQGzB6ZNJyZi0z+Sg==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.18.0.tgz", + "integrity": "sha512-pCuVxC1SVcpc08ENH32T4sLKSyzoU7TkRIDBMwSLfIiW+fq4znOmWDkAygHZ6pRcO9I1UJdqlfgnV7TRj+MXrA==", "requires": { - "@algolia/cache-browser-local-storage": "4.14.2", - "@algolia/cache-common": "4.14.2", - "@algolia/cache-in-memory": "4.14.2", - "@algolia/client-account": "4.14.2", - "@algolia/client-analytics": "4.14.2", - "@algolia/client-common": "4.14.2", - "@algolia/client-personalization": "4.14.2", - "@algolia/client-search": "4.14.2", - "@algolia/logger-common": "4.14.2", - "@algolia/logger-console": "4.14.2", - "@algolia/requester-browser-xhr": "4.14.2", - "@algolia/requester-common": "4.14.2", - "@algolia/requester-node-http": "4.14.2", - "@algolia/transporter": "4.14.2" + "@algolia/cache-browser-local-storage": "4.18.0", + "@algolia/cache-common": "4.18.0", + "@algolia/cache-in-memory": "4.18.0", + "@algolia/client-account": "4.18.0", + "@algolia/client-analytics": "4.18.0", + "@algolia/client-common": "4.18.0", + "@algolia/client-personalization": "4.18.0", + "@algolia/client-search": "4.18.0", + "@algolia/logger-common": "4.18.0", + "@algolia/logger-console": "4.18.0", + "@algolia/requester-browser-xhr": "4.18.0", + "@algolia/requester-common": "4.18.0", + "@algolia/requester-node-http": "4.18.0", + "@algolia/transporter": "4.18.0" } }, "algoliasearch-helper": { - "version": "3.11.1", - "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.11.1.tgz", - "integrity": "sha512-mvsPN3eK4E0bZG0/WlWJjeqe/bUD2KOEVOl0GyL/TGXn6wcpZU8NOuztGHCUKXkyg5gq6YzUakVTmnmSSO5Yiw==", + "version": "3.13.3", + "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.13.3.tgz", + "integrity": "sha512-jhbbuYZ+fheXpaJlqdJdFa1jOsrTWKmRRTYDM3oVTto5VodZzM7tT+BHzslAotaJf/81CKrm6yLRQn8WIr/K4A==", "requires": { "@algolia/events": "^4.0.1" } @@ -18226,9 +18306,9 @@ "integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==" }, "copy-text-to-clipboard": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.0.1.tgz", - "integrity": "sha512-rvVsHrpFcL4F2P8ihsoLdFHmd404+CMg71S756oRSeQgqk51U3kicGdnvfkrxva0xXH92SjGS62B0XIJsbh+9Q==" + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.0.tgz", + "integrity": "sha512-RnJFp1XR/LOBDckxTib5Qjr/PMfkatD0MUCQgdpqS8MdKiNUzBjAQBEN6oUy+jW7LI93BBG3DtMB2KOOKpGs2Q==" }, "copy-webpack-plugin": { "version": "11.0.0", @@ -18341,11 +18421,11 @@ } }, "cross-fetch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", - "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", + "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", "requires": { - "node-fetch": "2.6.7" + "node-fetch": "^2.6.12" } }, "cross-spawn": { @@ -18486,12 +18566,12 @@ } }, "cssnano-preset-advanced": { - "version": "5.3.8", - "resolved": "https://registry.npmjs.org/cssnano-preset-advanced/-/cssnano-preset-advanced-5.3.8.tgz", - "integrity": "sha512-xUlLLnEB1LjpEik+zgRNlk8Y/koBPPtONZjp7JKbXigeAmCrFvq9H0pXW5jJV45bQWAlmJ0sKy+IMr0XxLYQZg==", + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/cssnano-preset-advanced/-/cssnano-preset-advanced-5.3.10.tgz", + "integrity": "sha512-fnYJyCS9jgMU+cmHO1rPSPf9axbQyD7iUhLO5Df6O4G+fKIOMps+ZbU0PdGFejFBBZ3Pftf18fn1eG7MAPUSWQ==", "requires": { - "autoprefixer": "^10.3.7", - "cssnano-preset-default": "^5.2.12", + "autoprefixer": "^10.4.12", + "cssnano-preset-default": "^5.2.14", "postcss-discard-unused": "^5.1.0", "postcss-merge-idents": "^5.1.1", "postcss-reduce-idents": "^5.2.0", @@ -18499,24 +18579,24 @@ } }, "cssnano-preset-default": { - "version": "5.2.12", - "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.12.tgz", - "integrity": "sha512-OyCBTZi+PXgylz9HAA5kHyoYhfGcYdwFmyaJzWnzxuGRtnMw/kR6ilW9XzlzlRAtB6PLT/r+prYgkef7hngFew==", + "version": "5.2.14", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.14.tgz", + "integrity": "sha512-t0SFesj/ZV2OTylqQVOrFgEh5uanxbO6ZAdeCrNsUQ6fVuXwYTxJPNAGvGTxHbD68ldIJNec7PyYZDBrfDQ+6A==", "requires": { - "css-declaration-sorter": "^6.3.0", + "css-declaration-sorter": "^6.3.1", "cssnano-utils": "^3.1.0", "postcss-calc": "^8.2.3", - "postcss-colormin": "^5.3.0", - "postcss-convert-values": "^5.1.2", + "postcss-colormin": "^5.3.1", + "postcss-convert-values": "^5.1.3", "postcss-discard-comments": "^5.1.2", "postcss-discard-duplicates": "^5.1.0", "postcss-discard-empty": "^5.1.1", "postcss-discard-overridden": "^5.1.0", - "postcss-merge-longhand": "^5.1.6", - "postcss-merge-rules": "^5.1.2", + "postcss-merge-longhand": "^5.1.7", + "postcss-merge-rules": "^5.1.4", "postcss-minify-font-values": "^5.1.0", "postcss-minify-gradients": "^5.1.1", - "postcss-minify-params": "^5.1.3", + "postcss-minify-params": "^5.1.4", "postcss-minify-selectors": "^5.2.1", "postcss-normalize-charset": "^5.1.0", "postcss-normalize-display-values": "^5.1.0", @@ -18524,11 +18604,11 @@ "postcss-normalize-repeat-style": "^5.1.1", "postcss-normalize-string": "^5.1.0", "postcss-normalize-timing-functions": "^5.1.0", - "postcss-normalize-unicode": "^5.1.0", + "postcss-normalize-unicode": "^5.1.1", "postcss-normalize-url": "^5.1.0", "postcss-normalize-whitespace": "^5.1.1", "postcss-ordered-values": "^5.1.3", - "postcss-reduce-initial": "^5.1.0", + "postcss-reduce-initial": "^5.1.2", "postcss-reduce-transforms": "^5.1.0", "postcss-svgo": "^5.1.0", "postcss-unique-selectors": "^5.1.1" @@ -19060,9 +19140,9 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" }, "eta": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/eta/-/eta-1.12.3.tgz", - "integrity": "sha512-qHixwbDLtekO/d51Yr4glcaUJCIjGVJyTzuqV4GPlgZo1YpgOKG+avQynErZIYrfM6JIJdtiG2Kox8tbb+DoGg==" + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-2.2.0.tgz", + "integrity": "sha512-UVQ72Rqjy/ZKQalzV5dCCJP80GrmPrMxh6NlNf+erV6ObL0ZFkhCstWRawS85z3smdr3d2wXPsZEY7rDPfGd2g==" }, "etag": { "version": "1.8.1", @@ -19265,9 +19345,9 @@ } }, "fbjs": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.4.tgz", - "integrity": "sha512-ucV0tDODnGV3JCnnkmoszb5lf4bNpzjv80K41wd4k798Etq+UYD0y0TIfalLjZoKgjive6/adkRnszwapiDgBQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", + "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", "requires": { "cross-fetch": "^3.1.5", "fbjs-css-vars": "^1.0.0", @@ -19275,7 +19355,7 @@ "object-assign": "^4.1.0", "promise": "^7.1.1", "setimmediate": "^1.0.5", - "ua-parser-js": "^0.7.30" + "ua-parser-js": "^1.0.35" } }, "fbjs-css-vars": { @@ -19379,9 +19459,9 @@ } }, "flux": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/flux/-/flux-4.0.3.tgz", - "integrity": "sha512-yKAbrp7JhZhj6uiT1FTuVMlIAT1J4jqEyBpFApi1kxpGZCvacMVc/t1pMQyotqHhAgvoE3bNvAykhCo2CLjnYw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/flux/-/flux-4.0.4.tgz", + "integrity": "sha512-NCj3XlayA2UsapRpM7va6wU1+9rE5FIL7qoMcmxWHRzbp0yujihMBm9BBHZ1MDIk5h5o2Bl6eGiCe8rYELAmYw==", "requires": { "fbemitter": "^3.0.0", "fbjs": "^3.0.1" @@ -20205,9 +20285,9 @@ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==" }, "infima": { - "version": "0.2.0-alpha.42", - "resolved": "https://registry.npmjs.org/infima/-/infima-0.2.0-alpha.42.tgz", - "integrity": "sha512-ift8OXNbQQwtbIt6z16KnSWP7uJ/SysSMFI4F87MNRTicypfl4Pv3E2OGVv6N3nSZFJvA8imYulCBS64iyHYww==" + "version": "0.2.0-alpha.43", + "resolved": "https://registry.npmjs.org/infima/-/infima-0.2.0-alpha.43.tgz", + "integrity": "sha512-2uw57LvUqW0rK/SWYnd/2rRfxNA5DDNOh33jxF7fy46VWoNhGxiUQyVZHbBMjQ33mQem0cjdDVwgWVAmlRfgyQ==" }, "inflight": { "version": "1.0.6", @@ -21052,9 +21132,9 @@ } }, "node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", "requires": { "whatwg-url": "^5.0.0" } @@ -21638,9 +21718,9 @@ } }, "postcss": { - "version": "8.4.24", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.24.tgz", - "integrity": "sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==", + "version": "8.4.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.25.tgz", + "integrity": "sha512-7taJ/8t2av0Z+sQEvNzCkpDynl0tX3uJMCODi6nT3PfASC7dYCWV9aQ+uiCf+KBD4SEFcu+GvJdGdwzQ6OSjCw==", "requires": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -21657,22 +21737,22 @@ } }, "postcss-colormin": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.3.0.tgz", - "integrity": "sha512-WdDO4gOFG2Z8n4P8TWBpshnL3JpmNmJwdnfP2gbk2qBA8PWwOYcmjmI/t3CmMeL72a7Hkd+x/Mg9O2/0rD54Pg==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.3.1.tgz", + "integrity": "sha512-UsWQG0AqTFQmpBegeLLc1+c3jIqBNB0zlDGRWR+dQ3pRKJL1oeMzyqmH3o2PIfn9MBdNrVPWhDbT769LxCTLJQ==", "requires": { - "browserslist": "^4.16.6", + "browserslist": "^4.21.4", "caniuse-api": "^3.0.0", "colord": "^2.9.1", "postcss-value-parser": "^4.2.0" } }, "postcss-convert-values": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.2.tgz", - "integrity": "sha512-c6Hzc4GAv95B7suy4udszX9Zy4ETyMCgFPUDtWjdFTKH1SE9eFY/jEpHSwTH1QPuwxHpWslhckUQWbNRM4ho5g==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.3.tgz", + "integrity": "sha512-82pC1xkJZtcJEfiLw6UXnXVXScgtBrjlO5CBmuDQc+dlb88ZYheFsjTn40+zBVi3DkfF7iezO0nJUPLcJK3pvA==", "requires": { - "browserslist": "^4.20.3", + "browserslist": "^4.21.4", "postcss-value-parser": "^4.2.0" } }, @@ -21762,20 +21842,20 @@ } }, "postcss-merge-longhand": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.6.tgz", - "integrity": "sha512-6C/UGF/3T5OE2CEbOuX7iNO63dnvqhGZeUnKkDeifebY0XqkkvrctYSZurpNE902LDf2yKwwPFgotnfSoPhQiw==", + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.7.tgz", + "integrity": "sha512-YCI9gZB+PLNskrK0BB3/2OzPnGhPkBEwmwhfYk1ilBHYVAZB7/tkTHFBAnCrvBBOmeYyMYw3DMjT55SyxMBzjQ==", "requires": { "postcss-value-parser": "^4.2.0", - "stylehacks": "^5.1.0" + "stylehacks": "^5.1.1" } }, "postcss-merge-rules": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.2.tgz", - "integrity": "sha512-zKMUlnw+zYCWoPN6yhPjtcEdlJaMUZ0WyVcxTAmw3lkkN/NDMRkOkiuctQEoWAOvH7twaxUUdvBWl0d4+hifRQ==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.4.tgz", + "integrity": "sha512-0R2IuYpgU93y9lhVbO/OylTtKMVcHb67zjWIfCiKR9rWL3GUk1677LAqD/BcHizukdZEjT8Ru3oHRoAYoJy44g==", "requires": { - "browserslist": "^4.16.6", + "browserslist": "^4.21.4", "caniuse-api": "^3.0.0", "cssnano-utils": "^3.1.0", "postcss-selector-parser": "^6.0.5" @@ -21800,11 +21880,11 @@ } }, "postcss-minify-params": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.1.3.tgz", - "integrity": "sha512-bkzpWcjykkqIujNL+EVEPOlLYi/eZ050oImVtHU7b4lFS82jPnsCb44gvC6pxaNt38Els3jWYDHTjHKf0koTgg==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.1.4.tgz", + "integrity": "sha512-+mePA3MgdmVmv6g+30rn57USjOGSAyuxUmkfiWpzalZ8aiBkdPYjXWtHuwJGm1v5Ojy0Z0LaSYhHaLJQB0P8Jw==", "requires": { - "browserslist": "^4.16.6", + "browserslist": "^4.21.4", "cssnano-utils": "^3.1.0", "postcss-value-parser": "^4.2.0" } @@ -21904,11 +21984,11 @@ } }, "postcss-normalize-unicode": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.0.tgz", - "integrity": "sha512-J6M3MizAAZ2dOdSjy2caayJLQT8E8K9XjLce8AUQMwOrCvjCHv24aLC/Lps1R1ylOfol5VIDMaM/Lo9NGlk1SQ==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.1.tgz", + "integrity": "sha512-qnCL5jzkNUmKVhZoENp1mJiGNPcsJCs1aaRmURmeJGES23Z/ajaln+EPTD+rBeNkSryI+2WTdW+lwcVdOikrpA==", "requires": { - "browserslist": "^4.16.6", + "browserslist": "^4.21.4", "postcss-value-parser": "^4.2.0" } }, @@ -21947,11 +22027,11 @@ } }, "postcss-reduce-initial": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.0.tgz", - "integrity": "sha512-5OgTUviz0aeH6MtBjHfbr57tml13PuedK/Ecg8szzd4XRMbYxH4572JFG067z+FqBIf6Zp/d+0581glkvvWMFw==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.2.tgz", + "integrity": "sha512-dE/y2XRaqAi6OvjzD22pjTUQ8eOfc6m/natGHgKFBK9DxFmIm69YmaRVQrGgFlEfc1HePIurY0TmDeROK05rIg==", "requires": { - "browserslist": "^4.16.6", + "browserslist": "^4.21.4", "caniuse-api": "^3.0.0" } }, @@ -21973,9 +22053,9 @@ } }, "postcss-sort-media-queries": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/postcss-sort-media-queries/-/postcss-sort-media-queries-4.3.0.tgz", - "integrity": "sha512-jAl8gJM2DvuIJiI9sL1CuiHtKM4s5aEIomkU8G3LFvbP+p8i7Sz8VV63uieTgoewGqKbi+hxBTiOKJlB35upCg==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/postcss-sort-media-queries/-/postcss-sort-media-queries-4.4.1.tgz", + "integrity": "sha512-QDESFzDDGKgpiIh4GYXsSy6sek2yAwQx1JASl5AxBtU1Lq2JfKBljIPNdil989NcSKRQX1ToiaKphImtBuhXWw==", "requires": { "sort-css-media-queries": "2.1.0" } @@ -22592,11 +22672,11 @@ } }, "react-textarea-autosize": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.3.4.tgz", - "integrity": "sha512-CdtmP8Dc19xL8/R6sWvtknD/eCXkQr30dtvC4VmGInhRsfF8X/ihXCq6+9l9qbxmKRiq407/7z5fxE7cVWQNgQ==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.2.tgz", + "integrity": "sha512-uOkyjkEl0ByEK21eCJMHDGBAAd/BoFQBawYK5XItjAmCTeSbjxghd8qnt7nzsLYzidjnoObu6M26xts0YGKsGg==", "requires": { - "@babel/runtime": "^7.10.2", + "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" } @@ -22687,9 +22767,9 @@ } }, "regenerator-runtime": { - "version": "0.13.10", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz", - "integrity": "sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw==" + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, "regenerator-transform": { "version": "0.15.0", @@ -23344,6 +23424,12 @@ "ajv-keywords": "^3.5.2" } }, + "search-insights": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.7.0.tgz", + "integrity": "sha512-GLbVaGgzYEKMvuJbHRhLi1qoBFnjXZGZ6l4LxOYPCp4lI2jDRB3jPU9/XNhMwv6kvnA9slTreq6pvK+b3o3aqg==", + "peer": true + }, "section-matter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", @@ -23824,11 +23910,11 @@ } }, "stylehacks": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.0.tgz", - "integrity": "sha512-SzLmvHQTrIWfSgljkQCw2++C9+Ne91d/6Sp92I8c5uHTcy/PgeHamwITIbBW9wnFTY/3ZfSXR9HIL6Ikqmcu6Q==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", + "integrity": "sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw==", "requires": { - "browserslist": "^4.16.6", + "browserslist": "^4.21.4", "postcss-selector-parser": "^6.0.4" } }, @@ -24231,14 +24317,14 @@ } }, "typescript": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.3.tgz", - "integrity": "sha512-XH627E9vkeqhlZFQuL+UsyAXEnibT0kWR2FWONlr4sTjvxyJYnyefgrkyECLzM5NenmKzRAy2rR/OlYLA1HkZw==" + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==" }, "ua-parser-js": { - "version": "0.7.32", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.32.tgz", - "integrity": "sha512-f9BESNVhzlhEFf2CHMSj40NWOjYPl1YKYbrvIr/hFTDEmLq7SRbWvm7FcdcpCYT95zrOhC7gZSxjdnnTpBcwVw==" + "version": "1.0.35", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz", + "integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==" }, "unherit": { "version": "1.1.3", @@ -24552,6 +24638,12 @@ "use-isomorphic-layout-effect": "^1.1.1" } }, + "use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "requires": {} + }, "util": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", diff --git a/docs/package.json b/docs/package.json index e92b8124e..241060fab 100644 --- a/docs/package.json +++ b/docs/package.json @@ -17,14 +17,14 @@ "check": "tsc" }, "dependencies": { - "@docusaurus/core": "2.1.0", - "@docusaurus/preset-classic": "2.1.0", + "@docusaurus/core": "^2.4.1", + "@docusaurus/preset-classic": "^2.4.1", "@mdx-js/react": "^1.6.22", "autoprefixer": "^10.4.13", "clsx": "^1.2.1", "docusaurus-lunr-search": "^2.3.2", "docusaurus-preset-openapi": "^0.6.3", - "postcss": "^8.4.20", + "postcss": "^8.4.25", "prism-react-renderer": "^1.3.5", "react": "^17.0.2", "react-dom": "^17.0.2", @@ -32,10 +32,10 @@ "url": "^0.11.0" }, "devDependencies": { - "@docusaurus/module-type-aliases": "2.1.0", + "@docusaurus/module-type-aliases": "^2.4.1", "@tsconfig/docusaurus": "^1.0.5", "prettier": "^2.8.8", - "typescript": "^5.0.0" + "typescript": "^5.1.6" }, "browserslist": { "production": [ From 0b15f6035b0a0b1df3ca1d7a291399218c5000b5 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 11 Jul 2023 11:10:28 -0400 Subject: [PATCH 14/38] docs: add mobile links (#3214) --- docs/src/pages/index.tsx | 15 +++++++- docs/static/img/google-play-badge.png | Bin 0 -> 4904 bytes docs/static/img/ios-app-store-badge.svg | 46 ++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 docs/static/img/google-play-badge.png create mode 100755 docs/static/img/ios-app-store-badge.svg diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index 4e2b890c1..493656bf6 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -32,6 +32,19 @@ function HomepageHeader() { logo + +
+
+ + Get it on Google Play + +
+
+ + Download on the App Store + +
+
); @@ -40,7 +53,7 @@ function HomepageHeader() { export default function Home(): JSX.Element { return ( diff --git a/docs/static/img/google-play-badge.png b/docs/static/img/google-play-badge.png new file mode 100644 index 0000000000000000000000000000000000000000..131f3acaa252a863c3b694d0f522ea750aebd81c GIT binary patch literal 4904 zcmZu#X*kqj*B?vC^fxJqAzKJz8HNyL8$|XkODM@MO?I+mn~{BA$1;lSlw{2|q>(kd zNMqlPoovtaK40Ds&$+JqKIi(~zjLm$eY)T3YCoU@vw%S$5S_Z3vOWkzi6+~BFHw>2 z%YvR`AP~iruBM^N1C*w*v9YD4rLC>4i;Ihum6f-*cT!SPK|w)ubhMkBo4L7pR#sMM zXlO!0g1x=Hy1F_H24iGoB*)?4;2;yQSgeGEgulN(27{54lQS?dFf}!GbaVuR!DNse znUj-KK|uk9LebOHzj^cK>C>m8qN2LGx-^VnriVOiPvCq3ay;&LSqx$HcUY>bsumU& zR#sNd&(CLOX5!=Hr>3U1x3@bwI@Z?K4i67!XJgwwD_V)Jo_gh+8mY0`(eSLR# zcdyHH{Q2{TL?UrHNTjBw@>p2U&(AkBG$5YW%0yReZEeYTj3|Ze-cP9M>FJSvuT7|6 za512i$=4pKWjJo6p3J9~hf~NFXqN?3aWGTd|DYXUNl6cY);>~OKA@B@(*7DlmE}pL zP^7IROA+Hp85b9qo15E{PE}c1+27xPuh0w8dyslD8GibTPTcb_T*4wKq!--YlsEE~=_}VEV1t$aCU!*Vi@x$yAJQcK#)|bwBR>jI92jI4+!@YWtUy zw}pN}U0Ko4n__Lu=-H|Qy-I7mx|CQ~u;^M~Vk{InTTy%m8?-@5j`_buJ~zFG=}C+0 zNK)eC@`)4b-rmvq&2S znYL+>MWNEpl?(mg_OiwMBs(k}nb*mWUPg0tC|{`x2!y zr?doYs^bD|j#gy5wbbyZbAtK7y`XdA7sF3#h<(zNhKNzC%(*dqpvPW0-X_8SiViAy z?#oc+!=HO3>QVI#$FGD2_Usus0-LVjF`>XvRKs>} zj!S2hKo1QD;<>*6S2fyAH>H@3;zVPnvyb6cq3I zpsU<=E?!Zh?1*wx^rDVE7s0-2YicvaW~$bZ8)jROv;lCag16 za=qp5L-`1v7FG`xG;1fQ1iIxSFeazO$`Nzj;*meZ<-?^I9ceLvQkscm8a7I{Wd1m z;b-2)IVa%Y^gjxAAjs%&;juCau?yHeURI8*YB%+!ZYd4#>%83`ee+Rb8HEYAR72CV z0n2h!f7LM*B>jcTHHED&E|vJm?8ahj_zbmz)a^_&!mZ+1tE86d{1FOgYyhO67Iot_ z8VmFdZksK(8_~3Iy4vVR13RbM*wZ1EGp;=XDvd(b2OR}?{j4w><4p;r5YZu&siNa4 zByAryabA(XC}GH;&?i1;U5nLTF4h7psMNw^ttyMWb$1_sWz1!92nHXn0&%7)z=%?XN?tKZSt zTuk^6i(t%ho6zuMs{VYt@5J^Wqpep&KOfia?QC_t8Pwpsn$5+4UtD~MS@z3Z>5lp| zrZAu`3{0J3pX6Y;|FuC5olV@)Fw=92SuepU#-Fh^v*=5JdY_STt7^QvQ|huE-Z=pm zFWI_VWo5ZZFB%bxvol#2m7lgg(lQOWF7+m{d0lG$UOvSxGt?J73|T*7A6!LST|D{w z%Tme@&OS%!!|aQm_NsO3^@{jOf$9l}n1M)N>|YZn56lfSEbJ_vCD3P1sY=Qg;A?~M zJKO%TUV=W7{k-@ZkaFgq1fjZL;}=||#s2JGlb$6%1x#Bx7409RXT0O&BmZu&LLP3g zHl+Zypq3x~;-#MUaJx~T3bWW!;1&Ap?1IX>DLuUUN zcb%TRrL*d!n3Fzjmu4j1ok)|vX&~7PijaB}m5#Gpl9`j6X3+np52K091A@L7s zi26+ukITIe=bsjO**D+K{6E$7Ky*lU&A~tRX`#C`i%vTt826j#zTgwUX?qpegW$v3 zRP=--|Cx#DH3B`MQ6a=OoGLM|k^U1H4aUrO%a(#eo&SN_#Jmi6m5QjLw6pDRQy;i@?48gM7z zAQ^?d)+fBSCCi_y#FR0ON^%F~ueVg^=e!W^?yPx#<~ruZ%u5Bo7_cJ0;gq=+`rM-> zT6f;oP&}LMyS5~=<@fXHk!R1hIJa;x?-7-7G>hL|#o^w{GRP7ue z?bnZ5cA9GJgHsmh-!4EtL=VR)&u2BH{HxyqG{JZBenSMgDTzj43U!lKqrRICNKEkP zn%qauN0>FIC~Ai45K2SUjz29DK z*WJXf4BrnB7cSL@MrL0?Fw_aDs-1f2b5uz z+U(1|4vFtIA7}CiSPqTrB*;+u2;q;|__%GpH_}r5>WvP_w$yp8XK?em>nY$9b`7&V z%jzO|yJSGz!QlqxJ{NOlzD5B1V_Os76Fs<%y$DCNlimHqrQ615)?0iOM)KrTTW}|$ znG34Pp(1a)K9?<8AL8OK_r(RC=m}eo9Y)$0SZt2-U@Dx?_67WY?};mixni2n1=R=F zpv5c(T`D1Sm!TKHi`AsmH4t~C=&gbIt z;-mUHpobeGas(*4`=LE$!f(Ip6TZ;AGx&6D3NjgOe6HOWS>`AGp-3y8=>9NOD68YP zpL@2I826~tm(+LV8S?1ZC5cy;lish|T>@W#5F1Bl#U-RKAG}*Yk2LPUCA>y$e|L)t za;8|dbmmOog4;6Rk@xo6>2kY2Q?D$>meY1t?TRkcUC<5qdk3#7Y?F33OuDw%0iJr) zsa1vG_)*$!il&nbINDQ##uQ6q4E*hl=VKK48i&Mk1vL2)nzE7W%M}qC%-=Rh5%uD?L!C=QIPG|sWIDb za}gZ9oOLeJmS3BpeEWV^7D(c-TRQe#B(HCx?&7t@Isw=CY?;dnPJ!gNiPLy0#OIxg ze$Y#;Ve}(Wk?T{hbUGE zA(KyqXrcPSqd$bqif#DyE*NQ3+A4J9@6L zLi@t!6XSCl>HDj*yByp`UG&$x+|DVcI4jX{HPOmuC>$U6O~@E8k3lvxjS6VE{rb|7 zZ&`cg!F)FiGxc0lbQob?P9k#GTjlvFO}(o?APXbLJNwrV60T^b!@L|Gli<>p{=@s} z9_K!YJ0r>e*jo(OR7GL2&pz(o5Zl%4S&sl%;ZF;7e}is2J+Cz<4%)fzEVZZ7rug{y zmnz9*e>m{?tWy<@3${^z5N8CvxUlTz z-?(Q7W${jQwBdhrxXoC`j0M$wQhM)N%CU26`hqwhkm6XMWSL{t+04{Xanbf|HNsEW z@NnU78EZ1+beNW{fY*36MH1wQ_d)~srb;^H*pH@JR(LsqCzGvl?$tmTn4>v&c#v&u z+M%@w`QTC%d^h5gQpM-mevO`-tGhX0Ez_-j$O$|xOuJ8Sgt!hfche7tbwz5w;C8bN zC|bKC41b+<7Wv3*KVuPs`xVn81Ef15y!8WqHdq^$0XE>x=DgWvfi3~ z(MZCITSkaIg93x6Q#N-ME$5LxV3hGvspX|&GbPgd@vRzh3aBJILN_0net2EZPy0dS zUc#KiRm6tJr!ZkX5w4em>Dg1)!Z>EfSe?-B_8Nwf9E}biqzEOVL$oUmQDPxEp_~*? zjPe0qa&7Od3pBDXPcxNQD-NkeIBNh7q>d=x3uu}gBjW3uMcU|VVVgMvuVz)7 zUDq$w{8;P&_V^9Fsn9ZD=*;J~5rS?m>5N7uAd2ZESxeKc3Xk;7;DOty$y{eF$H#4M zV_L){ysHb8&>YPP$!=`&#X?-rBfW>Vn36Sq-iAYd-j&PK!5+-pkOw`8AzI4-e?Z>9 zTIuenFFix7?vE=eZj-I9w*+BA_e>zhQ?-Q_@m$MRb_^UpbdPolSD1s9Oz)0vy;~QR zN|S{>sCoDPr%F1%K)lOEKXWYxG^BU3J@fmDCEn+z70){9>bIEizo--?J(LH~Oatzi zH}BLL+$Rc!e2jspRQuCo|E^95NoSh;I_oyWbT-;t!y4T5Es5Eik~n!q=uVp)>Li`Z z2lB!fWjdA|&oqOA0vaUIPnr@^-Ljsu)UpIbh8U$VBK&yBgdm9!k@YsmrIgOL{M8la zpGb0=w8}LD$4x_>hJ(-y7_^C>>}F`LJ!u>C<%|5{qDL~YtW}3yO-dM>QA~xUv$VMc z$v~TYyU8?@D7%~W)c)PV5X<`VBrbpNf1qJyURPnHczrJQj3><*r&}4YJnr9wT{Uc5 zL#DCCWEU?34HL}IEE~tmuj}u3jF}Z&k6*D4KO$``pB}?=P+=08c=Mj9ys|_tTa9u< zuIz()u~K<7+_H4xr-zPL5b*|6+DwO|46;HJy@wHCzsXX}nRPcZUlxyw+~}r1OLWc% zta)rq8$%^VuOR#9mDZRTfIo%Tb!%qjA3$>9>PDyMtt`*z{(H`7N{KVSM(JH(WyF*5 zNo1cCQH)S};#@sPMv z^CwKdmU^#-)~Z&*a{W2wZX34m;pzQu?=^Xf`jUHt~g4A>3^F^`QwuC zwS7#v>^`Zx-vjfi>vDBl7VCIM9b@5<5gGJs-tJL6%sq9 g6#x5Mf{gz51&^3@tr*$xbpPY1t7t2iD?JVV5121yGXMYp literal 0 HcmV?d00001 diff --git a/docs/static/img/ios-app-store-badge.svg b/docs/static/img/ios-app-store-badge.svg new file mode 100755 index 000000000..072b425a1 --- /dev/null +++ b/docs/static/img/ios-app-store-badge.svg @@ -0,0 +1,46 @@ + + Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 9ad024c189d45a64c05d8718ce30037f5db40d66 Mon Sep 17 00:00:00 2001 From: Jesaja Everling Date: Tue, 11 Jul 2023 18:48:13 +0200 Subject: [PATCH 15/38] Fix typo (#3216) --- docs/docs/features/bulk-upload.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/features/bulk-upload.md b/docs/docs/features/bulk-upload.md index b2821e390..2e730ea1c 100644 --- a/docs/docs/features/bulk-upload.md +++ b/docs/docs/features/bulk-upload.md @@ -56,7 +56,7 @@ The API key can be obtained in the user setting panel on the web interface. --- -## Uploading exiting libraries +## Uploading existing libraries ### Run via Docker From 848ba685eb25908e755ea30e54d78eaafbae5693 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Tue, 11 Jul 2023 13:01:21 -0400 Subject: [PATCH 16/38] fix(ml): race condition when loading models (#3207) * sync model loading, disabled model ttl by default * disable revalidation if model unloading disabled * moved lock --- machine-learning/app/config.py | 2 +- machine-learning/app/main.py | 2 +- machine-learning/app/models/cache.py | 12 ++++-------- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/machine-learning/app/config.py b/machine-learning/app/config.py index 70520b27c..f5cb83595 100644 --- a/machine-learning/app/config.py +++ b/machine-learning/app/config.py @@ -13,7 +13,7 @@ class Settings(BaseSettings): facial_recognition_model: str = "buffalo_l" min_tag_score: float = 0.9 eager_startup: bool = True - model_ttl: int = 300 + model_ttl: int = 0 host: str = "0.0.0.0" port: int = 3003 workers: int = 1 diff --git a/machine-learning/app/main.py b/machine-learning/app/main.py index 35ee27204..264eb2ee8 100644 --- a/machine-learning/app/main.py +++ b/machine-learning/app/main.py @@ -25,7 +25,7 @@ app = FastAPI() def init_state() -> None: - app.state.model_cache = ModelCache(ttl=settings.model_ttl, revalidate=True) + app.state.model_cache = ModelCache(ttl=settings.model_ttl, revalidate=settings.model_ttl > 0) async def load_models() -> None: diff --git a/machine-learning/app/models/cache.py b/machine-learning/app/models/cache.py index 086a57c5a..b9d5f75a0 100644 --- a/machine-learning/app/models/cache.py +++ b/machine-learning/app/models/cache.py @@ -1,4 +1,3 @@ -import asyncio from typing import Any from aiocache.backends.memory import SimpleMemoryCache @@ -48,13 +47,10 @@ class ModelCache: """ key = self.cache.build_key(model_name, model_type.value) - model = await self.cache.get(key) - if model is None: - async with OptimisticLock(self.cache, key) as lock: - model = await asyncio.get_running_loop().run_in_executor( - None, - lambda: InferenceModel.from_model_type(model_type, model_name, **model_kwargs), - ) + async with OptimisticLock(self.cache, key) as lock: + model = await self.cache.get(key) + if model is None: + model = InferenceModel.from_model_type(model_type, model_name, **model_kwargs) await lock.cas(model, ttl=self.ttl) return model From c86b2ae500955f413cc6ac6912669f9fe5ae1670 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 11 Jul 2023 16:52:41 -0500 Subject: [PATCH 17/38] feat(web/server): merge faces (#3121) * feat(server/web): Merge faces * get parent id * update * query to get identical asset and change controller * change delete asset signature * delete identical assets * gaming time * delete merge person * query * query * generate api * pr feedback * generate api * naming * remove unused method * Update server/src/domain/person/person.service.ts Co-authored-by: Jason Rasmussen * Update server/src/domain/person/person.service.ts Co-authored-by: Jason Rasmussen * better method signature * cleaning up * fix bug * added interfaces * added tests * merge main * api * build merge face interface * api * selector interface * style * more style * clean up import * styling * styling * better * styling * styling * add merge face diablog * finished * refactor: merge person endpoint * refactor: merge person component * chore: open api * fix: tests --------- Co-authored-by: Jason Rasmussen --- cli/src/api/open-api/api.ts | 148 +++++++++++++ mobile/openapi/.openapi-generator/FILES | 6 + mobile/openapi/README.md | 3 + mobile/openapi/doc/BulkIdResponseDto.md | 17 ++ mobile/openapi/doc/MergePersonDto.md | 15 ++ mobile/openapi/doc/PersonApi.md | 58 ++++++ mobile/openapi/lib/api.dart | 2 + mobile/openapi/lib/api/person_api.dart | 55 +++++ mobile/openapi/lib/api_client.dart | 4 + .../lib/model/bulk_id_response_dto.dart | 197 ++++++++++++++++++ .../openapi/lib/model/merge_person_dto.dart | 100 +++++++++ .../test/bulk_id_response_dto_test.dart | 37 ++++ .../openapi/test/merge_person_dto_test.dart | 27 +++ mobile/openapi/test/person_api_test.dart | 5 + server/immich-openapi-specs.json | 94 +++++++++ .../response-dto/asset-ids-response.dto.ts | 15 ++ server/src/domain/person/person.dto.ts | 6 + server/src/domain/person/person.repository.ts | 10 +- .../src/domain/person/person.service.spec.ts | 84 +++++++- server/src/domain/person/person.service.ts | 137 +++++++----- .../immich/controllers/person.controller.ts | 13 +- .../infra/repositories/person.repository.ts | 34 ++- server/test/fixtures.ts | 92 ++++++++ .../repositories/person.repository.mock.ts | 2 + web/src/api/open-api/api.ts | 149 +++++++++++++ .../faces-page/face-thumbnail.svelte | 66 ++++++ .../faces-page/merge-face-selector.svelte | 145 +++++++++++++ web/src/routes/(user)/explore/+page.svelte | 2 +- web/src/routes/(user)/people/+page.svelte | 2 +- .../(user)/people/[personId]/+page.svelte | 24 ++- 30 files changed, 1478 insertions(+), 71 deletions(-) create mode 100644 mobile/openapi/doc/BulkIdResponseDto.md create mode 100644 mobile/openapi/doc/MergePersonDto.md create mode 100644 mobile/openapi/lib/model/bulk_id_response_dto.dart create mode 100644 mobile/openapi/lib/model/merge_person_dto.dart create mode 100644 mobile/openapi/test/bulk_id_response_dto_test.dart create mode 100644 mobile/openapi/test/merge_person_dto_test.dart create mode 100644 web/src/lib/components/faces-page/face-thumbnail.svelte create mode 100644 web/src/lib/components/faces-page/merge-face-selector.svelte diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index ec57c3591..a4cb44696 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -798,6 +798,41 @@ export interface AuthDeviceResponseDto { */ 'deviceOS': string; } +/** + * + * @export + * @interface BulkIdResponseDto + */ +export interface BulkIdResponseDto { + /** + * + * @type {string} + * @memberof BulkIdResponseDto + */ + 'id': string; + /** + * + * @type {boolean} + * @memberof BulkIdResponseDto + */ + 'success': boolean; + /** + * + * @type {string} + * @memberof BulkIdResponseDto + */ + 'error'?: BulkIdResponseDtoErrorEnum; +} + +export const BulkIdResponseDtoErrorEnum = { + Duplicate: 'duplicate', + NoPermission: 'no_permission', + NotFound: 'not_found', + Unknown: 'unknown' +} as const; + +export type BulkIdResponseDtoErrorEnum = typeof BulkIdResponseDtoErrorEnum[keyof typeof BulkIdResponseDtoErrorEnum]; + /** * * @export @@ -1686,6 +1721,19 @@ export interface MemoryLaneResponseDto { */ 'assets': Array; } +/** + * + * @export + * @interface MergePersonDto + */ +export interface MergePersonDto { + /** + * + * @type {Array} + * @memberof MergePersonDto + */ + 'ids': Array; +} /** * * @export @@ -8807,6 +8855,54 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio options: localVarRequestOptions, }; }, + /** + * + * @param {string} id + * @param {MergePersonDto} mergePersonDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + mergePerson: async (id: string, mergePersonDto: MergePersonDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('mergePerson', 'id', id) + // verify required parameter 'mergePersonDto' is not null or undefined + assertParamExists('mergePerson', 'mergePersonDto', mergePersonDto) + const localVarPath = `/person/{id}/merge` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(mergePersonDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {string} id @@ -8904,6 +9000,17 @@ export const PersonApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonThumbnail(id, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} id + * @param {MergePersonDto} mergePersonDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async mergePerson(id: string, mergePersonDto: MergePersonDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.mergePerson(id, mergePersonDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {string} id @@ -8960,6 +9067,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat getPersonThumbnail(requestParameters: PersonApiGetPersonThumbnailRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.getPersonThumbnail(requestParameters.id, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {PersonApiMergePersonRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + mergePerson(requestParameters: PersonApiMergePersonRequest, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {PersonApiUpdatePersonRequest} requestParameters Request parameters. @@ -9014,6 +9130,27 @@ export interface PersonApiGetPersonThumbnailRequest { readonly id: string } +/** + * Request parameters for mergePerson operation in PersonApi. + * @export + * @interface PersonApiMergePersonRequest + */ +export interface PersonApiMergePersonRequest { + /** + * + * @type {string} + * @memberof PersonApiMergePerson + */ + readonly id: string + + /** + * + * @type {MergePersonDto} + * @memberof PersonApiMergePerson + */ + readonly mergePersonDto: MergePersonDto +} + /** * Request parameters for updatePerson operation in PersonApi. * @export @@ -9085,6 +9222,17 @@ export class PersonApi extends BaseAPI { return PersonApiFp(this.configuration).getPersonThumbnail(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {PersonApiMergePersonRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof PersonApi + */ + public mergePerson(requestParameters: PersonApiMergePersonRequest, options?: AxiosRequestConfig) { + return PersonApiFp(this.configuration).mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {PersonApiUpdatePersonRequest} requestParameters Request parameters. diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 9862f98c4..86742e468 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -32,6 +32,7 @@ doc/AssetTypeEnum.md doc/AudioCodec.md doc/AuthDeviceResponseDto.md doc/AuthenticationApi.md +doc/BulkIdResponseDto.md doc/ChangePasswordDto.md doc/CheckDuplicateAssetDto.md doc/CheckDuplicateAssetResponseDto.md @@ -64,6 +65,7 @@ doc/LoginResponseDto.md doc/LogoutResponseDto.md doc/MapMarkerResponseDto.md doc/MemoryLaneResponseDto.md +doc/MergePersonDto.md doc/OAuthApi.md doc/OAuthCallbackDto.md doc/OAuthConfigDto.md @@ -169,6 +171,7 @@ lib/model/asset_response_dto.dart lib/model/asset_type_enum.dart lib/model/audio_codec.dart lib/model/auth_device_response_dto.dart +lib/model/bulk_id_response_dto.dart lib/model/change_password_dto.dart lib/model/check_duplicate_asset_dto.dart lib/model/check_duplicate_asset_response_dto.dart @@ -200,6 +203,7 @@ lib/model/login_response_dto.dart lib/model/logout_response_dto.dart lib/model/map_marker_response_dto.dart lib/model/memory_lane_response_dto.dart +lib/model/merge_person_dto.dart lib/model/o_auth_callback_dto.dart lib/model/o_auth_config_dto.dart lib/model/o_auth_config_response_dto.dart @@ -277,6 +281,7 @@ test/asset_type_enum_test.dart test/audio_codec_test.dart test/auth_device_response_dto_test.dart test/authentication_api_test.dart +test/bulk_id_response_dto_test.dart test/change_password_dto_test.dart test/check_duplicate_asset_dto_test.dart test/check_duplicate_asset_response_dto_test.dart @@ -309,6 +314,7 @@ test/login_response_dto_test.dart test/logout_response_dto_test.dart test/map_marker_response_dto_test.dart test/memory_lane_response_dto_test.dart +test/merge_person_dto_test.dart test/o_auth_api_test.dart test/o_auth_callback_dto_test.dart test/o_auth_config_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index a78726e08..98dc3fac7 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -134,6 +134,7 @@ Class | Method | HTTP request | Description *PersonApi* | [**getPerson**](doc//PersonApi.md#getperson) | **GET** /person/{id} | *PersonApi* | [**getPersonAssets**](doc//PersonApi.md#getpersonassets) | **GET** /person/{id}/assets | *PersonApi* | [**getPersonThumbnail**](doc//PersonApi.md#getpersonthumbnail) | **GET** /person/{id}/thumbnail | +*PersonApi* | [**mergePerson**](doc//PersonApi.md#mergeperson) | **POST** /person/{id}/merge | *PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /person/{id} | *SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | *SearchApi* | [**getSearchConfig**](doc//SearchApi.md#getsearchconfig) | **GET** /search/config | @@ -201,6 +202,7 @@ Class | Method | HTTP request | Description - [AssetTypeEnum](doc//AssetTypeEnum.md) - [AudioCodec](doc//AudioCodec.md) - [AuthDeviceResponseDto](doc//AuthDeviceResponseDto.md) + - [BulkIdResponseDto](doc//BulkIdResponseDto.md) - [ChangePasswordDto](doc//ChangePasswordDto.md) - [CheckDuplicateAssetDto](doc//CheckDuplicateAssetDto.md) - [CheckDuplicateAssetResponseDto](doc//CheckDuplicateAssetResponseDto.md) @@ -232,6 +234,7 @@ Class | Method | HTTP request | Description - [LogoutResponseDto](doc//LogoutResponseDto.md) - [MapMarkerResponseDto](doc//MapMarkerResponseDto.md) - [MemoryLaneResponseDto](doc//MemoryLaneResponseDto.md) + - [MergePersonDto](doc//MergePersonDto.md) - [OAuthCallbackDto](doc//OAuthCallbackDto.md) - [OAuthConfigDto](doc//OAuthConfigDto.md) - [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md) diff --git a/mobile/openapi/doc/BulkIdResponseDto.md b/mobile/openapi/doc/BulkIdResponseDto.md new file mode 100644 index 000000000..ce07f262d --- /dev/null +++ b/mobile/openapi/doc/BulkIdResponseDto.md @@ -0,0 +1,17 @@ +# openapi.model.BulkIdResponseDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**id** | **String** | | +**success** | **bool** | | +**error** | **String** | | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/MergePersonDto.md b/mobile/openapi/doc/MergePersonDto.md new file mode 100644 index 000000000..606f389de --- /dev/null +++ b/mobile/openapi/doc/MergePersonDto.md @@ -0,0 +1,15 @@ +# openapi.model.MergePersonDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**ids** | **List** | | [default to const []] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/PersonApi.md b/mobile/openapi/doc/PersonApi.md index aa37a294e..ee57d0c50 100644 --- a/mobile/openapi/doc/PersonApi.md +++ b/mobile/openapi/doc/PersonApi.md @@ -13,6 +13,7 @@ Method | HTTP request | Description [**getPerson**](PersonApi.md#getperson) | **GET** /person/{id} | [**getPersonAssets**](PersonApi.md#getpersonassets) | **GET** /person/{id}/assets | [**getPersonThumbnail**](PersonApi.md#getpersonthumbnail) | **GET** /person/{id}/thumbnail | +[**mergePerson**](PersonApi.md#mergeperson) | **POST** /person/{id}/merge | [**updatePerson**](PersonApi.md#updateperson) | **PUT** /person/{id} | @@ -232,6 +233,63 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) +# **mergePerson** +> List mergePerson(id, mergePersonDto) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = PersonApi(); +final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final mergePersonDto = MergePersonDto(); // MergePersonDto | + +try { + final result = api_instance.mergePerson(id, mergePersonDto); + print(result); +} catch (e) { + print('Exception when calling PersonApi->mergePerson: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **id** | **String**| | + **mergePersonDto** | [**MergePersonDto**](MergePersonDto.md)| | + +### Return type + +[**List**](BulkIdResponseDto.md) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + # **updatePerson** > PersonResponseDto updatePerson(id, personUpdateDto) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 604f07f19..099f5615c 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -68,6 +68,7 @@ part 'model/asset_response_dto.dart'; part 'model/asset_type_enum.dart'; part 'model/audio_codec.dart'; part 'model/auth_device_response_dto.dart'; +part 'model/bulk_id_response_dto.dart'; part 'model/change_password_dto.dart'; part 'model/check_duplicate_asset_dto.dart'; part 'model/check_duplicate_asset_response_dto.dart'; @@ -99,6 +100,7 @@ part 'model/login_response_dto.dart'; part 'model/logout_response_dto.dart'; part 'model/map_marker_response_dto.dart'; part 'model/memory_lane_response_dto.dart'; +part 'model/merge_person_dto.dart'; part 'model/o_auth_callback_dto.dart'; part 'model/o_auth_config_dto.dart'; part 'model/o_auth_config_response_dto.dart'; diff --git a/mobile/openapi/lib/api/person_api.dart b/mobile/openapi/lib/api/person_api.dart index 37f8bf8a3..3a53bd5eb 100644 --- a/mobile/openapi/lib/api/person_api.dart +++ b/mobile/openapi/lib/api/person_api.dart @@ -207,6 +207,61 @@ class PersonApi { return null; } + /// Performs an HTTP 'POST /person/{id}/merge' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [MergePersonDto] mergePersonDto (required): + Future mergePersonWithHttpInfo(String id, MergePersonDto mergePersonDto,) async { + // ignore: prefer_const_declarations + final path = r'/person/{id}/merge' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody = mergePersonDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [MergePersonDto] mergePersonDto (required): + Future?> mergePerson(String id, MergePersonDto mergePersonDto,) async { + final response = await mergePersonWithHttpInfo(id, mergePersonDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(); + + } + return null; + } + /// Performs an HTTP 'PUT /person/{id}' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 4ddf1833a..5855da8e8 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -231,6 +231,8 @@ class ApiClient { return AudioCodecTypeTransformer().decode(value); case 'AuthDeviceResponseDto': return AuthDeviceResponseDto.fromJson(value); + case 'BulkIdResponseDto': + return BulkIdResponseDto.fromJson(value); case 'ChangePasswordDto': return ChangePasswordDto.fromJson(value); case 'CheckDuplicateAssetDto': @@ -293,6 +295,8 @@ class ApiClient { return MapMarkerResponseDto.fromJson(value); case 'MemoryLaneResponseDto': return MemoryLaneResponseDto.fromJson(value); + case 'MergePersonDto': + return MergePersonDto.fromJson(value); case 'OAuthCallbackDto': return OAuthCallbackDto.fromJson(value); case 'OAuthConfigDto': diff --git a/mobile/openapi/lib/model/bulk_id_response_dto.dart b/mobile/openapi/lib/model/bulk_id_response_dto.dart new file mode 100644 index 000000000..18fb45108 --- /dev/null +++ b/mobile/openapi/lib/model/bulk_id_response_dto.dart @@ -0,0 +1,197 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class BulkIdResponseDto { + /// Returns a new [BulkIdResponseDto] instance. + BulkIdResponseDto({ + required this.id, + required this.success, + this.error, + }); + + String id; + + bool success; + + BulkIdResponseDtoErrorEnum? error; + + @override + bool operator ==(Object other) => identical(this, other) || other is BulkIdResponseDto && + other.id == id && + other.success == success && + other.error == error; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (id.hashCode) + + (success.hashCode) + + (error == null ? 0 : error!.hashCode); + + @override + String toString() => 'BulkIdResponseDto[id=$id, success=$success, error=$error]'; + + Map toJson() { + final json = {}; + json[r'id'] = this.id; + json[r'success'] = this.success; + if (this.error != null) { + json[r'error'] = this.error; + } else { + // json[r'error'] = null; + } + return json; + } + + /// Returns a new [BulkIdResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static BulkIdResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return BulkIdResponseDto( + id: mapValueOfType(json, r'id')!, + success: mapValueOfType(json, r'success')!, + error: BulkIdResponseDtoErrorEnum.fromJson(json[r'error']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = BulkIdResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = BulkIdResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of BulkIdResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = BulkIdResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'id', + 'success', + }; +} + + +class BulkIdResponseDtoErrorEnum { + /// Instantiate a new enum with the provided [value]. + const BulkIdResponseDtoErrorEnum._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const duplicate = BulkIdResponseDtoErrorEnum._(r'duplicate'); + static const noPermission = BulkIdResponseDtoErrorEnum._(r'no_permission'); + static const notFound = BulkIdResponseDtoErrorEnum._(r'not_found'); + static const unknown = BulkIdResponseDtoErrorEnum._(r'unknown'); + + /// List of all possible values in this [enum][BulkIdResponseDtoErrorEnum]. + static const values = [ + duplicate, + noPermission, + notFound, + unknown, + ]; + + static BulkIdResponseDtoErrorEnum? fromJson(dynamic value) => BulkIdResponseDtoErrorEnumTypeTransformer().decode(value); + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = BulkIdResponseDtoErrorEnum.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [BulkIdResponseDtoErrorEnum] to String, +/// and [decode] dynamic data back to [BulkIdResponseDtoErrorEnum]. +class BulkIdResponseDtoErrorEnumTypeTransformer { + factory BulkIdResponseDtoErrorEnumTypeTransformer() => _instance ??= const BulkIdResponseDtoErrorEnumTypeTransformer._(); + + const BulkIdResponseDtoErrorEnumTypeTransformer._(); + + String encode(BulkIdResponseDtoErrorEnum data) => data.value; + + /// Decodes a [dynamic value][data] to a BulkIdResponseDtoErrorEnum. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + BulkIdResponseDtoErrorEnum? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'duplicate': return BulkIdResponseDtoErrorEnum.duplicate; + case r'no_permission': return BulkIdResponseDtoErrorEnum.noPermission; + case r'not_found': return BulkIdResponseDtoErrorEnum.notFound; + case r'unknown': return BulkIdResponseDtoErrorEnum.unknown; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [BulkIdResponseDtoErrorEnumTypeTransformer] instance. + static BulkIdResponseDtoErrorEnumTypeTransformer? _instance; +} + + diff --git a/mobile/openapi/lib/model/merge_person_dto.dart b/mobile/openapi/lib/model/merge_person_dto.dart new file mode 100644 index 000000000..9c4cba764 --- /dev/null +++ b/mobile/openapi/lib/model/merge_person_dto.dart @@ -0,0 +1,100 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class MergePersonDto { + /// Returns a new [MergePersonDto] instance. + MergePersonDto({ + this.ids = const [], + }); + + List ids; + + @override + bool operator ==(Object other) => identical(this, other) || other is MergePersonDto && + other.ids == ids; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (ids.hashCode); + + @override + String toString() => 'MergePersonDto[ids=$ids]'; + + Map toJson() { + final json = {}; + json[r'ids'] = this.ids; + return json; + } + + /// Returns a new [MergePersonDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static MergePersonDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return MergePersonDto( + ids: json[r'ids'] is Iterable + ? (json[r'ids'] as Iterable).cast().toList(growable: false) + : const [], + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = MergePersonDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = MergePersonDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of MergePersonDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = MergePersonDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'ids', + }; +} + diff --git a/mobile/openapi/test/bulk_id_response_dto_test.dart b/mobile/openapi/test/bulk_id_response_dto_test.dart new file mode 100644 index 000000000..25baca986 --- /dev/null +++ b/mobile/openapi/test/bulk_id_response_dto_test.dart @@ -0,0 +1,37 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for BulkIdResponseDto +void main() { + // final instance = BulkIdResponseDto(); + + group('test BulkIdResponseDto', () { + // String id + test('to test the property `id`', () async { + // TODO + }); + + // bool success + test('to test the property `success`', () async { + // TODO + }); + + // String error + test('to test the property `error`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/merge_person_dto_test.dart b/mobile/openapi/test/merge_person_dto_test.dart new file mode 100644 index 000000000..4a22063a8 --- /dev/null +++ b/mobile/openapi/test/merge_person_dto_test.dart @@ -0,0 +1,27 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for MergePersonDto +void main() { + // final instance = MergePersonDto(); + + group('test MergePersonDto', () { + // List ids (default value: const []) + test('to test the property `ids`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/person_api_test.dart b/mobile/openapi/test/person_api_test.dart index 33e78b8a7..95482f63d 100644 --- a/mobile/openapi/test/person_api_test.dart +++ b/mobile/openapi/test/person_api_test.dart @@ -37,6 +37,11 @@ void main() { // TODO }); + //Future> mergePerson(String id, MergePersonDto mergePersonDto) async + test('test mergePerson', () async { + // TODO + }); + //Future updatePerson(String id, PersonUpdateDto personUpdateDto) async test('test updatePerson', () async { // TODO diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 8c700d669..88a5e7427 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -2693,6 +2693,61 @@ ] } }, + "/person/{id}/merge": { + "post": { + "operationId": "mergePerson", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MergePersonDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BulkIdResponseDto" + } + } + } + } + } + }, + "tags": [ + "Person" + ], + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ] + } + }, "/person/{id}/thumbnail": { "get": { "operationId": "getPersonThumbnail", @@ -4963,6 +5018,30 @@ "deviceOS" ] }, + "BulkIdResponseDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "success": { + "type": "boolean" + }, + "error": { + "type": "string", + "enum": [ + "duplicate", + "no_permission", + "not_found", + "unknown" + ] + } + }, + "required": [ + "id", + "success" + ] + }, "ChangePasswordDto": { "type": "object", "properties": { @@ -5756,6 +5835,21 @@ "assets" ] }, + "MergePersonDto": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "required": [ + "ids" + ] + }, "OAuthCallbackDto": { "type": "object", "properties": { diff --git a/server/src/domain/asset/response-dto/asset-ids-response.dto.ts b/server/src/domain/asset/response-dto/asset-ids-response.dto.ts index 928bed24d..81672564a 100644 --- a/server/src/domain/asset/response-dto/asset-ids-response.dto.ts +++ b/server/src/domain/asset/response-dto/asset-ids-response.dto.ts @@ -1,11 +1,26 @@ +/** @deprecated Use `BulkIdResponseDto` instead */ export enum AssetIdErrorReason { DUPLICATE = 'duplicate', NO_PERMISSION = 'no_permission', NOT_FOUND = 'not_found', } +/** @deprecated Use `BulkIdResponseDto` instead */ export class AssetIdsResponseDto { assetId!: string; success!: boolean; error?: AssetIdErrorReason; } + +export enum BulkIdErrorReason { + DUPLICATE = 'duplicate', + NO_PERMISSION = 'no_permission', + NOT_FOUND = 'not_found', + UNKNOWN = 'unknown', +} + +export class BulkIdResponseDto { + id!: string; + success!: boolean; + error?: BulkIdErrorReason; +} diff --git a/server/src/domain/person/person.dto.ts b/server/src/domain/person/person.dto.ts index 1790697e4..b8efa65c9 100644 --- a/server/src/domain/person/person.dto.ts +++ b/server/src/domain/person/person.dto.ts @@ -1,5 +1,6 @@ import { AssetFaceEntity, PersonEntity } from '@app/infra/entities'; import { IsOptional, IsString } from 'class-validator'; +import { ValidateUUID } from '../domain.util'; export class PersonUpdateDto { /** @@ -17,6 +18,11 @@ export class PersonUpdateDto { featureFaceAssetId?: string; } +export class MergePersonDto { + @ValidateUUID({ each: true }) + ids!: string[]; +} + export class PersonResponseDto { id!: string; name!: string; diff --git a/server/src/domain/person/person.repository.ts b/server/src/domain/person/person.repository.ts index 0f05e3d98..3c8432be1 100644 --- a/server/src/domain/person/person.repository.ts +++ b/server/src/domain/person/person.repository.ts @@ -6,11 +6,19 @@ export interface PersonSearchOptions { minimumFaceCount: number; } +export interface UpdateFacesData { + oldPersonId: string; + newPersonId: string; +} + export interface IPersonRepository { getAll(userId: string, options: PersonSearchOptions): Promise; getAllWithoutFaces(): Promise; getById(userId: string, personId: string): Promise; - getAssets(userId: string, id: string): Promise; + + getAssets(userId: string, personId: string): Promise; + prepareReassignFaces(data: UpdateFacesData): Promise; + reassignFaces(data: UpdateFacesData): Promise; create(entity: Partial): Promise; update(entity: Partial): Promise; diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index d598f1293..c7eb08bfd 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -8,7 +8,8 @@ import { newStorageRepositoryMock, personStub, } from '@test'; -import { IJobRepository, JobName } from '..'; +import { BulkIdErrorReason } from '../asset'; +import { IJobRepository, JobName } from '../job'; import { IStorageRepository } from '../storage'; import { PersonResponseDto } from './person.dto'; import { IPersonRepository } from './person.repository'; @@ -154,4 +155,85 @@ describe(PersonService.name, () => { }); }); }); + + describe('mergePerson', () => { + it('should merge two people', async () => { + personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); + personMock.getById.mockResolvedValueOnce(personStub.mergePerson); + personMock.prepareReassignFaces.mockResolvedValue([]); + personMock.delete.mockResolvedValue(personStub.mergePerson); + + await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ + { id: 'person-2', success: true }, + ]); + + expect(personMock.prepareReassignFaces).toHaveBeenCalledWith({ + newPersonId: personStub.primaryPerson.id, + oldPersonId: personStub.mergePerson.id, + }); + + expect(personMock.reassignFaces).toHaveBeenCalledWith({ + newPersonId: personStub.primaryPerson.id, + oldPersonId: personStub.mergePerson.id, + }); + + expect(personMock.delete).toHaveBeenCalledWith(personStub.mergePerson); + }); + + it('should delete conflicting faces before merging', async () => { + personMock.getById.mockResolvedValue(personStub.primaryPerson); + personMock.getById.mockResolvedValue(personStub.mergePerson); + personMock.prepareReassignFaces.mockResolvedValue([assetEntityStub.image.id]); + + await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ + { id: 'person-2', success: true }, + ]); + + expect(personMock.prepareReassignFaces).toHaveBeenCalledWith({ + newPersonId: personStub.primaryPerson.id, + oldPersonId: personStub.mergePerson.id, + }); + + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.SEARCH_REMOVE_FACE, + data: { assetId: assetEntityStub.image.id, personId: personStub.mergePerson.id }, + }); + }); + + it('should throw an error when the primary person is not found', async () => { + personMock.getById.mockResolvedValue(null); + + await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf( + BadRequestException, + ); + + expect(personMock.delete).not.toHaveBeenCalled(); + }); + + it('should handle invalid merge ids', async () => { + personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); + personMock.getById.mockResolvedValueOnce(null); + + await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ + { id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND }, + ]); + + expect(personMock.prepareReassignFaces).not.toHaveBeenCalled(); + expect(personMock.reassignFaces).not.toHaveBeenCalled(); + expect(personMock.delete).not.toHaveBeenCalled(); + }); + + it('should handle an error reassigning faces', async () => { + personMock.getById.mockResolvedValue(personStub.primaryPerson); + personMock.getById.mockResolvedValue(personStub.mergePerson); + personMock.prepareReassignFaces.mockResolvedValue([assetEntityStub.image.id]); + personMock.reassignFaces.mockRejectedValue(new Error('update failed')); + + await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ + { id: 'person-2', success: false, error: BulkIdErrorReason.UNKNOWN }, + ]); + + expect(personMock.delete).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index ce009f143..87046c50f 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -1,12 +1,11 @@ -import { PersonEntity } from '@app/infra/entities'; import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { AssetResponseDto, mapAsset } from '../asset'; +import { AssetResponseDto, BulkIdErrorReason, BulkIdResponseDto, mapAsset } from '../asset'; import { AuthUserDto } from '../auth'; import { mimeTypes } from '../domain.constant'; import { IJobRepository, JobName } from '../job'; import { ImmichReadStream, IStorageRepository } from '../storage'; -import { mapPerson, PersonResponseDto, PersonUpdateDto } from './person.dto'; -import { IPersonRepository } from './person.repository'; +import { mapPerson, MergePersonDto, PersonResponseDto, PersonUpdateDto } from './person.dto'; +import { IPersonRepository, UpdateFacesData } from './person.repository'; @Injectable() export class PersonService { @@ -30,17 +29,12 @@ export class PersonService { ); } - async getById(authUser: AuthUserDto, personId: string): Promise { - const person = await this.repository.getById(authUser.id, personId); - if (!person) { - throw new BadRequestException(); - } - - return mapPerson(person); + getById(authUser: AuthUserDto, id: string): Promise { + return this.findOrFail(authUser, id).then(mapPerson); } - async getThumbnail(authUser: AuthUserDto, personId: string): Promise { - const person = await this.repository.getById(authUser.id, personId); + async getThumbnail(authUser: AuthUserDto, id: string): Promise { + const person = await this.repository.getById(authUser.id, id); if (!person || !person.thumbnailPath) { throw new NotFoundException(); } @@ -48,62 +42,48 @@ export class PersonService { return this.storageRepository.createReadStream(person.thumbnailPath, mimeTypes.lookup(person.thumbnailPath)); } - async getAssets(authUser: AuthUserDto, personId: string): Promise { - const assets = await this.repository.getAssets(authUser.id, personId); + async getAssets(authUser: AuthUserDto, id: string): Promise { + const assets = await this.repository.getAssets(authUser.id, id); return assets.map(mapAsset); } - async update(authUser: AuthUserDto, personId: string, dto: PersonUpdateDto): Promise { - let person = await this.repository.getById(authUser.id, personId); - if (!person) { - throw new BadRequestException(); - } + async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise { + let person = await this.findOrFail(authUser, id); if (dto.name) { - person = await this.updateName(authUser, personId, dto.name); + person = await this.repository.update({ id, name: dto.name }); + const assets = await this.repository.getAssets(authUser.id, id); + const ids = assets.map((asset) => asset.id); + await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } }); } if (dto.featureFaceAssetId) { - await this.updateFaceThumbnail(personId, dto.featureFaceAssetId); + const assetId = dto.featureFaceAssetId; + const face = await this.repository.getFaceById({ personId: id, assetId }); + if (!face) { + throw new BadRequestException('Invalid assetId for feature face'); + } + + await this.jobRepository.queue({ + name: JobName.GENERATE_FACE_THUMBNAIL, + data: { + personId: id, + assetId, + boundingBox: { + x1: face.boundingBoxX1, + x2: face.boundingBoxX2, + y1: face.boundingBoxY1, + y2: face.boundingBoxY2, + }, + imageHeight: face.imageHeight, + imageWidth: face.imageWidth, + }, + }); } return mapPerson(person); } - private async updateName(authUser: AuthUserDto, personId: string, name: string): Promise { - const person = await this.repository.update({ id: personId, name }); - - const relatedAsset = await this.getAssets(authUser, personId); - const assetIds = relatedAsset.map((asset) => asset.id); - await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: assetIds } }); - - return person; - } - - private async updateFaceThumbnail(personId: string, assetId: string): Promise { - const face = await this.repository.getFaceById({ assetId, personId }); - - if (!face) { - throw new BadRequestException(); - } - - return await this.jobRepository.queue({ - name: JobName.GENERATE_FACE_THUMBNAIL, - data: { - assetId: assetId, - personId, - boundingBox: { - x1: face.boundingBoxX1, - x2: face.boundingBoxX2, - y1: face.boundingBoxY1, - y2: face.boundingBoxY2, - }, - imageHeight: face.imageHeight, - imageWidth: face.imageWidth, - }, - }); - } - async handlePersonCleanup() { const people = await this.repository.getAllWithoutFaces(); for (const person of people) { @@ -118,4 +98,49 @@ export class PersonService { return true; } + + async mergePerson(authUser: AuthUserDto, id: string, dto: MergePersonDto): Promise { + const mergeIds = dto.ids; + const primaryPerson = await this.findOrFail(authUser, id); + const primaryName = primaryPerson.name || primaryPerson.id; + + const results: BulkIdResponseDto[] = []; + + for (const mergeId of mergeIds) { + try { + const mergePerson = await this.repository.getById(authUser.id, mergeId); + if (!mergePerson) { + results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NOT_FOUND }); + continue; + } + + const mergeName = mergePerson.name || mergePerson.id; + const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id }; + this.logger.log(`Merging ${mergeName} into ${primaryName}`); + + const assetIds = await this.repository.prepareReassignFaces(mergeData); + for (const assetId of assetIds) { + await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_FACE, data: { assetId, personId: mergeId } }); + } + await this.repository.reassignFaces(mergeData); + await this.repository.delete(mergePerson); + + this.logger.log(`Merged ${mergeName} into ${primaryName}`); + results.push({ id: mergeId, success: true }); + } catch (error: Error | any) { + this.logger.error(`Unable to merge ${mergeId} into ${id}: ${error}`, error?.stack); + results.push({ id: mergeId, success: false, error: BulkIdErrorReason.UNKNOWN }); + } + } + + return results; + } + + private async findOrFail(authUser: AuthUserDto, id: string) { + const person = await this.repository.getById(authUser.id, id); + if (!person) { + throw new BadRequestException('Person not found'); + } + return person; + } } diff --git a/server/src/immich/controllers/person.controller.ts b/server/src/immich/controllers/person.controller.ts index 6eb58844f..106961aa8 100644 --- a/server/src/immich/controllers/person.controller.ts +++ b/server/src/immich/controllers/person.controller.ts @@ -1,12 +1,14 @@ import { AssetResponseDto, AuthUserDto, + BulkIdResponseDto, ImmichReadStream, + MergePersonDto, PersonResponseDto, PersonService, PersonUpdateDto, } from '@app/domain'; -import { Body, Controller, Get, Param, Put, StreamableFile } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Put, StreamableFile } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { Authenticated, AuthUser } from '../app.guard'; import { UseValidation } from '../app.utils'; @@ -56,4 +58,13 @@ export class PersonController { getPersonAssets(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise { return this.service.getAssets(authUser, id); } + + @Post(':id/merge') + mergePerson( + @AuthUser() authUser: AuthUserDto, + @Param() { id }: UUIDParamDto, + @Body() dto: MergePersonDto, + ): Promise { + return this.service.mergePerson(authUser, id, dto); + } } diff --git a/server/src/infra/repositories/person.repository.ts b/server/src/infra/repositories/person.repository.ts index c4a04acab..db4ccff06 100644 --- a/server/src/infra/repositories/person.repository.ts +++ b/server/src/infra/repositories/person.repository.ts @@ -1,6 +1,6 @@ -import { AssetFaceId, IPersonRepository, PersonSearchOptions } from '@app/domain'; +import { AssetFaceId, IPersonRepository, PersonSearchOptions, UpdateFacesData } from '@app/domain'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { AssetEntity, AssetFaceEntity, PersonEntity } from '../entities'; export class PersonRepository implements IPersonRepository { @@ -10,6 +10,36 @@ export class PersonRepository implements IPersonRepository { @InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository, ) {} + /** + * Before reassigning faces, delete potential key violations + */ + async prepareReassignFaces({ oldPersonId, newPersonId }: UpdateFacesData): Promise { + const results = await this.assetFaceRepository + .createQueryBuilder('face') + .select('face."assetId"') + .where(`face."personId" IN (:...ids)`, { ids: [oldPersonId, newPersonId] }) + .groupBy('face."assetId"') + .having('COUNT(face."personId") > 1') + .getRawMany(); + + const assetIds = results.map(({ assetId }) => assetId); + + await this.assetFaceRepository.delete({ personId: oldPersonId, assetId: In(assetIds) }); + + return assetIds; + } + + async reassignFaces({ oldPersonId, newPersonId }: UpdateFacesData): Promise { + const result = await this.assetFaceRepository + .createQueryBuilder() + .update() + .set({ personId: newPersonId }) + .where({ personId: oldPersonId }) + .execute(); + + return result.affected ?? 0; + } + delete(entity: PersonEntity): Promise { return this.personRepository.remove(entity); } diff --git a/server/test/fixtures.ts b/server/test/fixtures.ts index 1c6f87e02..145229407 100644 --- a/server/test/fixtures.ts +++ b/server/test/fixtures.ts @@ -327,6 +327,39 @@ export const assetEntityStub = { fileSizeInByte: 5_000, } as ExifEntity, }), + image1: Object.freeze({ + id: 'asset-id-1', + deviceAssetId: 'device-asset-id', + fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), + fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), + owner: userEntityStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/original/path.ext', + resizePath: '/uploads/user-id/thumbs/path.ext', + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.IMAGE, + webpPath: '/uploads/user-id/webp/path.ext', + thumbhash: Buffer.from('blablabla', 'base64'), + encodedVideoPath: null, + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + isFavorite: true, + isArchived: false, + isReadOnly: false, + duration: null, + isVisible: true, + livePhotoVideo: null, + livePhotoVideoId: null, + tags: [], + sharedLinks: [], + originalFileName: 'asset-id.ext', + faces: [], + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5_000, + } as ExifEntity, + }), video: Object.freeze({ id: 'asset-id', originalFileName: 'asset-id.ext', @@ -1158,6 +1191,26 @@ export const personStub = { thumbnailPath: '/new/path/to/thumbnail.jpg', faces: [], }), + primaryPerson: Object.freeze({ + id: 'person-1', + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + ownerId: userEntityStub.admin.id, + owner: userEntityStub.admin, + name: 'Person 1', + thumbnailPath: '/path/to/thumbnail', + faces: [], + }), + mergePerson: Object.freeze({ + id: 'person-2', + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + ownerId: userEntityStub.admin.id, + owner: userEntityStub.admin, + name: 'Person 2', + thumbnailPath: '/path/to/thumbnail', + faces: [], + }), }; export const partnerStub = { @@ -1193,6 +1246,45 @@ export const faceStub = { imageHeight: 1024, imageWidth: 1024, }), + primaryFace1: Object.freeze({ + assetId: assetEntityStub.image.id, + asset: assetEntityStub.image, + personId: personStub.primaryPerson.id, + person: personStub.primaryPerson, + embedding: [1, 2, 3, 4], + boundingBoxX1: 0, + boundingBoxY1: 0, + boundingBoxX2: 1, + boundingBoxY2: 1, + imageHeight: 1024, + imageWidth: 1024, + }), + mergeFace1: Object.freeze({ + assetId: assetEntityStub.image.id, + asset: assetEntityStub.image, + personId: personStub.mergePerson.id, + person: personStub.mergePerson, + embedding: [1, 2, 3, 4], + boundingBoxX1: 0, + boundingBoxY1: 0, + boundingBoxX2: 1, + boundingBoxY2: 1, + imageHeight: 1024, + imageWidth: 1024, + }), + mergeFace2: Object.freeze({ + assetId: assetEntityStub.image1.id, + asset: assetEntityStub.image1, + personId: personStub.mergePerson.id, + person: personStub.mergePerson, + embedding: [1, 2, 3, 4], + boundingBoxX1: 0, + boundingBoxY1: 0, + boundingBoxX2: 1, + boundingBoxY2: 1, + imageHeight: 1024, + imageWidth: 1024, + }), }; export const tagStub = { diff --git a/server/test/repositories/person.repository.mock.ts b/server/test/repositories/person.repository.mock.ts index 68cd833ed..99fa6de3e 100644 --- a/server/test/repositories/person.repository.mock.ts +++ b/server/test/repositories/person.repository.mock.ts @@ -13,5 +13,7 @@ export const newPersonRepositoryMock = (): jest.Mocked => { delete: jest.fn(), getFaceById: jest.fn(), + prepareReassignFaces: jest.fn(), + reassignFaces: jest.fn(), }; }; diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index da6a8d174..3ca2b95a6 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -798,6 +798,41 @@ export interface AuthDeviceResponseDto { */ 'deviceOS': string; } +/** + * + * @export + * @interface BulkIdResponseDto + */ +export interface BulkIdResponseDto { + /** + * + * @type {string} + * @memberof BulkIdResponseDto + */ + 'id': string; + /** + * + * @type {boolean} + * @memberof BulkIdResponseDto + */ + 'success': boolean; + /** + * + * @type {string} + * @memberof BulkIdResponseDto + */ + 'error'?: BulkIdResponseDtoErrorEnum; +} + +export const BulkIdResponseDtoErrorEnum = { + Duplicate: 'duplicate', + NoPermission: 'no_permission', + NotFound: 'not_found', + Unknown: 'unknown' +} as const; + +export type BulkIdResponseDtoErrorEnum = typeof BulkIdResponseDtoErrorEnum[keyof typeof BulkIdResponseDtoErrorEnum]; + /** * * @export @@ -1686,6 +1721,19 @@ export interface MemoryLaneResponseDto { */ 'assets': Array; } +/** + * + * @export + * @interface MergePersonDto + */ +export interface MergePersonDto { + /** + * + * @type {Array} + * @memberof MergePersonDto + */ + 'ids': Array; +} /** * * @export @@ -8852,6 +8900,54 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio options: localVarRequestOptions, }; }, + /** + * + * @param {string} id + * @param {MergePersonDto} mergePersonDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + mergePerson: async (id: string, mergePersonDto: MergePersonDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('mergePerson', 'id', id) + // verify required parameter 'mergePersonDto' is not null or undefined + assertParamExists('mergePerson', 'mergePersonDto', mergePersonDto) + const localVarPath = `/person/{id}/merge` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(mergePersonDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {string} id @@ -8949,6 +9045,17 @@ export const PersonApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonThumbnail(id, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} id + * @param {MergePersonDto} mergePersonDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async mergePerson(id: string, mergePersonDto: MergePersonDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.mergePerson(id, mergePersonDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {string} id @@ -9005,6 +9112,16 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat getPersonThumbnail(id: string, options?: any): AxiosPromise { return localVarFp.getPersonThumbnail(id, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {string} id + * @param {MergePersonDto} mergePersonDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + mergePerson(id: string, mergePersonDto: MergePersonDto, options?: any): AxiosPromise> { + return localVarFp.mergePerson(id, mergePersonDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {string} id @@ -9060,6 +9177,27 @@ export interface PersonApiGetPersonThumbnailRequest { readonly id: string } +/** + * Request parameters for mergePerson operation in PersonApi. + * @export + * @interface PersonApiMergePersonRequest + */ +export interface PersonApiMergePersonRequest { + /** + * + * @type {string} + * @memberof PersonApiMergePerson + */ + readonly id: string + + /** + * + * @type {MergePersonDto} + * @memberof PersonApiMergePerson + */ + readonly mergePersonDto: MergePersonDto +} + /** * Request parameters for updatePerson operation in PersonApi. * @export @@ -9131,6 +9269,17 @@ export class PersonApi extends BaseAPI { return PersonApiFp(this.configuration).getPersonThumbnail(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {PersonApiMergePersonRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof PersonApi + */ + public mergePerson(requestParameters: PersonApiMergePersonRequest, options?: AxiosRequestConfig) { + return PersonApiFp(this.configuration).mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {PersonApiUpdatePersonRequest} requestParameters Request parameters. diff --git a/web/src/lib/components/faces-page/face-thumbnail.svelte b/web/src/lib/components/faces-page/face-thumbnail.svelte new file mode 100644 index 000000000..61ec188a3 --- /dev/null +++ b/web/src/lib/components/faces-page/face-thumbnail.svelte @@ -0,0 +1,66 @@ + + + diff --git a/web/src/lib/components/faces-page/merge-face-selector.svelte b/web/src/lib/components/faces-page/merge-face-selector.svelte new file mode 100644 index 000000000..71c3f6bf8 --- /dev/null +++ b/web/src/lib/components/faces-page/merge-face-selector.svelte @@ -0,0 +1,145 @@ + + + + +
+ + + {#if hasSelection} + Selected {selectedPeople.length} + {:else} + Merge faces + {/if} +
+ + + + + +
+
+
+

Choose matching faces to merge

+ +
+ {#each selectedPeople as person (person.id)} +
+ onSelect(person)} /> +
+ {/each} + + {#if hasSelection} + + {/if} + +
+
+
+
+ {#each unselectedPeople as person (person.id)} + onSelect(person)} circle border selectable /> + {/each} +
+
+
+ + {#if isShowConfirmation} + (isShowConfirmation = false)} + > + +

Are you sure you want merge these faces?
This action is irreversible.

+
+
+ {/if} +
+
diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte index 68f59d887..09b2f0710 100644 --- a/web/src/routes/(user)/explore/+page.svelte +++ b/web/src/routes/(user)/explore/+page.svelte @@ -38,7 +38,7 @@ {#if data.people.length > MAX_ITEMS} View All {/if} diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index b0f60a6d2..745197adf 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -15,7 +15,7 @@ {#each data.people as person (person.id)}
-
+
= new Set(); $: isMultiSelectionMode = selectedAssets.size > 0; $: isAllArchive = Array.from(selectedAssets).every((asset) => asset.isArchived); $: isAllFavorite = Array.from(selectedAssets).every((asset) => asset.isFavorite); + $: showAssets = !showMergeFacePanel && !showFaceThumbnailSelection; + afterNavigate(({ from }) => { // Prevent setting previousRoute to the current page. if (from && from.route.id !== $page.route.id) { @@ -64,7 +67,7 @@ }; const handleSelectFeaturePhoto = async (event: CustomEvent) => { - isSelectingFace = false; + showFaceThumbnailSelection = false; const { selectedAsset }: { selectedAsset: AssetResponseDto | undefined } = event.detail; @@ -102,7 +105,8 @@ goto(previousRoute)}> - (isSelectingFace = true)} /> + (showFaceThumbnailSelection = true)} /> + (showMergeFacePanel = true)} /> @@ -117,7 +121,7 @@ on:cancel={() => (isEditingName = false)} /> {:else} -
diff --git a/web/src/lib/models/upload-asset.ts b/web/src/lib/models/upload-asset.ts index c4de5458b..1b8bb0a25 100644 --- a/web/src/lib/models/upload-asset.ts +++ b/web/src/lib/models/upload-asset.ts @@ -2,5 +2,4 @@ export type UploadAsset = { id: string; file: File; progress: number; - fileExtension: string; }; diff --git a/web/src/lib/utils/asset-utils.spec.ts b/web/src/lib/utils/asset-utils.spec.ts index d9abbdcdc..2046e6c4e 100644 --- a/web/src/lib/utils/asset-utils.spec.ts +++ b/web/src/lib/utils/asset-utils.spec.ts @@ -1,6 +1,6 @@ import type { AssetResponseDto } from '@api'; import { describe, expect, it } from '@jest/globals'; -import { getAssetFilename, getFileMimeType, getFilenameExtension } from './asset-utils'; +import { getAssetFilename, getFilenameExtension } from './asset-utils'; describe('get file extension from filename', () => { it('returns the extension without including the dot', () => { @@ -57,88 +57,3 @@ describe('get asset filename', () => { }); }); }); - -describe('get file mime type', () => { - for (const { mimetype, extension } of [ - { mimetype: 'image/avif', extension: 'avif' }, - { mimetype: 'image/gif', extension: 'gif' }, - { mimetype: 'image/heic', extension: 'heic' }, - { mimetype: 'image/heif', extension: 'heif' }, - { mimetype: 'image/jpeg', extension: 'jpeg' }, - { mimetype: 'image/jpeg', extension: 'jpg' }, - { mimetype: 'image/jxl', extension: 'jxl' }, - { mimetype: 'image/png', extension: 'png' }, - { mimetype: 'image/tiff', extension: 'tiff' }, - { mimetype: 'image/webp', extension: 'webp' }, - { mimetype: 'image/x-adobe-dng', extension: 'dng' }, - { mimetype: 'image/x-arriflex-ari', extension: 'ari' }, - { mimetype: 'image/x-canon-cr2', extension: 'cr2' }, - { mimetype: 'image/x-canon-cr3', extension: 'cr3' }, - { mimetype: 'image/x-canon-crw', extension: 'crw' }, - { mimetype: 'image/x-epson-erf', extension: 'erf' }, - { mimetype: 'image/x-fuji-raf', extension: 'raf' }, - { mimetype: 'image/x-hasselblad-3fr', extension: '3fr' }, - { mimetype: 'image/x-hasselblad-fff', extension: 'fff' }, - { mimetype: 'image/x-kodak-dcr', extension: 'dcr' }, - { mimetype: 'image/x-kodak-k25', extension: 'k25' }, - { mimetype: 'image/x-kodak-kdc', extension: 'kdc' }, - { mimetype: 'image/x-leica-rwl', extension: 'rwl' }, - { mimetype: 'image/x-minolta-mrw', extension: 'mrw' }, - { mimetype: 'image/x-nikon-nef', extension: 'nef' }, - { mimetype: 'image/x-olympus-orf', extension: 'orf' }, - { mimetype: 'image/x-olympus-ori', extension: 'ori' }, - { mimetype: 'image/x-panasonic-raw', extension: 'raw' }, - { mimetype: 'image/x-pentax-pef', extension: 'pef' }, - { mimetype: 'image/x-phantom-cin', extension: 'cin' }, - { mimetype: 'image/x-phaseone-cap', extension: 'cap' }, - { mimetype: 'image/x-phaseone-iiq', extension: 'iiq' }, - { mimetype: 'image/x-samsung-srw', extension: 'srw' }, - { mimetype: 'image/x-sigma-x3f', extension: 'x3f' }, - { mimetype: 'image/x-sony-arw', extension: 'arw' }, - { mimetype: 'image/x-sony-sr2', extension: 'sr2' }, - { mimetype: 'image/x-sony-srf', extension: 'srf' }, - { mimetype: 'video/3gpp', extension: '3gp' }, - { mimetype: 'video/avi', extension: 'avi' }, - { mimetype: 'video/mp2t', extension: 'm2ts' }, - { mimetype: 'video/mp2t', extension: 'mts' }, - { mimetype: 'video/mp4', extension: 'mp4' }, - { mimetype: 'video/mpeg', extension: 'mpg' }, - { mimetype: 'video/quicktime', extension: 'mov' }, - { mimetype: 'video/webm', extension: 'webm' }, - { mimetype: 'video/x-flv', extension: 'flv' }, - { mimetype: 'video/x-matroska', extension: 'mkv' }, - { mimetype: 'video/x-ms-wmv', extension: 'wmv' }, - ]) { - it(`returns the mime type for ${extension}`, () => { - expect(getFileMimeType({ name: `filename.${extension}` } as File)).toEqual(mimetype); - }); - } - - it('returns the mime type from the file', () => { - [ - { - file: { - name: 'filename.jpg', - type: 'image/jpeg', - }, - result: 'image/jpeg', - }, - { - file: { - name: 'filename.txt', - type: 'text/plain', - }, - result: 'text/plain', - }, - { - file: { - name: 'filename.txt', - type: '', - }, - result: '', - }, - ].forEach(({ file, result }) => { - expect(getFileMimeType(file as File)).toEqual(result); - }); - }); -}); diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 13c7c26b0..ff35bdb50 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -136,66 +136,6 @@ export function getAssetFilename(asset: AssetResponseDto): string { return `${asset.originalFileName}.${fileExtension}`; } -/** - * Returns the MIME type of the file and an empty string when not found. - */ -export function getFileMimeType(file: File): string { - const mimeTypes: Record = { - '3fr': 'image/x-hasselblad-3fr', - '3gp': 'video/3gpp', - ari: 'image/x-arriflex-ari', - arw: 'image/x-sony-arw', - avi: 'video/avi', - avif: 'image/avif', - cap: 'image/x-phaseone-cap', - cin: 'image/x-phantom-cin', - cr2: 'image/x-canon-cr2', - cr3: 'image/x-canon-cr3', - crw: 'image/x-canon-crw', - dcr: 'image/x-kodak-dcr', - dng: 'image/x-adobe-dng', - erf: 'image/x-epson-erf', - fff: 'image/x-hasselblad-fff', - flv: 'video/x-flv', - gif: 'image/gif', - heic: 'image/heic', - heif: 'image/heif', - iiq: 'image/x-phaseone-iiq', - insp: 'image/jpeg', - insv: 'video/mp4', - jpeg: 'image/jpeg', - jpg: 'image/jpeg', - jxl: 'image/jxl', - k25: 'image/x-kodak-k25', - kdc: 'image/x-kodak-kdc', - m2ts: 'video/mp2t', - mkv: 'video/x-matroska', - mov: 'video/quicktime', - mp4: 'video/mp4', - mpg: 'video/mpeg', - mrw: 'image/x-minolta-mrw', - mts: 'video/mp2t', - nef: 'image/x-nikon-nef', - orf: 'image/x-olympus-orf', - ori: 'image/x-olympus-ori', - pef: 'image/x-pentax-pef', - png: 'image/png', - raf: 'image/x-fuji-raf', - raw: 'image/x-panasonic-raw', - rwl: 'image/x-leica-rwl', - sr2: 'image/x-sony-sr2', - srf: 'image/x-sony-srf', - srw: 'image/x-samsung-srw', - tiff: 'image/tiff', - webm: 'video/webm', - webp: 'image/webp', - wmv: 'video/x-ms-wmv', - x3f: 'image/x-sigma-x3f', - }; - // Return the MIME type determined by the browser or the MIME type based on the file extension. - return file.type || (mimeTypes[getFilenameExtension(file.name)] ?? ''); -} - function isRotated90CW(orientation: number) { return orientation == 6 || orientation == 90; } diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index 2e5a411a2..badfd690c 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/web/src/lib/utils/file-uploader.ts @@ -1,11 +1,59 @@ import { uploadAssetsStore } from '$lib/stores/upload'; -import { addAssetsToAlbum, getFileMimeType, getFilenameExtension } from '$lib/utils/asset-utils'; +import { addAssetsToAlbum, getFilenameExtension } from '$lib/utils/asset-utils'; import type { AssetFileUploadResponseDto } from '@api'; import axios from 'axios'; import { combineLatestAll, filter, firstValueFrom, from, mergeMap, of } from 'rxjs'; -import type { UploadAsset } from '../models/upload-asset'; import { notificationController, NotificationType } from './../components/shared-components/notification/notification'; +const extensions = [ + '.3fr', + '.3gp', + '.ari', + '.arw', + '.avi', + '.avif', + '.cap', + '.cin', + '.cr2', + '.cr3', + '.crw', + '.dcr', + '.dng', + '.erf', + '.fff', + '.flv', + '.gif', + '.heic', + '.heif', + '.iiq', + '.jpeg', + '.jpg', + '.k25', + '.kdc', + '.mkv', + '.mov', + '.mp2t', + '.mp4', + '.mpeg', + '.mrw', + '.nef', + '.orf', + '.ori', + '.pef', + '.png', + '.raf', + '.raw', + '.rwl', + '.sr2', + '.srf', + '.srw', + '.tiff', + '.webm', + '.webp', + '.wmv', + '.x3f', +]; + export const openFileUploadDialog = async ( albumId: string | undefined = undefined, sharedKey: string | undefined = undefined, @@ -16,52 +64,7 @@ export const openFileUploadDialog = async ( fileSelector.type = 'file'; fileSelector.multiple = true; - - // When adding a content type that is unsupported by browsers, make sure - // to also add it to getFileMimeType() otherwise the upload will fail. - fileSelector.accept = [ - 'image/*', - 'video/*', - '.3fr', - '.3gp', - '.ari', - '.arw', - '.avif', - '.cap', - '.cin', - '.cr2', - '.cr3', - '.crw', - '.dcr', - '.dng', - '.erf', - '.fff', - '.heic', - '.heif', - '.iiq', - '.insp', - '.insv', - '.jxl', - '.k25', - '.kdc', - '.m2ts', - '.mov', - '.mrw', - '.mts', - '.nef', - '.orf', - '.ori', - '.pef', - '.raf', - '.raf', - '.raw', - '.rwl', - '.sr2', - '.srf', - '.srw', - '.x3f', - ].join(','); - + fileSelector.accept = extensions.join(','); fileSelector.onchange = async (e: Event) => { const target = e.target as HTMLInputElement; if (!target.files) { @@ -87,10 +90,7 @@ export const fileUploadHandler = async ( ) => { return firstValueFrom( from(files).pipe( - filter((file) => { - const assetType = getFileMimeType(file).split('/')[0]; - return assetType === 'video' || assetType === 'image'; - }), + filter((file) => extensions.includes('.' + getFilenameExtension(file.name))), mergeMap(async (file) => of(await fileUploader(file, albumId, sharedKey)), 2), combineLatestAll(), ), @@ -103,51 +103,24 @@ async function fileUploader( albumId: string | undefined = undefined, sharedKey: string | undefined = undefined, ): Promise { - const mimeType = getFileMimeType(asset); - const assetType = mimeType.split('/')[0].toUpperCase(); - const fileExtension = getFilenameExtension(asset.name); const formData = new FormData(); const fileCreatedAt = new Date(asset.lastModified).toISOString(); const deviceAssetId = 'web' + '-' + asset.name + '-' + asset.lastModified; try { - // Create and add pseudo-unique ID of asset on the device formData.append('deviceAssetId', deviceAssetId); - - // Get device id - for web -> use WEB formData.append('deviceId', 'WEB'); - - // Get asset type - formData.append('assetType', assetType); - - // Get Asset Created Date formData.append('fileCreatedAt', fileCreatedAt); - - // Get Asset Modified At formData.append('fileModifiedAt', new Date(asset.lastModified).toISOString()); - - // Set Asset is Favorite to false formData.append('isFavorite', 'false'); - - // Get asset duration formData.append('duration', '0:00:00.000000'); + formData.append('assetData', new File([asset], asset.name)); - // Get asset file extension - formData.append('fileExtension', '.' + fileExtension); - - // Get asset binary data with a custom MIME type, because browsers will - // use application/octet-stream for unsupported MIME types, leading to - // failed uploads. - formData.append('assetData', new File([asset], asset.name, { type: mimeType })); - - const newUploadAsset: UploadAsset = { + uploadAssetsStore.addNewUploadAsset({ id: deviceAssetId, file: asset, progress: 0, - fileExtension: fileExtension, - }; - - uploadAssetsStore.addNewUploadAsset(newUploadAsset); + }); const response = await axios.post(`/api/asset/upload`, formData, { params: { From 863e98372628977d328852c61b429bdac803928f Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 13 Jul 2023 09:00:46 -0500 Subject: [PATCH 21/38] fix(web): download livephotos video part correctly (#3230) --- web/src/lib/utils/asset-utils.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index ff35bdb50..d420d9f57 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -88,14 +88,17 @@ export const downloadArchive = async ( }; export const downloadFile = async (asset: AssetResponseDto, key?: string) => { - const filenames = [`${asset.originalFileName}.${getFilenameExtension(asset.originalPath)}`]; + const assets = [{ filename: `${asset.originalFileName}.${getFilenameExtension(asset.originalPath)}`, id: asset.id }]; if (asset.livePhotoVideoId) { - filenames.push(`${asset.originalFileName}.mov`); + assets.push({ + filename: `${asset.originalFileName}.mov`, + id: asset.livePhotoVideoId, + }); } - for (const filename of filenames) { + for (const asset of assets) { try { - updateDownload(filename, 0); + updateDownload(asset.filename, 0); const { data } = await api.assetApi.downloadFile( { id: asset.id, key }, @@ -103,17 +106,17 @@ export const downloadFile = async (asset: AssetResponseDto, key?: string) => { responseType: 'blob', onDownloadProgress: (event: ProgressEvent) => { if (event.lengthComputable) { - updateDownload(filename, Math.floor((event.loaded / event.total) * 100)); + updateDownload(asset.filename, Math.floor((event.loaded / event.total) * 100)); } }, }, ); - downloadBlob(data, filename); + downloadBlob(data, asset.filename); } catch (e) { - handleError(e, `Error downloading ${filename}`); + handleError(e, `Error downloading ${asset.filename}`); } finally { - setTimeout(() => clearDownload(filename), 3_000); + setTimeout(() => clearDownload(asset.filename), 3_000); } } }; From f9739c9730f7a92e7fed9e51dd2256124b39007a Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Thu, 13 Jul 2023 17:42:06 +0200 Subject: [PATCH 22/38] feat(mobile): stop asset grid rebuilds (#3226) * feat(mobile): stop asset grid rebuilds * undo unnecessary changes --------- Co-authored-by: Fynn Petersen-Frey --- .../asset_viewer/views/gallery_viewer.dart | 6 +- .../home/ui/asset_grid/immich_asset_grid.dart | 114 +++++++----------- .../ui/asset_grid/immich_asset_grid_view.dart | 3 + .../home/ui/asset_grid/thumbnail_image.dart | 30 +---- .../lib/modules/memories/ui/memory_lane.dart | 2 +- mobile/lib/routing/router.gr.dart | 38 +++--- 6 files changed, 80 insertions(+), 113 deletions(-) diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 6df20fe0c..1e3e37d66 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -37,12 +37,14 @@ class GalleryViewerPage extends HookConsumerWidget { final Asset Function(int index) loadAsset; final int totalAssets; final int initialIndex; + final int heroOffset; GalleryViewerPage({ super.key, required this.initialIndex, required this.loadAsset, required this.totalAssets, + this.heroOffset = 0, }) : controller = PageController(initialPage: initialIndex); final PageController controller; @@ -589,7 +591,7 @@ class GalleryViewerPage extends HookConsumerWidget { }, imageProvider: provider, heroAttributes: PhotoViewHeroAttributes( - tag: asset.id, + tag: asset.id + heroOffset, ), filterQuality: FilterQuality.high, tightMode: true, @@ -606,7 +608,7 @@ class GalleryViewerPage extends HookConsumerWidget { onDragUpdate: (_, details, __) => handleSwipeUpDown(details), heroAttributes: PhotoViewHeroAttributes( - tag: asset.id, + tag: asset.id + heroOffset, ), filterQuality: FilterQuality.high, maxScale: 1.0, diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart index 21a33b51c..891bde100 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:auto_route/auto_route.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -52,84 +53,61 @@ class ImmichAssetGrid extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { var settings = ref.watch(appSettingsServiceProvider); - // Needs to suppress hero animations when navigating to this widget - final enableHeroAnimations = useState(false); - final transitionDuration = ModalRoute.of(context)?.transitionDuration; - final perRow = useState( assetsPerRow ?? settings.getSetting(AppSettingsEnum.tilesPerRow)!, ); final scaleFactor = useState(7.0 - perRow.value); final baseScaleFactor = useState(7.0 - perRow.value); - useEffect( - () { - // Wait for transition to complete, then re-enable - if (transitionDuration == null) { - // No route transition found, maybe we opened this up first - enableHeroAnimations.value = true; - } else { - // Unfortunately, using the transition animation itself didn't - // seem to work reliably. So instead, wait until the duration of the - // animation has elapsed to re-enable the hero animations - Future.delayed(transitionDuration).then((_) { - enableHeroAnimations.value = true; - }); - } - return null; - }, - [], - ); - - Future onWillPop() async { - enableHeroAnimations.value = false; - return true; + /// assets need different hero tags across tabs / modals + /// otherwise, hero animations are performed across tabs (looks buggy!) + int heroOffset() { + const int range = 1152921504606846976; // 2^60 + final tabScope = TabsRouterScope.of(context); + if (tabScope != null) { + final int tabIndex = tabScope.controller.activeIndex; + return tabIndex * range; + } + return range * 7; } Widget buildAssetGridView(RenderList renderList) { - return WillPopScope( - onWillPop: onWillPop, - child: HeroMode( - enabled: enableHeroAnimations.value, - child: RawGestureDetector( - gestures: { - CustomScaleGestureRecognizer: - GestureRecognizerFactoryWithHandlers< - CustomScaleGestureRecognizer>( - () => CustomScaleGestureRecognizer(), - (CustomScaleGestureRecognizer scale) { - scale.onStart = (details) { - baseScaleFactor.value = scaleFactor.value; - }; + return RawGestureDetector( + gestures: { + CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers< + CustomScaleGestureRecognizer>( + () => CustomScaleGestureRecognizer(), + (CustomScaleGestureRecognizer scale) { + scale.onStart = (details) { + baseScaleFactor.value = scaleFactor.value; + }; - scale.onUpdate = (details) { - scaleFactor.value = - max(min(5.0, baseScaleFactor.value * details.scale), 1.0); - if (7 - scaleFactor.value.toInt() != perRow.value) { - perRow.value = 7 - scaleFactor.value.toInt(); - } - }; - scale.onEnd = (details) {}; - }) - }, - child: ImmichAssetGridView( - onRefresh: onRefresh, - assetsPerRow: perRow.value, - listener: listener, - showStorageIndicator: showStorageIndicator ?? - settings.getSetting(AppSettingsEnum.storageIndicator), - renderList: renderList, - margin: margin, - selectionActive: selectionActive, - preselectedAssets: preselectedAssets, - canDeselect: canDeselect, - dynamicLayout: dynamicLayout ?? - settings.getSetting(AppSettingsEnum.dynamicLayout), - showMultiSelectIndicator: showMultiSelectIndicator, - visibleItemsListener: visibleItemsListener, - topWidget: topWidget, - ), - ), + scale.onUpdate = (details) { + scaleFactor.value = + max(min(5.0, baseScaleFactor.value * details.scale), 1.0); + if (7 - scaleFactor.value.toInt() != perRow.value) { + perRow.value = 7 - scaleFactor.value.toInt(); + } + }; + }) + }, + child: ImmichAssetGridView( + onRefresh: onRefresh, + assetsPerRow: perRow.value, + listener: listener, + showStorageIndicator: showStorageIndicator ?? + settings.getSetting(AppSettingsEnum.storageIndicator), + renderList: renderList, + margin: margin, + selectionActive: selectionActive, + preselectedAssets: preselectedAssets, + canDeselect: canDeselect, + dynamicLayout: dynamicLayout ?? + settings.getSetting(AppSettingsEnum.dynamicLayout), + showMultiSelectIndicator: showMultiSelectIndicator, + visibleItemsListener: visibleItemsListener, + topWidget: topWidget, + heroOffset: heroOffset(), ), ); } diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart index fb7c9ddc0..ec1676206 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart @@ -34,6 +34,7 @@ class ImmichAssetGridView extends StatefulWidget { final void Function(ItemPosition start, ItemPosition end)? visibleItemsListener; final Widget? topWidget; + final int heroOffset; const ImmichAssetGridView({ super.key, @@ -50,6 +51,7 @@ class ImmichAssetGridView extends StatefulWidget { this.showMultiSelectIndicator = true, this.visibleItemsListener, this.topWidget, + this.heroOffset = 0, }); @override @@ -122,6 +124,7 @@ class ImmichAssetGridViewState extends State { : null, useGrayBoxPlaceholder: true, showStorageIndicator: widget.showStorageIndicator, + heroOffset: widget.heroOffset, ); } diff --git a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart index 1373df7d3..39f1cf4ac 100644 --- a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart @@ -18,6 +18,7 @@ class ThumbnailImage extends HookConsumerWidget { final bool multiselectEnabled; final Function? onSelect; final Function? onDeselect; + final int heroOffset; const ThumbnailImage({ Key? key, @@ -31,6 +32,7 @@ class ThumbnailImage extends HookConsumerWidget { this.multiselectEnabled = false, this.onDeselect, this.onSelect, + this.heroOffset = 0, }) : super(key: key); @override @@ -63,6 +65,7 @@ class ThumbnailImage extends HookConsumerWidget { initialIndex: index, loadAsset: loadAsset, totalAssets: totalAssets, + heroOffset: heroOffset, ), ); } @@ -72,32 +75,7 @@ class ThumbnailImage extends HookConsumerWidget { HapticFeedback.heavyImpact(); }, child: Hero( - createRectTween: (begin, end) { - double? top; - // Uses the [BoxFit.contain] algorithm - if (asset.width != null && asset.height != null) { - final assetAR = asset.width! / asset.height!; - final w = MediaQuery.of(context).size.width; - final deviceAR = MediaQuery.of(context).size.aspectRatio; - if (deviceAR < assetAR) { - top = asset.height! * w / asset.width!; - } else { - top = 0; - } - // get the height offset - } - - return MaterialRectCenterArcTween( - begin: Rect.fromLTRB( - 0, - top ?? 0.0, - MediaQuery.of(context).size.width, - MediaQuery.of(context).size.height, - ), - end: end, - ); - }, - tag: asset.id, + tag: asset.id + heroOffset, child: Stack( children: [ Container( diff --git a/mobile/lib/modules/memories/ui/memory_lane.dart b/mobile/lib/modules/memories/ui/memory_lane.dart index f3405aaa2..902154607 100644 --- a/mobile/lib/modules/memories/ui/memory_lane.dart +++ b/mobile/lib/modules/memories/ui/memory_lane.dart @@ -31,7 +31,7 @@ class MemoryLane extends HookConsumerWidget { onTap: () { HapticFeedback.heavyImpact(); AutoRouter.of(context).push( - VerticalRouteView( + MemoryRoute( memories: memories, memoryIndex: index, ), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 3ab78b69d..c25242258 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -70,6 +70,7 @@ class _$AppRouter extends RootStackRouter { initialIndex: args.initialIndex, loadAsset: args.loadAsset, totalAssets: args.totalAssets, + heroOffset: args.heroOffset, ), ); }, @@ -290,8 +291,8 @@ class _$AppRouter extends RootStackRouter { child: const AllPeoplePage(), ); }, - VerticalRouteView.name: (routeData) { - final args = routeData.argsAs(); + MemoryRoute.name: (routeData) { + final args = routeData.argsAs(); return MaterialPageX( routeData: routeData, child: MemoryPage( @@ -506,7 +507,7 @@ class _$AppRouter extends RootStackRouter { ), RouteConfig( AlbumViewerRoute.name, - path: '/album-viewer-page', + path: '/', guards: [ authGuard, duplicateGuard, @@ -601,8 +602,8 @@ class _$AppRouter extends RootStackRouter { ], ), RouteConfig( - VerticalRouteView.name, - path: '/vertical-page-view', + MemoryRoute.name, + path: '/memory-page', guards: [ authGuard, duplicateGuard, @@ -680,6 +681,7 @@ class GalleryViewerRoute extends PageRouteInfo { required int initialIndex, required Asset Function(int) loadAsset, required int totalAssets, + int heroOffset = 0, }) : super( GalleryViewerRoute.name, path: '/gallery-viewer-page', @@ -688,6 +690,7 @@ class GalleryViewerRoute extends PageRouteInfo { initialIndex: initialIndex, loadAsset: loadAsset, totalAssets: totalAssets, + heroOffset: heroOffset, ), ); @@ -700,6 +703,7 @@ class GalleryViewerRouteArgs { required this.initialIndex, required this.loadAsset, required this.totalAssets, + this.heroOffset = 0, }); final Key? key; @@ -710,9 +714,11 @@ class GalleryViewerRouteArgs { final int totalAssets; + final int heroOffset; + @override String toString() { - return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets}'; + return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset}'; } } @@ -1014,7 +1020,7 @@ class AlbumViewerRoute extends PageRouteInfo { required int albumId, }) : super( AlbumViewerRoute.name, - path: '/album-viewer-page', + path: '/', args: AlbumViewerRouteArgs( key: key, albumId: albumId, @@ -1302,26 +1308,26 @@ class AllPeopleRoute extends PageRouteInfo { /// generated route for /// [MemoryPage] -class VerticalRouteView extends PageRouteInfo { - VerticalRouteView({ +class MemoryRoute extends PageRouteInfo { + MemoryRoute({ required List memories, required int memoryIndex, Key? key, }) : super( - VerticalRouteView.name, - path: '/vertical-page-view', - args: VerticalRouteViewArgs( + MemoryRoute.name, + path: '/memory-page', + args: MemoryRouteArgs( memories: memories, memoryIndex: memoryIndex, key: key, ), ); - static const String name = 'VerticalRouteView'; + static const String name = 'MemoryRoute'; } -class VerticalRouteViewArgs { - const VerticalRouteViewArgs({ +class MemoryRouteArgs { + const MemoryRouteArgs({ required this.memories, required this.memoryIndex, this.key, @@ -1335,7 +1341,7 @@ class VerticalRouteViewArgs { @override String toString() { - return 'VerticalRouteViewArgs{memories: $memories, memoryIndex: $memoryIndex, key: $key}'; + return 'MemoryRouteArgs{memories: $memories, memoryIndex: $memoryIndex, key: $key}'; } } From 48c9cfb4326dd78a40bb4d9c1d358c4d41ce4ccc Mon Sep 17 00:00:00 2001 From: Harry Tran Date: Fri, 14 Jul 2023 01:43:11 +1000 Subject: [PATCH 23/38] fix(web): remove processing key events in photo viewer window (#3238) --- web/src/lib/components/asset-viewer/photo-viewer.svelte | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index bbf73c734..1b847f697 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -45,6 +45,9 @@ }; const handleKeypress = async ({ metaKey, ctrlKey, key }: KeyboardEvent) => { + if (window.getSelection()?.type === 'Range') { + return; + } if ((metaKey || ctrlKey) && key === 'c') { await doCopy(); } From 34d1f74b77c8f3444a10aa3aebd199e20a473d82 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Jul 2023 10:44:37 -0500 Subject: [PATCH 24/38] chore(deps): bump docker/setup-buildx-action from 2.9.0 to 2.9.1 (#3236) Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2.9.0 to 2.9.1. - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/v2.9.0...v2.9.1) --- updated-dependencies: - dependency-name: docker/setup-buildx-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 4784864a9..98a3b156b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -45,7 +45,7 @@ jobs: uses: docker/setup-qemu-action@v2.2.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2.9.0 + uses: docker/setup-buildx-action@v2.9.1 # Workaround to fix error: # failed to push: failed to copy: io: read/write on closed pipe # See https://github.com/docker/build-push-action/issues/761 From 2fb85f4a164e588bf70f65b107081bbb4834067b Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Thu, 13 Jul 2023 17:02:49 -0400 Subject: [PATCH 25/38] chore: rebase main (#3103) Co-authored-by: Jason Rasmussen --- .../immich/api-v1/asset/asset.controller.ts | 18 +-- .../src/immich/api-v1/asset/asset.service.ts | 112 ++++-------------- 2 files changed, 26 insertions(+), 104 deletions(-) diff --git a/server/src/immich/api-v1/asset/asset.controller.ts b/server/src/immich/api-v1/asset/asset.controller.ts index 4a738f37e..b59c97199 100644 --- a/server/src/immich/api-v1/asset/asset.controller.ts +++ b/server/src/immich/api-v1/asset/asset.controller.ts @@ -4,8 +4,6 @@ import { Controller, Delete, Get, - Header, - Headers, HttpCode, HttpStatus, Param, @@ -111,39 +109,35 @@ export class AssetController { @SharedLinkRoute() @Get('/file/:id') - @Header('Cache-Control', 'private, max-age=86400, no-transform') @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } }, }, }) - serveFile( + async serveFile( @AuthUser() authUser: AuthUserDto, - @Headers() headers: Record, - @Response({ passthrough: true }) res: Res, + @Response() res: Res, @Query(new ValidationPipe({ transform: true })) query: ServeFileDto, @Param() { id }: UUIDParamDto, ) { - return this.assetService.serveFile(authUser, id, query, res, headers); + await this.assetService.serveFile(authUser, id, query, res); } @SharedLinkRoute() @Get('/thumbnail/:id') - @Header('Cache-Control', 'private, max-age=86400, no-transform') @ApiOkResponse({ content: { 'image/jpeg': { schema: { type: 'string', format: 'binary' } }, 'image/webp': { schema: { type: 'string', format: 'binary' } }, }, }) - getAssetThumbnail( + async getAssetThumbnail( @AuthUser() authUser: AuthUserDto, - @Headers() headers: Record, - @Response({ passthrough: true }) res: Res, + @Response() res: Res, @Param() { id }: UUIDParamDto, @Query(new ValidationPipe({ transform: true })) query: GetAssetThumbnailDto, ) { - return this.assetService.getAssetThumbnail(authUser, id, query, res, headers); + await this.assetService.serveThumbnail(authUser, id, query, res); } @Get('/curated-objects') diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index 5fe7df0d2..c08a24fe0 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -28,11 +28,10 @@ import { } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Response as Res } from 'express'; -import { constants, createReadStream } from 'fs'; +import { constants } from 'fs'; import fs from 'fs/promises'; import path, { extname } from 'path'; import sanitize from 'sanitize-filename'; -import { pipeline } from 'stream/promises'; import { QueryFailedError, Repository } from 'typeorm'; import { UploadRequest } from '../../app.interceptor'; import { IAssetRepository } from './asset-repository'; @@ -301,13 +300,7 @@ export class AssetService { return mapAsset(updatedAsset); } - async getAssetThumbnail( - authUser: AuthUserDto, - assetId: string, - query: GetAssetThumbnailDto, - res: Res, - headers: Record, - ) { + async serveThumbnail(authUser: AuthUserDto, assetId: string, query: GetAssetThumbnailDto, res: Res) { await this.access.requirePermission(authUser, Permission.ASSET_VIEW, assetId); const asset = await this._assetRepository.get(assetId); @@ -316,7 +309,7 @@ export class AssetService { } try { - return this.streamFile(this.getThumbnailPath(asset, query.format), res, headers); + await this.sendFile(res, this.getThumbnailPath(asset, query.format)); } catch (e) { res.header('Cache-Control', 'none'); this.logger.error(`Cannot create read stream for asset ${asset.id}`, 'getAssetThumbnail'); @@ -327,42 +320,23 @@ export class AssetService { } } - public async serveFile( - authUser: AuthUserDto, - assetId: string, - query: ServeFileDto, - res: Res, - headers: Record, - ) { + public async serveFile(authUser: AuthUserDto, assetId: string, query: ServeFileDto, res: Res) { // this is not quite right as sometimes this returns the original still await this.access.requirePermission(authUser, Permission.ASSET_VIEW, assetId); - const allowOriginalFile = !!(!authUser.isPublicUser || authUser.isAllowDownload); - const asset = await this._assetRepository.getById(assetId); if (!asset) { throw new NotFoundException('Asset does not exist'); } - // Handle Sending Images - if (asset.type == AssetType.IMAGE) { - try { - return this.streamFile(this.getServePath(asset, query, allowOriginalFile), res, headers); - } catch (e) { - this.logger.error(`Cannot create read stream for asset ${asset.id} ${JSON.stringify(e)}`, 'serveFile[IMAGE]'); - throw new InternalServerErrorException( - e, - `Cannot read thumbnail file for asset ${asset.id} - contact your administrator`, - ); - } - } else { - try { - return this.streamFile(asset.encodedVideoPath || asset.originalPath, res, headers); - } catch (e: Error | any) { - this.logger.error(`Error serving VIDEO asset=${asset.id}`, e?.stack); - throw new InternalServerErrorException(`Failed to serve video asset ${e}`, 'ServeFile'); - } - } + const allowOriginalFile = !!(!authUser.isPublicUser || authUser.isAllowDownload); + + const filepath = + asset.type === AssetType.IMAGE + ? this.getServePath(asset, query, allowOriginalFile) + : asset.encodedVideoPath || asset.originalPath; + + await this.sendFile(res, filepath); } public async deleteAll(authUser: AuthUserDto, dto: DeleteAssetDto): Promise { @@ -624,64 +598,18 @@ export class AssetService { return asset.resizePath; } - private async streamFile(filepath: string, res: Res, headers: Record) { + private async sendFile(res: Res, filepath: string): Promise { await fs.access(filepath, constants.R_OK); - const { size, mtimeNs } = await fs.stat(filepath, { bigint: true }); - + res.set('Cache-Control', 'private, max-age=86400, no-transform'); res.header('Content-Type', mimeTypes.lookup(filepath)); + res.sendFile(filepath, { root: process.cwd() }, (error: Error) => { + if (!error) { + return; + } - const range = this.setResRange(res, headers, Number(size)); - - // etag - const etag = `W/"${size}-${mtimeNs}"`; - res.setHeader('ETag', etag); - if (etag === headers['if-none-match']) { - res.status(304); - return; - } - - const stream = createReadStream(filepath, range); - return await pipeline(stream, res).catch((err) => { - if (err.code !== 'ERR_STREAM_PREMATURE_CLOSE') { - this.logger.error(err); + if (error.message !== 'Request aborted') { + this.logger.error(`Unable to send file: ${error.name}`, error.stack); } }); } - - private setResRange(res: Res, headers: Record, size: number) { - if (!headers.range) { - return {}; - } - - /** Extracting Start and End value from Range Header */ - const [startStr, endStr] = headers.range.replace(/bytes=/, '').split('-'); - let start = parseInt(startStr, 10); - let end = endStr ? parseInt(endStr, 10) : size - 1; - - if (!isNaN(start) && isNaN(end)) { - start = start; - end = size - 1; - } - - if (isNaN(start) && !isNaN(end)) { - start = size - end; - end = size - 1; - } - - // Handle unavailable range request - if (start >= size || end >= size) { - console.error('Bad Request'); - res.status(416).set({ 'Content-Range': `bytes */${size}` }); - - throw new BadRequestException('Bad Request Range'); - } - - res.status(206).set({ - 'Content-Range': `bytes ${start}-${end}/${size}`, - 'Accept-Ranges': 'bytes', - 'Content-Length': end - start + 1, - }); - - return { start, end }; - } } From 6387e38e27bb365ec129480526f921c8c56b9fb2 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 13 Jul 2023 18:42:27 -0500 Subject: [PATCH 26/38] fix(server): Asset controller does not load all endpoint (#3245) --- server/src/immich/app.module.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/immich/app.module.ts b/server/src/immich/app.module.ts index 522b2912a..bb32db502 100644 --- a/server/src/immich/app.module.ts +++ b/server/src/immich/app.module.ts @@ -39,11 +39,11 @@ import { TypeOrmModule.forFeature([AssetEntity, ExifEntity]), ], controllers: [ + AssetController, AssetControllerV1, AppController, AlbumController, APIKeyController, - AssetController, AuthController, JobController, OAuthController, From cd184cf36616ad19573872595585c6870ef4c386 Mon Sep 17 00:00:00 2001 From: Alex The Bot Date: Fri, 14 Jul 2023 00:39:54 +0000 Subject: [PATCH 27/38] Version v1.67.0 --- cli/src/api/open-api/api.ts | 2 +- cli/src/api/open-api/base.ts | 2 +- cli/src/api/open-api/common.ts | 2 +- cli/src/api/open-api/configuration.ts | 2 +- cli/src/api/open-api/index.ts | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- server/immich-openapi-specs.json | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/src/api/open-api/api.ts | 2 +- web/src/api/open-api/base.ts | 2 +- web/src/api/open-api/common.ts | 2 +- web/src/api/open-api/configuration.ts | 2 +- web/src/api/open-api/index.ts | 2 +- 18 files changed, 20 insertions(+), 20 deletions(-) diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index da189929c..9dd2f2033 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.66.1 + * The version of the OpenAPI document: 1.67.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/cli/src/api/open-api/base.ts b/cli/src/api/open-api/base.ts index 3281535e5..cdc421eb1 100644 --- a/cli/src/api/open-api/base.ts +++ b/cli/src/api/open-api/base.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.66.1 + * The version of the OpenAPI document: 1.67.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/cli/src/api/open-api/common.ts b/cli/src/api/open-api/common.ts index c9fda5ed7..2fe94a62b 100644 --- a/cli/src/api/open-api/common.ts +++ b/cli/src/api/open-api/common.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.66.1 + * The version of the OpenAPI document: 1.67.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/cli/src/api/open-api/configuration.ts b/cli/src/api/open-api/configuration.ts index 1a74d526b..b0fb1a3ac 100644 --- a/cli/src/api/open-api/configuration.ts +++ b/cli/src/api/open-api/configuration.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.66.1 + * The version of the OpenAPI document: 1.67.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/cli/src/api/open-api/index.ts b/cli/src/api/open-api/index.ts index 1243cc241..1a07c2aff 100644 --- a/cli/src/api/open-api/index.ts +++ b/cli/src/api/open-api/index.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.66.1 + * The version of the OpenAPI document: 1.67.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index ab321c8b5..2ef0b43ea 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.66.1" +version = "1.67.0" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index bd427e209..7769f0dd2 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 89, - "android.injected.version.name" => "1.66.1", + "android.injected.version.code" => 90, + "android.injected.version.name" => "1.67.0", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 51b66dd95..d9b679cf0 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Beta" lane :beta do increment_version_number( - version_number: "1.66.1" + version_number: "1.67.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 98dc3fac7..e9c9ddced 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.66.1 +- API version: 1.67.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen ## Requirements diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 45189a7c6..cf2f64e68 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: "none" -version: 1.66.1+89 +version: 1.67.0+90 isar_version: &isar_version 3.1.0+1 environment: diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index f285899b1..e7cccc3e4 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -4383,7 +4383,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.66.1", + "version": "1.67.0", "contact": {} }, "tags": [], diff --git a/server/package-lock.json b/server/package-lock.json index 468d08730..89f84114d 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.66.1", + "version": "1.67.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.66.1", + "version": "1.67.0", "license": "UNLICENSED", "dependencies": { "@babel/runtime": "^7.20.13", diff --git a/server/package.json b/server/package.json index 4c3ba0e1f..dcf86de7f 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.66.1", + "version": "1.67.0", "description": "", "author": "", "private": true, diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 89633647c..a2866bbe6 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.66.1 + * The version of the OpenAPI document: 1.67.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/base.ts b/web/src/api/open-api/base.ts index 3281535e5..cdc421eb1 100644 --- a/web/src/api/open-api/base.ts +++ b/web/src/api/open-api/base.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.66.1 + * The version of the OpenAPI document: 1.67.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/common.ts b/web/src/api/open-api/common.ts index c9fda5ed7..2fe94a62b 100644 --- a/web/src/api/open-api/common.ts +++ b/web/src/api/open-api/common.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.66.1 + * The version of the OpenAPI document: 1.67.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/configuration.ts b/web/src/api/open-api/configuration.ts index 1a74d526b..b0fb1a3ac 100644 --- a/web/src/api/open-api/configuration.ts +++ b/web/src/api/open-api/configuration.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.66.1 + * The version of the OpenAPI document: 1.67.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/index.ts b/web/src/api/open-api/index.ts index 1243cc241..1a07c2aff 100644 --- a/web/src/api/open-api/index.ts +++ b/web/src/api/open-api/index.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.66.1 + * The version of the OpenAPI document: 1.67.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). From f18c2fd339004e7fce3f9ea098e0e3dd1a199c73 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 13 Jul 2023 22:41:16 -0400 Subject: [PATCH 28/38] fix: exclude e2e format (#3250) --- server/tsconfig.build.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/tsconfig.build.json b/server/tsconfig.build.json index 0d7cd0873..6bdc715a1 100644 --- a/server/tsconfig.build.json +++ b/server/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["dist", "node_modules", "upload", "test", "**/*spec.ts"] + "exclude": ["dist", "node_modules", "upload", "test", "e2e", "**/*spec.ts"] } From 2d4e2af6294891cde070856ec841644b05092bfc Mon Sep 17 00:00:00 2001 From: Alex The Bot Date: Fri, 14 Jul 2023 02:45:02 +0000 Subject: [PATCH 29/38] Version v1.67.1 --- cli/src/api/open-api/api.ts | 2 +- cli/src/api/open-api/base.ts | 2 +- cli/src/api/open-api/common.ts | 2 +- cli/src/api/open-api/configuration.ts | 2 +- cli/src/api/open-api/index.ts | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 2 +- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- server/immich-openapi-specs.json | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/src/api/open-api/api.ts | 2 +- web/src/api/open-api/base.ts | 2 +- web/src/api/open-api/common.ts | 2 +- web/src/api/open-api/configuration.ts | 2 +- web/src/api/open-api/index.ts | 2 +- 18 files changed, 19 insertions(+), 19 deletions(-) diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 9dd2f2033..4f36e2a94 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.67.0 + * The version of the OpenAPI document: 1.67.1 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/cli/src/api/open-api/base.ts b/cli/src/api/open-api/base.ts index cdc421eb1..0ce7be5a0 100644 --- a/cli/src/api/open-api/base.ts +++ b/cli/src/api/open-api/base.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.67.0 + * The version of the OpenAPI document: 1.67.1 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/cli/src/api/open-api/common.ts b/cli/src/api/open-api/common.ts index 2fe94a62b..a5041a104 100644 --- a/cli/src/api/open-api/common.ts +++ b/cli/src/api/open-api/common.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.67.0 + * The version of the OpenAPI document: 1.67.1 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/cli/src/api/open-api/configuration.ts b/cli/src/api/open-api/configuration.ts index b0fb1a3ac..8d02efcd4 100644 --- a/cli/src/api/open-api/configuration.ts +++ b/cli/src/api/open-api/configuration.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.67.0 + * The version of the OpenAPI document: 1.67.1 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/cli/src/api/open-api/index.ts b/cli/src/api/open-api/index.ts index 1a07c2aff..ae69b2411 100644 --- a/cli/src/api/open-api/index.ts +++ b/cli/src/api/open-api/index.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.67.0 + * The version of the OpenAPI document: 1.67.1 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 2ef0b43ea..202bbab46 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.67.0" +version = "1.67.1" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 7769f0dd2..80b028ce6 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -36,7 +36,7 @@ platform :android do build_type: 'Release', properties: { "android.injected.version.code" => 90, - "android.injected.version.name" => "1.67.0", + "android.injected.version.name" => "1.67.1", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index d9b679cf0..92d2f2e2c 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Beta" lane :beta do increment_version_number( - version_number: "1.67.0" + version_number: "1.67.1" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index e9c9ddced..ef92b6a0f 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.67.0 +- API version: 1.67.1 - Build package: org.openapitools.codegen.languages.DartClientCodegen ## Requirements diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index cf2f64e68..ab879b19b 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: "none" -version: 1.67.0+90 +version: 1.67.1+90 isar_version: &isar_version 3.1.0+1 environment: diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index e7cccc3e4..a3ce03df0 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -4383,7 +4383,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.67.0", + "version": "1.67.1", "contact": {} }, "tags": [], diff --git a/server/package-lock.json b/server/package-lock.json index 89f84114d..c9a3006e9 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.67.0", + "version": "1.67.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.67.0", + "version": "1.67.1", "license": "UNLICENSED", "dependencies": { "@babel/runtime": "^7.20.13", diff --git a/server/package.json b/server/package.json index dcf86de7f..15563c7d2 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.67.0", + "version": "1.67.1", "description": "", "author": "", "private": true, diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index a2866bbe6..484dc4e4d 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.67.0 + * The version of the OpenAPI document: 1.67.1 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/base.ts b/web/src/api/open-api/base.ts index cdc421eb1..0ce7be5a0 100644 --- a/web/src/api/open-api/base.ts +++ b/web/src/api/open-api/base.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.67.0 + * The version of the OpenAPI document: 1.67.1 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/common.ts b/web/src/api/open-api/common.ts index 2fe94a62b..a5041a104 100644 --- a/web/src/api/open-api/common.ts +++ b/web/src/api/open-api/common.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.67.0 + * The version of the OpenAPI document: 1.67.1 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/configuration.ts b/web/src/api/open-api/configuration.ts index b0fb1a3ac..8d02efcd4 100644 --- a/web/src/api/open-api/configuration.ts +++ b/web/src/api/open-api/configuration.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.67.0 + * The version of the OpenAPI document: 1.67.1 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/index.ts b/web/src/api/open-api/index.ts index 1a07c2aff..ae69b2411 100644 --- a/web/src/api/open-api/index.ts +++ b/web/src/api/open-api/index.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.67.0 + * The version of the OpenAPI document: 1.67.1 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). From ea3d01ec627b00025a9e378be3f81a4c11218124 Mon Sep 17 00:00:00 2001 From: xpwmaosldk Date: Fri, 14 Jul 2023 22:20:04 +0900 Subject: [PATCH 30/38] chore(mobile): clean up (#3256) --- mobile/lib/main.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 1dfc630d4..672198e5b 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -93,7 +93,8 @@ Future loadDb() async { DuplicatedAssetSchema, LoggerMessageSchema, ETagSchema, - Platform.isAndroid ? AndroidDeviceAssetSchema : IOSDeviceAssetSchema, + if (Platform.isAndroid) AndroidDeviceAssetSchema, + if (Platform.isIOS) IOSDeviceAssetSchema, ], directory: dir.path, maxSizeMiB: 256, From 05e1a6d94958b84cded65c97ee6ed72dbeb819b6 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 14 Jul 2023 09:22:38 -0400 Subject: [PATCH 31/38] chore: hide auto generated cli content (#3254) --- .gitattributes | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.gitattributes b/.gitattributes index b45b801c9..32ea167bb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,12 +2,16 @@ mobile/openapi/**/*.md -diff -merge mobile/openapi/**/*.md linguist-generated=true mobile/openapi/**/*.dart -diff -merge mobile/openapi/**/*.dart linguist-generated=true +mobile/openapi/.openapi-generator/FILES -diff -merge +mobile/openapi/.openapi-generator/FILES linguist-generated=true + + +cli/src/api/open-api/**/*.md -diff -merge +cli/src/api/open-api/**/*.md linguist-generated=true +cli/src/api/open-api/**/*.ts -diff -merge +cli/src/api/open-api/**/*.ts linguist-generated=true web/src/api/open-api/**/*.md -diff -merge web/src/api/open-api/**/*.md linguist-generated=true - web/src/api/open-api/**/*.ts -diff -merge web/src/api/open-api/**/*.ts linguist-generated=true - -mobile/openapi/.openapi-generator/FILES -diff -merge -mobile/openapi/.openapi-generator/FILES linguist-generated=true From f952bc0b64c9e6f7f2a8320bc12a31576040be4e Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 14 Jul 2023 09:30:17 -0400 Subject: [PATCH 32/38] refactor(server): asset stats (#3253) * refactor(server): asset stats * chore: open api --- cli/src/api/open-api/api.ts | 194 +++++++---------- mobile/openapi/.openapi-generator/FILES | 6 +- mobile/openapi/README.md | 5 +- mobile/openapi/doc/AssetApi.md | 162 ++++++--------- ...esponseDto.md => AssetStatsResponseDto.md} | 10 +- mobile/openapi/lib/api.dart | 2 +- mobile/openapi/lib/api/asset_api.dart | 140 ++++++------- mobile/openapi/lib/api_client.dart | 4 +- .../asset_count_by_user_id_response_dto.dart | 130 ------------ .../lib/model/asset_stats_response_dto.dart | 114 ++++++++++ mobile/openapi/test/asset_api_test.dart | 13 +- ...art => asset_stats_response_dto_test.dart} | 24 +-- server/immich-openapi-specs.json | 108 ++++------ server/src/domain/asset/asset.repository.ts | 8 + server/src/domain/asset/asset.service.spec.ts | 44 +++- server/src/domain/asset/asset.service.ts | 6 + .../domain/asset/dto/asset-statistics.dto.ts | 37 ++++ server/src/domain/asset/dto/index.ts | 1 + .../immich/api-v1/asset/asset-repository.ts | 57 +---- .../immich/api-v1/asset/asset.controller.ts | 10 - .../immich/api-v1/asset/asset.service.spec.ts | 35 ---- .../src/immich/api-v1/asset/asset.service.ts | 9 - .../asset-count-by-user-id-response.dto.ts | 18 -- .../immich/controllers/asset.controller.ts | 7 + .../infra/repositories/asset.repository.ts | 36 ++++ .../repositories/asset.repository.mock.ts | 1 + web/src/api/open-api/api.ts | 195 +++++++----------- .../side-bar/side-bar.svelte | 59 +----- web/src/routes/(user)/photos/+page.svelte | 10 +- 29 files changed, 601 insertions(+), 844 deletions(-) rename mobile/openapi/doc/{AssetCountByUserIdResponseDto.md => AssetStatsResponseDto.md} (58%) delete mode 100644 mobile/openapi/lib/model/asset_count_by_user_id_response_dto.dart create mode 100644 mobile/openapi/lib/model/asset_stats_response_dto.dart rename mobile/openapi/test/{asset_count_by_user_id_response_dto_test.dart => asset_stats_response_dto_test.dart} (50%) create mode 100644 server/src/domain/asset/dto/asset-statistics.dto.ts delete mode 100644 server/src/immich/api-v1/asset/response-dto/asset-count-by-user-id-response.dto.ts diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 4f36e2a94..68552add3 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -486,43 +486,6 @@ export interface AssetCountByTimeBucketResponseDto { */ 'buckets': Array; } -/** - * - * @export - * @interface AssetCountByUserIdResponseDto - */ -export interface AssetCountByUserIdResponseDto { - /** - * - * @type {number} - * @memberof AssetCountByUserIdResponseDto - */ - 'audio': number; - /** - * - * @type {number} - * @memberof AssetCountByUserIdResponseDto - */ - 'photos': number; - /** - * - * @type {number} - * @memberof AssetCountByUserIdResponseDto - */ - 'videos': number; - /** - * - * @type {number} - * @memberof AssetCountByUserIdResponseDto - */ - 'other': number; - /** - * - * @type {number} - * @memberof AssetCountByUserIdResponseDto - */ - 'total': number; -} /** * * @export @@ -724,6 +687,31 @@ export interface AssetResponseDto { } +/** + * + * @export + * @interface AssetStatsResponseDto + */ +export interface AssetStatsResponseDto { + /** + * + * @type {number} + * @memberof AssetStatsResponseDto + */ + 'images': number; + /** + * + * @type {number} + * @memberof AssetStatsResponseDto + */ + 'videos': number; + /** + * + * @type {number} + * @memberof AssetStatsResponseDto + */ + 'total': number; +} /** * * @export @@ -4892,44 +4880,6 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - getArchivedAssetCountByUserId: async (options: AxiosRequestConfig = {}): Promise => { - const localVarPath = `/asset/stat/archive`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication cookie required - - // authentication api_key required - await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) - - // authentication bearer required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - - setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -5079,8 +5029,8 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAssetCountByUserId: async (options: AxiosRequestConfig = {}): Promise => { - const localVarPath = `/asset/count-by-user-id`; + getAssetSearchTerms: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/asset/search-terms`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -5114,11 +5064,13 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration }, /** * + * @param {boolean} [isArchived] + * @param {boolean} [isFavorite] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAssetSearchTerms: async (options: AxiosRequestConfig = {}): Promise => { - const localVarPath = `/asset/search-terms`; + getAssetStats: async (isArchived?: boolean, isFavorite?: boolean, options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/asset/statistics`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -5139,6 +5091,14 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration // http bearer authentication required await setBearerAuthToObject(localVarHeaderParameter, configuration) + if (isArchived !== undefined) { + localVarQueryParameter['isArchived'] = isArchived; + } + + if (isFavorite !== undefined) { + localVarQueryParameter['isFavorite'] = isFavorite; + } + setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -5887,15 +5847,6 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(userId, isFavorite, isArchived, withoutThumbs, skip, ifNoneMatch, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async getArchivedAssetCountByUserId(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getArchivedAssetCountByUserId(options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, /** * Get a single asset\'s information * @param {string} id @@ -5932,17 +5883,19 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getAssetCountByUserId(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetCountByUserId(options); + async getAssetSearchTerms(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetSearchTerms(options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** * + * @param {boolean} [isArchived] + * @param {boolean} [isFavorite] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getAssetSearchTerms(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetSearchTerms(options); + async getAssetStats(isArchived?: boolean, isFavorite?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetStats(isArchived, isFavorite, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -6160,14 +6113,6 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath getAllAssets(requestParameters: AssetApiGetAllAssetsRequest = {}, options?: AxiosRequestConfig): AxiosPromise> { return localVarFp.getAllAssets(requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.withoutThumbs, requestParameters.skip, requestParameters.ifNoneMatch, options).then((request) => request(axios, basePath)); }, - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - getArchivedAssetCountByUserId(options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.getArchivedAssetCountByUserId(options).then((request) => request(axios, basePath)); - }, /** * Get a single asset\'s information * @param {AssetApiGetAssetByIdRequest} requestParameters Request parameters. @@ -6200,16 +6145,17 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAssetCountByUserId(options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.getAssetCountByUserId(options).then((request) => request(axios, basePath)); + getAssetSearchTerms(options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.getAssetSearchTerms(options).then((request) => request(axios, basePath)); }, /** * + * @param {AssetApiGetAssetStatsRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAssetSearchTerms(options?: AxiosRequestConfig): AxiosPromise> { - return localVarFp.getAssetSearchTerms(options).then((request) => request(axios, basePath)); + getAssetStats(requestParameters: AssetApiGetAssetStatsRequest = {}, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.getAssetStats(requestParameters.isArchived, requestParameters.isFavorite, options).then((request) => request(axios, basePath)); }, /** * @@ -6523,6 +6469,27 @@ export interface AssetApiGetAssetCountByTimeBucketRequest { readonly getAssetCountByTimeBucketDto: GetAssetCountByTimeBucketDto } +/** + * Request parameters for getAssetStats operation in AssetApi. + * @export + * @interface AssetApiGetAssetStatsRequest + */ +export interface AssetApiGetAssetStatsRequest { + /** + * + * @type {boolean} + * @memberof AssetApiGetAssetStats + */ + readonly isArchived?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiGetAssetStats + */ + readonly isFavorite?: boolean +} + /** * Request parameters for getAssetThumbnail operation in AssetApi. * @export @@ -6915,16 +6882,6 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).getAllAssets(requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.withoutThumbs, requestParameters.skip, requestParameters.ifNoneMatch, options).then((request) => request(this.axios, this.basePath)); } - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof AssetApi - */ - public getArchivedAssetCountByUserId(options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).getArchivedAssetCountByUserId(options).then((request) => request(this.axios, this.basePath)); - } - /** * Get a single asset\'s information * @param {AssetApiGetAssetByIdRequest} requestParameters Request parameters. @@ -6964,18 +6921,19 @@ export class AssetApi extends BaseAPI { * @throws {RequiredError} * @memberof AssetApi */ - public getAssetCountByUserId(options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).getAssetCountByUserId(options).then((request) => request(this.axios, this.basePath)); + public getAssetSearchTerms(options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).getAssetSearchTerms(options).then((request) => request(this.axios, this.basePath)); } /** * + * @param {AssetApiGetAssetStatsRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof AssetApi */ - public getAssetSearchTerms(options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).getAssetSearchTerms(options).then((request) => request(this.axios, this.basePath)); + public getAssetStats(requestParameters: AssetApiGetAssetStatsRequest = {}, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).getAssetStats(requestParameters.isArchived, requestParameters.isFavorite, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 86742e468..f098bf4ff 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -23,11 +23,11 @@ doc/AssetBulkUploadCheckResponseDto.md doc/AssetBulkUploadCheckResult.md doc/AssetCountByTimeBucket.md doc/AssetCountByTimeBucketResponseDto.md -doc/AssetCountByUserIdResponseDto.md doc/AssetFileUploadResponseDto.md doc/AssetIdsDto.md doc/AssetIdsResponseDto.md doc/AssetResponseDto.md +doc/AssetStatsResponseDto.md doc/AssetTypeEnum.md doc/AudioCodec.md doc/AuthDeviceResponseDto.md @@ -163,11 +163,11 @@ lib/model/asset_bulk_upload_check_response_dto.dart lib/model/asset_bulk_upload_check_result.dart lib/model/asset_count_by_time_bucket.dart lib/model/asset_count_by_time_bucket_response_dto.dart -lib/model/asset_count_by_user_id_response_dto.dart lib/model/asset_file_upload_response_dto.dart lib/model/asset_ids_dto.dart lib/model/asset_ids_response_dto.dart lib/model/asset_response_dto.dart +lib/model/asset_stats_response_dto.dart lib/model/asset_type_enum.dart lib/model/audio_codec.dart lib/model/auth_device_response_dto.dart @@ -272,11 +272,11 @@ test/asset_bulk_upload_check_response_dto_test.dart test/asset_bulk_upload_check_result_test.dart test/asset_count_by_time_bucket_response_dto_test.dart test/asset_count_by_time_bucket_test.dart -test/asset_count_by_user_id_response_dto_test.dart test/asset_file_upload_response_dto_test.dart test/asset_ids_dto_test.dart test/asset_ids_response_dto_test.dart test/asset_response_dto_test.dart +test/asset_stats_response_dto_test.dart test/asset_type_enum_test.dart test/audio_codec_test.dart test/auth_device_response_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index ef92b6a0f..7f3e9db5e 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -94,12 +94,11 @@ Class | Method | HTTP request | Description *AssetApi* | [**downloadArchive**](doc//AssetApi.md#downloadarchive) | **POST** /asset/download | *AssetApi* | [**downloadFile**](doc//AssetApi.md#downloadfile) | **POST** /asset/download/{id} | *AssetApi* | [**getAllAssets**](doc//AssetApi.md#getallassets) | **GET** /asset | -*AssetApi* | [**getArchivedAssetCountByUserId**](doc//AssetApi.md#getarchivedassetcountbyuserid) | **GET** /asset/stat/archive | *AssetApi* | [**getAssetById**](doc//AssetApi.md#getassetbyid) | **GET** /asset/assetById/{id} | *AssetApi* | [**getAssetByTimeBucket**](doc//AssetApi.md#getassetbytimebucket) | **POST** /asset/time-bucket | *AssetApi* | [**getAssetCountByTimeBucket**](doc//AssetApi.md#getassetcountbytimebucket) | **POST** /asset/count-by-time-bucket | -*AssetApi* | [**getAssetCountByUserId**](doc//AssetApi.md#getassetcountbyuserid) | **GET** /asset/count-by-user-id | *AssetApi* | [**getAssetSearchTerms**](doc//AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms | +*AssetApi* | [**getAssetStats**](doc//AssetApi.md#getassetstats) | **GET** /asset/statistics | *AssetApi* | [**getAssetThumbnail**](doc//AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} | *AssetApi* | [**getCuratedLocations**](doc//AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations | *AssetApi* | [**getCuratedObjects**](doc//AssetApi.md#getcuratedobjects) | **GET** /asset/curated-objects | @@ -194,11 +193,11 @@ Class | Method | HTTP request | Description - [AssetBulkUploadCheckResult](doc//AssetBulkUploadCheckResult.md) - [AssetCountByTimeBucket](doc//AssetCountByTimeBucket.md) - [AssetCountByTimeBucketResponseDto](doc//AssetCountByTimeBucketResponseDto.md) - - [AssetCountByUserIdResponseDto](doc//AssetCountByUserIdResponseDto.md) - [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md) - [AssetIdsDto](doc//AssetIdsDto.md) - [AssetIdsResponseDto](doc//AssetIdsResponseDto.md) - [AssetResponseDto](doc//AssetResponseDto.md) + - [AssetStatsResponseDto](doc//AssetStatsResponseDto.md) - [AssetTypeEnum](doc//AssetTypeEnum.md) - [AudioCodec](doc//AudioCodec.md) - [AuthDeviceResponseDto](doc//AuthDeviceResponseDto.md) diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 998836d09..644907d1e 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -16,12 +16,11 @@ Method | HTTP request | Description [**downloadArchive**](AssetApi.md#downloadarchive) | **POST** /asset/download | [**downloadFile**](AssetApi.md#downloadfile) | **POST** /asset/download/{id} | [**getAllAssets**](AssetApi.md#getallassets) | **GET** /asset | -[**getArchivedAssetCountByUserId**](AssetApi.md#getarchivedassetcountbyuserid) | **GET** /asset/stat/archive | [**getAssetById**](AssetApi.md#getassetbyid) | **GET** /asset/assetById/{id} | [**getAssetByTimeBucket**](AssetApi.md#getassetbytimebucket) | **POST** /asset/time-bucket | [**getAssetCountByTimeBucket**](AssetApi.md#getassetcountbytimebucket) | **POST** /asset/count-by-time-bucket | -[**getAssetCountByUserId**](AssetApi.md#getassetcountbyuserid) | **GET** /asset/count-by-user-id | [**getAssetSearchTerms**](AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms | +[**getAssetStats**](AssetApi.md#getassetstats) | **GET** /asset/statistics | [**getAssetThumbnail**](AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} | [**getCuratedLocations**](AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations | [**getCuratedObjects**](AssetApi.md#getcuratedobjects) | **GET** /asset/curated-objects | @@ -445,57 +444,6 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) -# **getArchivedAssetCountByUserId** -> AssetCountByUserIdResponseDto getArchivedAssetCountByUserId() - - - -### Example -```dart -import 'package:openapi/api.dart'; -// TODO Configure API key authorization: cookie -//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; -// TODO Configure API key authorization: api_key -//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; -// TODO Configure HTTP Bearer authorization: bearer -// Case 1. Use String Token -//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); -// Case 2. Use Function which generate token. -// String yourTokenGeneratorFunction() { ... } -//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); - -final api_instance = AssetApi(); - -try { - final result = api_instance.getArchivedAssetCountByUserId(); - print(result); -} catch (e) { - print('Exception when calling AssetApi->getArchivedAssetCountByUserId: $e\n'); -} -``` - -### Parameters -This endpoint does not need any parameter. - -### Return type - -[**AssetCountByUserIdResponseDto**](AssetCountByUserIdResponseDto.md) - -### Authorization - -[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - # **getAssetById** > AssetResponseDto getAssetById(id, key) @@ -665,57 +613,6 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) -# **getAssetCountByUserId** -> AssetCountByUserIdResponseDto getAssetCountByUserId() - - - -### Example -```dart -import 'package:openapi/api.dart'; -// TODO Configure API key authorization: cookie -//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; -// TODO Configure API key authorization: api_key -//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; -// TODO Configure HTTP Bearer authorization: bearer -// Case 1. Use String Token -//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); -// Case 2. Use Function which generate token. -// String yourTokenGeneratorFunction() { ... } -//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); - -final api_instance = AssetApi(); - -try { - final result = api_instance.getAssetCountByUserId(); - print(result); -} catch (e) { - print('Exception when calling AssetApi->getAssetCountByUserId: $e\n'); -} -``` - -### Parameters -This endpoint does not need any parameter. - -### Return type - -[**AssetCountByUserIdResponseDto**](AssetCountByUserIdResponseDto.md) - -### Authorization - -[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - # **getAssetSearchTerms** > List getAssetSearchTerms() @@ -767,6 +664,63 @@ This endpoint does not need any parameter. [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) +# **getAssetStats** +> AssetStatsResponseDto getAssetStats(isArchived, isFavorite) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = AssetApi(); +final isArchived = true; // bool | +final isFavorite = true; // bool | + +try { + final result = api_instance.getAssetStats(isArchived, isFavorite); + print(result); +} catch (e) { + print('Exception when calling AssetApi->getAssetStats: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **isArchived** | **bool**| | [optional] + **isFavorite** | **bool**| | [optional] + +### Return type + +[**AssetStatsResponseDto**](AssetStatsResponseDto.md) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + # **getAssetThumbnail** > MultipartFile getAssetThumbnail(id, format, key) diff --git a/mobile/openapi/doc/AssetCountByUserIdResponseDto.md b/mobile/openapi/doc/AssetStatsResponseDto.md similarity index 58% rename from mobile/openapi/doc/AssetCountByUserIdResponseDto.md rename to mobile/openapi/doc/AssetStatsResponseDto.md index b6271c3f7..d7937a7ed 100644 --- a/mobile/openapi/doc/AssetCountByUserIdResponseDto.md +++ b/mobile/openapi/doc/AssetStatsResponseDto.md @@ -1,4 +1,4 @@ -# openapi.model.AssetCountByUserIdResponseDto +# openapi.model.AssetStatsResponseDto ## Load the model package ```dart @@ -8,11 +8,9 @@ import 'package:openapi/api.dart'; ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- -**audio** | **int** | | [default to 0] -**photos** | **int** | | [default to 0] -**videos** | **int** | | [default to 0] -**other** | **int** | | [default to 0] -**total** | **int** | | [default to 0] +**images** | **int** | | +**videos** | **int** | | +**total** | **int** | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 099f5615c..ef9544c85 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -60,11 +60,11 @@ part 'model/asset_bulk_upload_check_response_dto.dart'; part 'model/asset_bulk_upload_check_result.dart'; part 'model/asset_count_by_time_bucket.dart'; part 'model/asset_count_by_time_bucket_response_dto.dart'; -part 'model/asset_count_by_user_id_response_dto.dart'; part 'model/asset_file_upload_response_dto.dart'; part 'model/asset_ids_dto.dart'; part 'model/asset_ids_response_dto.dart'; part 'model/asset_response_dto.dart'; +part 'model/asset_stats_response_dto.dart'; part 'model/asset_type_enum.dart'; part 'model/audio_codec.dart'; part 'model/auth_device_response_dto.dart'; diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 1c609a727..c570229aa 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -440,47 +440,6 @@ class AssetApi { return null; } - /// Performs an HTTP 'GET /asset/stat/archive' operation and returns the [Response]. - Future getArchivedAssetCountByUserIdWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/asset/stat/archive'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - Future getArchivedAssetCountByUserId() async { - final response = await getArchivedAssetCountByUserIdWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetCountByUserIdResponseDto',) as AssetCountByUserIdResponseDto; - - } - return null; - } - /// Get a single asset's information /// /// Note: This method returns the HTTP [Response]. @@ -639,47 +598,6 @@ class AssetApi { return null; } - /// Performs an HTTP 'GET /asset/count-by-user-id' operation and returns the [Response]. - Future getAssetCountByUserIdWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/asset/count-by-user-id'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - Future getAssetCountByUserId() async { - final response = await getAssetCountByUserIdWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetCountByUserIdResponseDto',) as AssetCountByUserIdResponseDto; - - } - return null; - } - /// Performs an HTTP 'GET /asset/search-terms' operation and returns the [Response]. Future getAssetSearchTermsWithHttpInfo() async { // ignore: prefer_const_declarations @@ -724,6 +642,64 @@ class AssetApi { return null; } + /// Performs an HTTP 'GET /asset/statistics' operation and returns the [Response]. + /// Parameters: + /// + /// * [bool] isArchived: + /// + /// * [bool] isFavorite: + Future getAssetStatsWithHttpInfo({ bool? isArchived, bool? isFavorite, }) async { + // ignore: prefer_const_declarations + final path = r'/asset/statistics'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (isArchived != null) { + queryParams.addAll(_queryParams('', 'isArchived', isArchived)); + } + if (isFavorite != null) { + queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [bool] isArchived: + /// + /// * [bool] isFavorite: + Future getAssetStats({ bool? isArchived, bool? isFavorite, }) async { + final response = await getAssetStatsWithHttpInfo( isArchived: isArchived, isFavorite: isFavorite, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetStatsResponseDto',) as AssetStatsResponseDto; + + } + return null; + } + /// Performs an HTTP 'GET /asset/thumbnail/{id}' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 5855da8e8..824d4c9eb 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -215,8 +215,6 @@ class ApiClient { return AssetCountByTimeBucket.fromJson(value); case 'AssetCountByTimeBucketResponseDto': return AssetCountByTimeBucketResponseDto.fromJson(value); - case 'AssetCountByUserIdResponseDto': - return AssetCountByUserIdResponseDto.fromJson(value); case 'AssetFileUploadResponseDto': return AssetFileUploadResponseDto.fromJson(value); case 'AssetIdsDto': @@ -225,6 +223,8 @@ class ApiClient { return AssetIdsResponseDto.fromJson(value); case 'AssetResponseDto': return AssetResponseDto.fromJson(value); + case 'AssetStatsResponseDto': + return AssetStatsResponseDto.fromJson(value); case 'AssetTypeEnum': return AssetTypeEnumTypeTransformer().decode(value); case 'AudioCodec': diff --git a/mobile/openapi/lib/model/asset_count_by_user_id_response_dto.dart b/mobile/openapi/lib/model/asset_count_by_user_id_response_dto.dart deleted file mode 100644 index 0e2b1cefc..000000000 --- a/mobile/openapi/lib/model/asset_count_by_user_id_response_dto.dart +++ /dev/null @@ -1,130 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.12 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class AssetCountByUserIdResponseDto { - /// Returns a new [AssetCountByUserIdResponseDto] instance. - AssetCountByUserIdResponseDto({ - this.audio = 0, - this.photos = 0, - this.videos = 0, - this.other = 0, - this.total = 0, - }); - - int audio; - - int photos; - - int videos; - - int other; - - int total; - - @override - bool operator ==(Object other) => identical(this, other) || other is AssetCountByUserIdResponseDto && - other.audio == audio && - other.photos == photos && - other.videos == videos && - other.other == other && - other.total == total; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (audio.hashCode) + - (photos.hashCode) + - (videos.hashCode) + - (other.hashCode) + - (total.hashCode); - - @override - String toString() => 'AssetCountByUserIdResponseDto[audio=$audio, photos=$photos, videos=$videos, other=$other, total=$total]'; - - Map toJson() { - final json = {}; - json[r'audio'] = this.audio; - json[r'photos'] = this.photos; - json[r'videos'] = this.videos; - json[r'other'] = this.other; - json[r'total'] = this.total; - return json; - } - - /// Returns a new [AssetCountByUserIdResponseDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static AssetCountByUserIdResponseDto? fromJson(dynamic value) { - if (value is Map) { - final json = value.cast(); - - return AssetCountByUserIdResponseDto( - audio: mapValueOfType(json, r'audio')!, - photos: mapValueOfType(json, r'photos')!, - videos: mapValueOfType(json, r'videos')!, - other: mapValueOfType(json, r'other')!, - total: mapValueOfType(json, r'total')!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = AssetCountByUserIdResponseDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = AssetCountByUserIdResponseDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of AssetCountByUserIdResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = AssetCountByUserIdResponseDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'audio', - 'photos', - 'videos', - 'other', - 'total', - }; -} - diff --git a/mobile/openapi/lib/model/asset_stats_response_dto.dart b/mobile/openapi/lib/model/asset_stats_response_dto.dart new file mode 100644 index 000000000..1221712d8 --- /dev/null +++ b/mobile/openapi/lib/model/asset_stats_response_dto.dart @@ -0,0 +1,114 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetStatsResponseDto { + /// Returns a new [AssetStatsResponseDto] instance. + AssetStatsResponseDto({ + required this.images, + required this.videos, + required this.total, + }); + + int images; + + int videos; + + int total; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetStatsResponseDto && + other.images == images && + other.videos == videos && + other.total == total; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (images.hashCode) + + (videos.hashCode) + + (total.hashCode); + + @override + String toString() => 'AssetStatsResponseDto[images=$images, videos=$videos, total=$total]'; + + Map toJson() { + final json = {}; + json[r'images'] = this.images; + json[r'videos'] = this.videos; + json[r'total'] = this.total; + return json; + } + + /// Returns a new [AssetStatsResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetStatsResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return AssetStatsResponseDto( + images: mapValueOfType(json, r'images')!, + videos: mapValueOfType(json, r'videos')!, + total: mapValueOfType(json, r'total')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetStatsResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetStatsResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetStatsResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetStatsResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'images', + 'videos', + 'total', + }; +} + diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 97fa9c3f0..426e5e79a 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -60,11 +60,6 @@ void main() { // TODO }); - //Future getArchivedAssetCountByUserId() async - test('test getArchivedAssetCountByUserId', () async { - // TODO - }); - // Get a single asset's information // //Future getAssetById(String id, { String key }) async @@ -82,13 +77,13 @@ void main() { // TODO }); - //Future getAssetCountByUserId() async - test('test getAssetCountByUserId', () async { + //Future> getAssetSearchTerms() async + test('test getAssetSearchTerms', () async { // TODO }); - //Future> getAssetSearchTerms() async - test('test getAssetSearchTerms', () async { + //Future getAssetStats({ bool isArchived, bool isFavorite }) async + test('test getAssetStats', () async { // TODO }); diff --git a/mobile/openapi/test/asset_count_by_user_id_response_dto_test.dart b/mobile/openapi/test/asset_stats_response_dto_test.dart similarity index 50% rename from mobile/openapi/test/asset_count_by_user_id_response_dto_test.dart rename to mobile/openapi/test/asset_stats_response_dto_test.dart index 6d0b97b6e..3e5d8b548 100644 --- a/mobile/openapi/test/asset_count_by_user_id_response_dto_test.dart +++ b/mobile/openapi/test/asset_stats_response_dto_test.dart @@ -11,32 +11,22 @@ import 'package:openapi/api.dart'; import 'package:test/test.dart'; -// tests for AssetCountByUserIdResponseDto +// tests for AssetStatsResponseDto void main() { - // final instance = AssetCountByUserIdResponseDto(); + // final instance = AssetStatsResponseDto(); - group('test AssetCountByUserIdResponseDto', () { - // int audio (default value: 0) - test('to test the property `audio`', () async { + group('test AssetStatsResponseDto', () { + // int images + test('to test the property `images`', () async { // TODO }); - // int photos (default value: 0) - test('to test the property `photos`', () async { - // TODO - }); - - // int videos (default value: 0) + // int videos test('to test the property `videos`', () async { // TODO }); - // int other (default value: 0) - test('to test the property `other`', () async { - // TODO - }); - - // int total (default value: 0) + // int total test('to test the property `total`', () async { // TODO }); diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index a3ce03df0..739d00186 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -984,38 +984,6 @@ ] } }, - "/asset/count-by-user-id": { - "get": { - "operationId": "getAssetCountByUserId", - "parameters": [], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AssetCountByUserIdResponseDto" - } - } - } - } - }, - "tags": [ - "Asset" - ], - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ] - } - }, "/asset/curated-locations": { "get": { "operationId": "getCuratedLocations", @@ -1608,17 +1576,34 @@ ] } }, - "/asset/stat/archive": { + "/asset/statistics": { "get": { - "operationId": "getArchivedAssetCountByUserId", - "parameters": [], + "operationId": "getAssetStats", + "parameters": [ + { + "name": "isArchived", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isFavorite", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + } + ], "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssetCountByUserIdResponseDto" + "$ref": "#/components/schemas/AssetStatsResponseDto" } } } @@ -4786,38 +4771,6 @@ "buckets" ] }, - "AssetCountByUserIdResponseDto": { - "type": "object", - "properties": { - "audio": { - "type": "integer", - "default": 0 - }, - "photos": { - "type": "integer", - "default": 0 - }, - "videos": { - "type": "integer", - "default": 0 - }, - "other": { - "type": "integer", - "default": 0 - }, - "total": { - "type": "integer", - "default": 0 - } - }, - "required": [ - "audio", - "photos", - "videos", - "other", - "total" - ] - }, "AssetFileUploadResponseDto": { "type": "object", "properties": { @@ -4970,6 +4923,25 @@ "checksum" ] }, + "AssetStatsResponseDto": { + "type": "object", + "properties": { + "images": { + "type": "integer" + }, + "videos": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "images", + "videos", + "total" + ] + }, "AssetTypeEnum": { "type": "string", "enum": [ diff --git a/server/src/domain/asset/asset.repository.ts b/server/src/domain/asset/asset.repository.ts index 9bd9c687a..ae8f64e64 100644 --- a/server/src/domain/asset/asset.repository.ts +++ b/server/src/domain/asset/asset.repository.ts @@ -1,6 +1,13 @@ import { AssetEntity, AssetType } from '@app/infra/entities'; import { Paginated, PaginationOptions } from '../domain.util'; +export type AssetStats = Record; + +export interface AssetStatsOptions { + isFavorite?: boolean; + isArchived?: boolean; +} + export interface AssetSearchOptions { isVisible?: boolean; type?: AssetType; @@ -55,4 +62,5 @@ export interface IAssetRepository { save(asset: Partial): Promise; findLivePhotoMatch(options: LivePhotoSearchOptions): Promise; getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise; + getStatistics(ownerId: string, options: AssetStatsOptions): Promise; } diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index ef51c8831..986e7d0b3 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -1,3 +1,4 @@ +import { AssetType } from '@app/infra/entities'; import { BadRequestException } from '@nestjs/common'; import { assetEntityStub, @@ -10,9 +11,9 @@ import { import { when } from 'jest-when'; import { Readable } from 'stream'; import { IStorageRepository } from '../storage'; -import { IAssetRepository } from './asset.repository'; +import { AssetStats, IAssetRepository } from './asset.repository'; import { AssetService } from './asset.service'; -import { DownloadResponseDto } from './index'; +import { AssetStatsResponseDto, DownloadResponseDto } from './dto'; import { mapAsset } from './response-dto'; const downloadResponse: DownloadResponseDto = { @@ -25,6 +26,19 @@ const downloadResponse: DownloadResponseDto = { ], }; +const stats: AssetStats = { + [AssetType.IMAGE]: 10, + [AssetType.VIDEO]: 23, + [AssetType.AUDIO]: 0, + [AssetType.OTHER]: 0, +}; + +const statResponse: AssetStatsResponseDto = { + images: 10, + videos: 23, + total: 33, +}; + describe(AssetService.name, () => { let sut: AssetService; let accessMock: IAccessRepositoryMock; @@ -287,4 +301,30 @@ describe(AssetService.name, () => { }); }); }); + + describe('getStatistics', () => { + it('should get the statistics for a user, excluding archived assets', async () => { + assetMock.getStatistics.mockResolvedValue(stats); + await expect(sut.getStatistics(authStub.admin, { isArchived: false })).resolves.toEqual(statResponse); + expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.id, { isArchived: false }); + }); + + it('should get the statistics for a user for archived assets', async () => { + assetMock.getStatistics.mockResolvedValue(stats); + await expect(sut.getStatistics(authStub.admin, { isArchived: true })).resolves.toEqual(statResponse); + expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.id, { isArchived: true }); + }); + + it('should get the statistics for a user for favorite assets', async () => { + assetMock.getStatistics.mockResolvedValue(stats); + await expect(sut.getStatistics(authStub.admin, { isFavorite: true })).resolves.toEqual(statResponse); + expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.id, { isFavorite: true }); + }); + + it('should get the statistics for a user for all assets', async () => { + assetMock.getStatistics.mockResolvedValue(stats); + await expect(sut.getStatistics(authStub.admin, {})).resolves.toEqual(statResponse); + expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.id, {}); + }); + }); }); diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 5a84a4a35..90595de69 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -9,6 +9,7 @@ import { HumanReadableSize, usePagination } from '../domain.util'; import { ImmichReadStream, IStorageRepository } from '../storage'; import { IAssetRepository } from './asset.repository'; import { AssetIdsDto, DownloadArchiveInfo, DownloadDto, DownloadResponseDto, MemoryLaneDto } from './dto'; +import { AssetStatsDto, mapStats } from './dto/asset-statistics.dto'; import { MapMarkerDto } from './dto/map-marker.dto'; import { mapAsset, MapMarkerResponseDto } from './response-dto'; import { MemoryLaneResponseDto } from './response-dto/memory-lane-response.dto'; @@ -155,4 +156,9 @@ export class AssetService { throw new BadRequestException('assetIds, albumId, or userId is required'); } + + async getStatistics(authUser: AuthUserDto, dto: AssetStatsDto) { + const stats = await this.assetRepository.getStatistics(authUser.id, dto); + return mapStats(stats); + } } diff --git a/server/src/domain/asset/dto/asset-statistics.dto.ts b/server/src/domain/asset/dto/asset-statistics.dto.ts new file mode 100644 index 000000000..ef9c0606f --- /dev/null +++ b/server/src/domain/asset/dto/asset-statistics.dto.ts @@ -0,0 +1,37 @@ +import { AssetType } from '@app/infra/entities'; +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsOptional } from 'class-validator'; +import { toBoolean } from '../../domain.util'; +import { AssetStats } from '../asset.repository'; + +export class AssetStatsDto { + @IsBoolean() + @Transform(toBoolean) + @IsOptional() + isArchived?: boolean; + + @IsBoolean() + @Transform(toBoolean) + @IsOptional() + isFavorite?: boolean; +} + +export class AssetStatsResponseDto { + @ApiProperty({ type: 'integer' }) + images!: number; + + @ApiProperty({ type: 'integer' }) + videos!: number; + + @ApiProperty({ type: 'integer' }) + total!: number; +} + +export const mapStats = (stats: AssetStats): AssetStatsResponseDto => { + return { + images: stats[AssetType.IMAGE], + videos: stats[AssetType.VIDEO], + total: Object.values(stats).reduce((total, value) => total + value, 0), + }; +}; diff --git a/server/src/domain/asset/dto/index.ts b/server/src/domain/asset/dto/index.ts index 9778a9122..f22534d35 100644 --- a/server/src/domain/asset/dto/index.ts +++ b/server/src/domain/asset/dto/index.ts @@ -1,4 +1,5 @@ export * from './asset-ids.dto'; +export * from './asset-statistics.dto'; export * from './download.dto'; export * from './map-marker.dto'; export * from './memory-lane.dto'; diff --git a/server/src/immich/api-v1/asset/asset-repository.ts b/server/src/immich/api-v1/asset/asset-repository.ts index 7b3dfea4d..22c25d6ef 100644 --- a/server/src/immich/api-v1/asset/asset-repository.ts +++ b/server/src/immich/api-v1/asset/asset-repository.ts @@ -1,4 +1,4 @@ -import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; +import { AssetEntity, ExifEntity } from '@app/infra/entities'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { IsNull, Not } from 'typeorm'; @@ -11,7 +11,6 @@ import { GetAssetCountByTimeBucketDto, TimeGroupEnum } from './dto/get-asset-cou import { SearchPropertiesDto } from './dto/search-properties.dto'; import { UpdateAssetDto } from './dto/update-asset.dto'; import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto'; -import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto'; import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto'; import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto'; @@ -38,8 +37,6 @@ export interface IAssetRepository { getDetectedObjectsByUserId(userId: string): Promise; getSearchPropertiesByUserId(userId: string): Promise; getAssetCountByTimeBucket(userId: string, dto: GetAssetCountByTimeBucketDto): Promise; - getAssetCountByUserId(userId: string): Promise; - getArchivedAssetCountByUserId(userId: string): Promise; getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise; getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise; getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise; @@ -55,35 +52,6 @@ export class AssetRepository implements IAssetRepository { @InjectRepository(ExifEntity) private exifRepository: Repository, ) {} - async getAssetCountByUserId(ownerId: string): Promise { - // Get asset count by AssetType - const items = await this.assetRepository - .createQueryBuilder('asset') - .select(`COUNT(asset.id)`, 'count') - .addSelect(`asset.type`, 'type') - .where('"ownerId" = :ownerId', { ownerId: ownerId }) - .andWhere('asset.isVisible = true') - .groupBy('asset.type') - .getRawMany(); - - return this.getAssetCount(items); - } - - async getArchivedAssetCountByUserId(ownerId: string): Promise { - // Get archived asset count by AssetType - const items = await this.assetRepository - .createQueryBuilder('asset') - .select(`COUNT(asset.id)`, 'count') - .addSelect(`asset.type`, 'type') - .where('"ownerId" = :ownerId', { ownerId: ownerId }) - .andWhere('asset.isVisible = true') - .andWhere('asset.isArchived = true') - .groupBy('asset.type') - .getRawMany(); - - return this.getAssetCount(items); - } - async getAssetByTimeBucket(userId: string, dto: GetAssetByTimeBucketDto): Promise { // Get asset entity from a list of time buckets let builder = this.assetRepository @@ -337,29 +305,6 @@ export class AssetRepository implements IAssetRepository { return assets.map((asset) => asset.deviceAssetId); } - private getAssetCount(items: any): AssetCountByUserIdResponseDto { - const assetCountByUserId = new AssetCountByUserIdResponseDto(); - - // asset type to dto property mapping - const map: Record = { - [AssetType.AUDIO]: 'audio', - [AssetType.IMAGE]: 'photos', - [AssetType.VIDEO]: 'videos', - [AssetType.OTHER]: 'other', - }; - - for (const item of items) { - const count = Number(item.count) || 0; - const assetType = item.type as AssetType; - const type = map[assetType]; - - assetCountByUserId[type] = count; - assetCountByUserId.total += count; - } - - return assetCountByUserId; - } - getByOriginalPath(originalPath: string): Promise { return this.assetRepository.findOne({ select: { diff --git a/server/src/immich/api-v1/asset/asset.controller.ts b/server/src/immich/api-v1/asset/asset.controller.ts index b59c97199..1e22bf3ba 100644 --- a/server/src/immich/api-v1/asset/asset.controller.ts +++ b/server/src/immich/api-v1/asset/asset.controller.ts @@ -38,7 +38,6 @@ import { ServeFileDto } from './dto/serve-file.dto'; import { UpdateAssetDto } from './dto/update-asset.dto'; import { AssetBulkUploadCheckResponseDto } from './response-dto/asset-check-response.dto'; import { AssetCountByTimeBucketResponseDto } from './response-dto/asset-count-by-time-group-response.dto'; -import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto'; import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto'; import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto'; import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto'; @@ -173,15 +172,6 @@ export class AssetController { return this.assetService.getAssetCountByTimeBucket(authUser, dto); } - @Get('/count-by-user-id') - getAssetCountByUserId(@AuthUser() authUser: AuthUserDto): Promise { - return this.assetService.getAssetCountByUserId(authUser); - } - - @Get('/stat/archive') - getArchivedAssetCountByUserId(@AuthUser() authUser: AuthUserDto): Promise { - return this.assetService.getArchivedAssetCountByUserId(authUser); - } /** * Get all AssetEntity belong to the user */ diff --git a/server/src/immich/api-v1/asset/asset.service.spec.ts b/server/src/immich/api-v1/asset/asset.service.spec.ts index 97725f149..110d63f50 100644 --- a/server/src/immich/api-v1/asset/asset.service.spec.ts +++ b/server/src/immich/api-v1/asset/asset.service.spec.ts @@ -26,7 +26,6 @@ import { CreateAssetDto } from './dto/create-asset.dto'; import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto'; import { AssetRejectReason, AssetUploadAction } from './response-dto/asset-check-response.dto'; import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto'; -import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto'; const _getCreateAssetDto = (): CreateAssetDto => { const createAssetDto = new CreateAssetDto(); @@ -103,24 +102,6 @@ const _getAssetCountByTimeBucket = (): AssetCountByTimeBucket[] => { return [result1, result2]; }; -const _getAssetCountByUserId = (): AssetCountByUserIdResponseDto => { - const result = new AssetCountByUserIdResponseDto(); - - result.videos = 2; - result.photos = 2; - - return result; -}; - -const _getArchivedAssetsCountByUserId = (): AssetCountByUserIdResponseDto => { - const result = new AssetCountByUserIdResponseDto(); - - result.videos = 1; - result.photos = 2; - - return result; -}; - const uploadFile = { nullAuth: { authUser: null, @@ -197,8 +178,6 @@ describe('AssetService', () => { getSearchPropertiesByUserId: jest.fn(), getAssetByTimeBucket: jest.fn(), getAssetsByChecksums: jest.fn(), - getAssetCountByUserId: jest.fn(), - getArchivedAssetCountByUserId: jest.fn(), getExistingAssets: jest.fn(), getByOriginalPath: jest.fn(), }; @@ -467,20 +446,6 @@ describe('AssetService', () => { expect(result.buckets.length).toEqual(2); }); - it('get asset count by user id', async () => { - const assetCount = _getAssetCountByUserId(); - assetRepositoryMock.getAssetCountByUserId.mockResolvedValue(assetCount); - - await expect(sut.getAssetCountByUserId(authStub.user1)).resolves.toEqual(assetCount); - }); - - it('get archived asset count by user id', async () => { - const assetCount = _getArchivedAssetsCountByUserId(); - assetRepositoryMock.getArchivedAssetCountByUserId.mockResolvedValue(assetCount); - - await expect(sut.getArchivedAssetCountByUserId(authStub.user1)).resolves.toEqual(assetCount); - }); - describe('deleteAll', () => { it('should return failed status when an asset is missing', async () => { assetRepositoryMock.get.mockResolvedValue(null); diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index c08a24fe0..1aeac5ac0 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -58,7 +58,6 @@ import { AssetCountByTimeBucketResponseDto, mapAssetCountByTimeBucket, } from './response-dto/asset-count-by-time-group-response.dto'; -import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto'; import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto'; import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto'; import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto'; @@ -536,14 +535,6 @@ export class AssetService { return mapAssetCountByTimeBucket(result); } - getAssetCountByUserId(authUser: AuthUserDto): Promise { - return this._assetRepository.getAssetCountByUserId(authUser.id); - } - - getArchivedAssetCountByUserId(authUser: AuthUserDto): Promise { - return this._assetRepository.getArchivedAssetCountByUserId(authUser.id); - } - getExifPermission(authUser: AuthUserDto) { return !authUser.isPublicUser || authUser.isShowExif; } diff --git a/server/src/immich/api-v1/asset/response-dto/asset-count-by-user-id-response.dto.ts b/server/src/immich/api-v1/asset/response-dto/asset-count-by-user-id-response.dto.ts deleted file mode 100644 index cbee0eed5..000000000 --- a/server/src/immich/api-v1/asset/response-dto/asset-count-by-user-id-response.dto.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; - -export class AssetCountByUserIdResponseDto { - @ApiProperty({ type: 'integer' }) - audio = 0; - - @ApiProperty({ type: 'integer' }) - photos = 0; - - @ApiProperty({ type: 'integer' }) - videos = 0; - - @ApiProperty({ type: 'integer' }) - other = 0; - - @ApiProperty({ type: 'integer' }) - total = 0; -} diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index 28e23c98e..5c4e19ccf 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -1,6 +1,8 @@ import { AssetIdsDto, AssetService, + AssetStatsDto, + AssetStatsResponseDto, AuthUserDto, DownloadDto, DownloadResponseDto, @@ -53,4 +55,9 @@ export class AssetController { downloadFile(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) { return this.service.downloadFile(authUser, id).then(asStreamableFile); } + + @Get('statistics') + getAssetStats(@AuthUser() authUser: AuthUserDto, @Query() dto: AssetStatsDto): Promise { + return this.service.getStatistics(authUser, dto); + } } diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index a23787252..09fb3a17e 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -1,5 +1,7 @@ import { AssetSearchOptions, + AssetStats, + AssetStatsOptions, IAssetRepository, LivePhotoSearchOptions, MapMarker, @@ -321,4 +323,38 @@ export class AssetRepository implements IAssetRepository { lon: asset.exifInfo!.longitude!, })); } + + async getStatistics(ownerId: string, options: AssetStatsOptions): Promise { + let builder = await this.repository + .createQueryBuilder('asset') + .select(`COUNT(asset.id)`, 'count') + .addSelect(`asset.type`, 'type') + .where('"ownerId" = :ownerId', { ownerId }) + .andWhere('asset.isVisible = true') + .groupBy('asset.type'); + + const { isArchived, isFavorite } = options; + if (isArchived !== undefined) { + builder = builder.andWhere(`asset.isArchived = :isArchived`, { isArchived }); + } + + if (isFavorite !== undefined) { + builder = builder.andWhere(`asset.isFavorite = :isFavorite`, { isFavorite }); + } + + const items = await builder.getRawMany(); + + const result: AssetStats = { + [AssetType.AUDIO]: 0, + [AssetType.IMAGE]: 0, + [AssetType.VIDEO]: 0, + [AssetType.OTHER]: 0, + }; + + for (const item of items) { + result[item.type as AssetType] = Number(item.count) || 0; + } + + return result; + } } diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 7e8a52262..5eb69a9e5 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -18,5 +18,6 @@ export const newAssetRepositoryMock = (): jest.Mocked => { save: jest.fn(), findLivePhotoMatch: jest.fn(), getMapMarkers: jest.fn(), + getStatistics: jest.fn(), }; }; diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 484dc4e4d..67434662d 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -486,43 +486,6 @@ export interface AssetCountByTimeBucketResponseDto { */ 'buckets': Array; } -/** - * - * @export - * @interface AssetCountByUserIdResponseDto - */ -export interface AssetCountByUserIdResponseDto { - /** - * - * @type {number} - * @memberof AssetCountByUserIdResponseDto - */ - 'audio': number; - /** - * - * @type {number} - * @memberof AssetCountByUserIdResponseDto - */ - 'photos': number; - /** - * - * @type {number} - * @memberof AssetCountByUserIdResponseDto - */ - 'videos': number; - /** - * - * @type {number} - * @memberof AssetCountByUserIdResponseDto - */ - 'other': number; - /** - * - * @type {number} - * @memberof AssetCountByUserIdResponseDto - */ - 'total': number; -} /** * * @export @@ -724,6 +687,31 @@ export interface AssetResponseDto { } +/** + * + * @export + * @interface AssetStatsResponseDto + */ +export interface AssetStatsResponseDto { + /** + * + * @type {number} + * @memberof AssetStatsResponseDto + */ + 'images': number; + /** + * + * @type {number} + * @memberof AssetStatsResponseDto + */ + 'videos': number; + /** + * + * @type {number} + * @memberof AssetStatsResponseDto + */ + 'total': number; +} /** * * @export @@ -4901,44 +4889,6 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - getArchivedAssetCountByUserId: async (options: AxiosRequestConfig = {}): Promise => { - const localVarPath = `/asset/stat/archive`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication cookie required - - // authentication api_key required - await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) - - // authentication bearer required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - - setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -5088,8 +5038,8 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAssetCountByUserId: async (options: AxiosRequestConfig = {}): Promise => { - const localVarPath = `/asset/count-by-user-id`; + getAssetSearchTerms: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/asset/search-terms`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -5123,11 +5073,13 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration }, /** * + * @param {boolean} [isArchived] + * @param {boolean} [isFavorite] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAssetSearchTerms: async (options: AxiosRequestConfig = {}): Promise => { - const localVarPath = `/asset/search-terms`; + getAssetStats: async (isArchived?: boolean, isFavorite?: boolean, options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/asset/statistics`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -5148,6 +5100,14 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration // http bearer authentication required await setBearerAuthToObject(localVarHeaderParameter, configuration) + if (isArchived !== undefined) { + localVarQueryParameter['isArchived'] = isArchived; + } + + if (isFavorite !== undefined) { + localVarQueryParameter['isFavorite'] = isFavorite; + } + setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -5896,15 +5856,6 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(userId, isFavorite, isArchived, withoutThumbs, skip, ifNoneMatch, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async getArchivedAssetCountByUserId(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getArchivedAssetCountByUserId(options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, /** * Get a single asset\'s information * @param {string} id @@ -5941,17 +5892,19 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getAssetCountByUserId(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetCountByUserId(options); + async getAssetSearchTerms(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetSearchTerms(options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** * + * @param {boolean} [isArchived] + * @param {boolean} [isFavorite] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getAssetSearchTerms(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetSearchTerms(options); + async getAssetStats(isArchived?: boolean, isFavorite?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetStats(isArchived, isFavorite, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -6177,14 +6130,6 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath getAllAssets(userId?: string, isFavorite?: boolean, isArchived?: boolean, withoutThumbs?: boolean, skip?: number, ifNoneMatch?: string, options?: any): AxiosPromise> { return localVarFp.getAllAssets(userId, isFavorite, isArchived, withoutThumbs, skip, ifNoneMatch, options).then((request) => request(axios, basePath)); }, - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - getArchivedAssetCountByUserId(options?: any): AxiosPromise { - return localVarFp.getArchivedAssetCountByUserId(options).then((request) => request(axios, basePath)); - }, /** * Get a single asset\'s information * @param {string} id @@ -6218,16 +6163,18 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAssetCountByUserId(options?: any): AxiosPromise { - return localVarFp.getAssetCountByUserId(options).then((request) => request(axios, basePath)); + getAssetSearchTerms(options?: any): AxiosPromise> { + return localVarFp.getAssetSearchTerms(options).then((request) => request(axios, basePath)); }, /** * + * @param {boolean} [isArchived] + * @param {boolean} [isFavorite] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAssetSearchTerms(options?: any): AxiosPromise> { - return localVarFp.getAssetSearchTerms(options).then((request) => request(axios, basePath)); + getAssetStats(isArchived?: boolean, isFavorite?: boolean, options?: any): AxiosPromise { + return localVarFp.getAssetStats(isArchived, isFavorite, options).then((request) => request(axios, basePath)); }, /** * @@ -6565,6 +6512,27 @@ export interface AssetApiGetAssetCountByTimeBucketRequest { readonly getAssetCountByTimeBucketDto: GetAssetCountByTimeBucketDto } +/** + * Request parameters for getAssetStats operation in AssetApi. + * @export + * @interface AssetApiGetAssetStatsRequest + */ +export interface AssetApiGetAssetStatsRequest { + /** + * + * @type {boolean} + * @memberof AssetApiGetAssetStats + */ + readonly isArchived?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiGetAssetStats + */ + readonly isFavorite?: boolean +} + /** * Request parameters for getAssetThumbnail operation in AssetApi. * @export @@ -6957,16 +6925,6 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).getAllAssets(requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.withoutThumbs, requestParameters.skip, requestParameters.ifNoneMatch, options).then((request) => request(this.axios, this.basePath)); } - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof AssetApi - */ - public getArchivedAssetCountByUserId(options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).getArchivedAssetCountByUserId(options).then((request) => request(this.axios, this.basePath)); - } - /** * Get a single asset\'s information * @param {AssetApiGetAssetByIdRequest} requestParameters Request parameters. @@ -7006,18 +6964,19 @@ export class AssetApi extends BaseAPI { * @throws {RequiredError} * @memberof AssetApi */ - public getAssetCountByUserId(options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).getAssetCountByUserId(options).then((request) => request(this.axios, this.basePath)); + public getAssetSearchTerms(options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).getAssetSearchTerms(options).then((request) => request(this.axios, this.basePath)); } /** * + * @param {AssetApiGetAssetStatsRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof AssetApi */ - public getAssetSearchTerms(options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).getAssetSearchTerms(options).then((request) => request(this.axios, this.basePath)); + public getAssetStats(requestParameters: AssetApiGetAssetStatsRequest = {}, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).getAssetStats(requestParameters.isArchived, requestParameters.isFavorite, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/web/src/lib/components/shared-components/side-bar/side-bar.svelte b/web/src/lib/components/shared-components/side-bar/side-bar.svelte index 3d985661a..9cc966b46 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar.svelte @@ -1,6 +1,6 @@ {#if $isDownloading} @@ -10,16 +19,27 @@ >

DOWNLOADING

- {#each Object.keys($downloadAssets) as fileName} -
-

■ {fileName}

-
-

- {$downloadAssets[fileName]}/100 -

-
-
+ {#each Object.keys($downloadAssets) as downloadKey (downloadKey)} + {@const download = $downloadAssets[downloadKey]} +
+
+
+

■ {downloadKey}

+ {#if download.total} +

{asByteUnitString(download.total, $locale)}

+ {/if}
+
+
+
+
+

+ {download.percentage}% +

+
+
+
+ abort(downloadKey, download)} size="20" logo={Close} forceDark />
{/each} diff --git a/web/src/lib/components/photos-page/actions/download-action.svelte b/web/src/lib/components/photos-page/actions/download-action.svelte index 43b3a9ae4..e1fb24c08 100644 --- a/web/src/lib/components/photos-page/actions/download-action.svelte +++ b/web/src/lib/components/photos-page/actions/download-action.svelte @@ -14,12 +14,13 @@ const handleDownloadFiles = async () => { const assets = Array.from(getAssets()); if (assets.length === 1) { - await downloadFile(assets[0], sharedLinkKey); clearSelect(); + await downloadFile(assets[0], sharedLinkKey); return; } - await downloadArchive(filename, { assetIds: assets.map((asset) => asset.id) }, clearSelect, sharedLinkKey); + clearSelect(); + await downloadArchive(filename, { assetIds: assets.map((asset) => asset.id) }, sharedLinkKey); }; diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte index aa16f1b45..0f883a4a8 100644 --- a/web/src/lib/components/share-page/individual-shared-viewer.svelte +++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte @@ -35,12 +35,7 @@ }); const downloadAssets = async () => { - await downloadArchive( - `immich-shared.zip`, - { assetIds: assets.map((asset) => asset.id) }, - undefined, - sharedLink.key, - ); + await downloadArchive(`immich-shared.zip`, { assetIds: assets.map((asset) => asset.id) }, sharedLink.key); }; const handleUploadAssets = async (files: File[] = []) => { diff --git a/web/src/lib/stores/download.ts b/web/src/lib/stores/download.ts index a7a9f81c0..7dd13b18c 100644 --- a/web/src/lib/stores/download.ts +++ b/web/src/lib/stores/download.ts @@ -1,6 +1,13 @@ import { derived, writable } from 'svelte/store'; -export const downloadAssets = writable>({}); +export interface DownloadProgress { + progress: number; + total: number; + percentage: number; + abort: AbortController | null; +} + +export const downloadAssets = writable>({}); export const isDownloading = derived(downloadAssets, ($downloadAssets) => { if (Object.keys($downloadAssets).length == 0) { @@ -10,17 +17,35 @@ export const isDownloading = derived(downloadAssets, ($downloadAssets) => { return true; }); -const update = (key: string, value: number | null) => { +const update = (key: string, value: Partial | null) => { downloadAssets.update((state) => { const newState = { ...state }; + if (value === null) { delete newState[key]; - } else { - newState[key] = value; + return newState; } + + if (!newState[key]) { + newState[key] = { progress: 0, total: 0, percentage: 0, abort: null }; + } + + const item = newState[key]; + Object.assign(item, value); + item.percentage = Math.min(Math.floor((item.progress / item.total) * 100), 100); + return newState; }); }; -export const clearDownload = (key: string) => update(key, null); -export const updateDownload = (key: string, value: number) => update(key, value); +export const downloadManager = { + add: (key: string, total: number, abort?: AbortController) => update(key, { total, abort }), + clear: (key: string) => update(key, null), + update: (key: string, progress: number, total?: number) => { + const download: Partial = { progress }; + if (total !== undefined) { + download.total = total; + } + update(key, download); + }, +}; diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index d420d9f57..55169acf1 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -1,5 +1,5 @@ import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification'; -import { clearDownload, updateDownload } from '$lib/stores/download'; +import { downloadManager } from '$lib/stores/download'; import { AddAssetsResponseDto, api, AssetApiGetDownloadInfoRequest, AssetResponseDto, DownloadResponseDto } from '@api'; import { handleError } from './handle-error'; @@ -37,7 +37,6 @@ const downloadBlob = (data: Blob, filename: string) => { export const downloadArchive = async ( fileName: string, options: Omit, - onDone?: () => void, key?: string, ) => { let downloadInfo: DownloadResponseDto | null = null; @@ -58,65 +57,77 @@ export const downloadArchive = async ( const suffix = downloadInfo.archives.length === 1 ? '' : `+${i + 1}`; const archiveName = fileName.replace('.zip', `${suffix}.zip`); - let downloadKey = `${archiveName}`; + let downloadKey = `${archiveName} `; if (downloadInfo.archives.length > 1) { downloadKey = `${archiveName} (${i + 1}/${downloadInfo.archives.length})`; } - updateDownload(downloadKey, 0); + const abort = new AbortController(); + downloadManager.add(downloadKey, archive.size, abort); try { const { data } = await api.assetApi.downloadArchive( { assetIdsDto: { assetIds: archive.assetIds }, key }, { responseType: 'blob', - onDownloadProgress: (event) => updateDownload(downloadKey, Math.floor((event.loaded / archive.size) * 100)), + signal: abort.signal, + onDownloadProgress: (event) => downloadManager.update(downloadKey, event.loaded), }, ); downloadBlob(data, archiveName); } catch (e) { handleError(e, 'Unable to download files'); - clearDownload(downloadKey); + downloadManager.clear(downloadKey); return; } finally { - setTimeout(() => clearDownload(downloadKey), 3_000); + setTimeout(() => downloadManager.clear(downloadKey), 5_000); } } - - onDone?.(); }; export const downloadFile = async (asset: AssetResponseDto, key?: string) => { - const assets = [{ filename: `${asset.originalFileName}.${getFilenameExtension(asset.originalPath)}`, id: asset.id }]; + const assets = [ + { + filename: `${asset.originalFileName}.${getFilenameExtension(asset.originalPath)}`, + id: asset.id, + size: asset.exifInfo?.fileSizeInByte || 0, + }, + ]; if (asset.livePhotoVideoId) { assets.push({ filename: `${asset.originalFileName}.mov`, id: asset.livePhotoVideoId, + size: 0, }); } - for (const asset of assets) { + for (const { filename, id, size } of assets) { + const downloadKey = filename; + try { - updateDownload(asset.filename, 0); + const abort = new AbortController(); + downloadManager.add(downloadKey, size, abort); const { data } = await api.assetApi.downloadFile( - { id: asset.id, key }, + { id, key }, { responseType: 'blob', onDownloadProgress: (event: ProgressEvent) => { if (event.lengthComputable) { - updateDownload(asset.filename, Math.floor((event.loaded / event.total) * 100)); + downloadManager.update(downloadKey, event.loaded, event.total); } }, + signal: abort.signal, }, ); - downloadBlob(data, asset.filename); + downloadBlob(data, filename); } catch (e) { - handleError(e, `Error downloading ${asset.filename}`); + handleError(e, `Error downloading ${filename}`); + downloadManager.clear(downloadKey); } finally { - setTimeout(() => clearDownload(asset.filename), 3_000); + setTimeout(() => downloadManager.clear(downloadKey), 5_000); } } }; diff --git a/web/src/lib/utils/handle-error.ts b/web/src/lib/utils/handle-error.ts index 3c9c0e1c9..32a73f03d 100644 --- a/web/src/lib/utils/handle-error.ts +++ b/web/src/lib/utils/handle-error.ts @@ -1,7 +1,12 @@ import type { ApiError } from '@api'; +import { CanceledError } from 'axios'; import { notificationController, NotificationType } from '../components/shared-components/notification/notification'; export async function handleError(error: unknown, message: string) { + if (error instanceof CanceledError) { + return; + } + console.error(`[handleError]: ${message}`, error); let data = (error as ApiError)?.response?.data; From 1064128fde1f3d9fee47bdf7a7fe92e2014d224c Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 14 Jul 2023 21:31:42 -0400 Subject: [PATCH 36/38] refactor(server): upload config (#3252) --- server/src/domain/asset/asset.service.spec.ts | 136 +++++++++++++++++- server/src/domain/asset/asset.service.ts | 78 +++++++++- .../immich/api-v1/asset/asset.service.spec.ts | 136 +----------------- .../src/immich/api-v1/asset/asset.service.ts | 71 +-------- server/src/immich/app.interceptor.ts | 9 +- 5 files changed, 213 insertions(+), 217 deletions(-) diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index 986e7d0b3..fa0cccb20 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -1,18 +1,21 @@ import { AssetType } from '@app/infra/entities'; -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { assetEntityStub, authStub, IAccessRepositoryMock, newAccessRepositoryMock, newAssetRepositoryMock, + newCryptoRepositoryMock, newStorageRepositoryMock, } from '@test'; import { when } from 'jest-when'; import { Readable } from 'stream'; +import { ICryptoRepository } from '../crypto'; +import { mimeTypes } from '../domain.constant'; import { IStorageRepository } from '../storage'; import { AssetStats, IAssetRepository } from './asset.repository'; -import { AssetService } from './asset.service'; +import { AssetService, UploadFieldName } from './asset.service'; import { AssetStatsResponseDto, DownloadResponseDto } from './dto'; import { mapAsset } from './response-dto'; @@ -39,10 +42,62 @@ const statResponse: AssetStatsResponseDto = { total: 33, }; +const uploadFile = { + nullAuth: { + authUser: null, + fieldName: UploadFieldName.ASSET_DATA, + file: { + checksum: Buffer.from('checksum', 'utf8'), + originalPath: 'upload/admin/image.jpeg', + originalName: 'image.jpeg', + }, + }, + filename: (fieldName: UploadFieldName, filename: string) => { + return { + authUser: authStub.admin, + fieldName, + file: { + mimeType: 'image/jpeg', + checksum: Buffer.from('checksum', 'utf8'), + originalPath: `upload/admin/${filename}`, + originalName: filename, + }, + }; + }, +}; + +const uploadTests = [ + { + label: 'asset', + fieldName: UploadFieldName.ASSET_DATA, + filetypes: Object.keys({ ...mimeTypes.image, ...mimeTypes.video }), + invalid: ['.xml', '.html'], + }, + { + label: 'live photo', + fieldName: UploadFieldName.LIVE_PHOTO_DATA, + filetypes: Object.keys(mimeTypes.video), + invalid: ['.xml', '.html', '.jpg', '.jpeg'], + }, + { + label: 'sidecar', + fieldName: UploadFieldName.SIDECAR_DATA, + filetypes: Object.keys(mimeTypes.sidecar), + invalid: ['.xml', '.html', '.jpg', '.jpeg', '.mov', '.mp4'], + }, + { + label: 'profile', + fieldName: UploadFieldName.PROFILE_DATA, + filetypes: Object.keys(mimeTypes.profile), + invalid: ['.xml', '.html', '.cr2', '.arf', '.mov', '.mp4'], + }, +]; + describe(AssetService.name, () => { let sut: AssetService; let accessMock: IAccessRepositoryMock; let assetMock: jest.Mocked; + let cryptoMock: jest.Mocked; let storageMock: jest.Mocked; it('should work', () => { @@ -52,8 +107,83 @@ describe(AssetService.name, () => { beforeEach(async () => { accessMock = newAccessRepositoryMock(); assetMock = newAssetRepositoryMock(); + cryptoMock = newCryptoRepositoryMock(); storageMock = newStorageRepositoryMock(); - sut = new AssetService(accessMock, assetMock, storageMock); + sut = new AssetService(accessMock, assetMock, cryptoMock, storageMock); + }); + + describe('canUpload', () => { + it('should require an authenticated user', () => { + expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException); + }); + + for (const { fieldName, filetypes, invalid } of uploadTests) { + describe(`${fieldName}`, () => { + for (const filetype of filetypes) { + it(`should accept ${filetype}`, () => { + expect(sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toEqual(true); + }); + } + + for (const filetype of invalid) { + it(`should reject ${filetype}`, () => { + expect(() => sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toThrowError( + BadRequestException, + ); + }); + } + }); + } + }); + + describe('getUploadFilename', () => { + it('should require authentication', () => { + expect(() => sut.getUploadFilename(uploadFile.nullAuth)).toThrowError(UnauthorizedException); + }); + + it('should be the original extension for asset upload', () => { + expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual( + 'random-uuid.jpg', + ); + }); + + it('should be the mov extension for live photo upload', () => { + expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.LIVE_PHOTO_DATA, 'image.mp4'))).toEqual( + 'random-uuid.mov', + ); + }); + + it('should be the xmp extension for sidecar upload', () => { + expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.SIDECAR_DATA, 'image.html'))).toEqual( + 'random-uuid.xmp', + ); + }); + + it('should be the original extension for profile upload', () => { + expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual( + 'random-uuid.jpg', + ); + }); + }); + + describe('getUploadFolder', () => { + it('should require authentication', () => { + expect(() => sut.getUploadFolder(uploadFile.nullAuth)).toThrowError(UnauthorizedException); + }); + + it('should return profile for profile uploads', () => { + expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual( + 'upload/profile/admin_id', + ); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id'); + }); + + it('should return upload for everything else', () => { + expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual( + 'upload/upload/admin_id', + ); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id'); + }); }); describe('getMapMarkers', () => { diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 90595de69..cb2701ea1 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -1,12 +1,14 @@ import { AssetEntity } from '@app/infra/entities'; -import { BadRequestException, Inject } from '@nestjs/common'; +import { BadRequestException, Inject, Logger } from '@nestjs/common'; import { DateTime } from 'luxon'; import { extname } from 'path'; +import sanitize from 'sanitize-filename'; import { AccessCore, IAccessRepository, Permission } from '../access'; import { AuthUserDto } from '../auth'; +import { ICryptoRepository } from '../crypto'; import { mimeTypes } from '../domain.constant'; import { HumanReadableSize, usePagination } from '../domain.util'; -import { ImmichReadStream, IStorageRepository } from '../storage'; +import { ImmichReadStream, IStorageRepository, StorageCore, StorageFolder } from '../storage'; import { IAssetRepository } from './asset.repository'; import { AssetIdsDto, DownloadArchiveInfo, DownloadDto, DownloadResponseDto, MemoryLaneDto } from './dto'; import { AssetStatsDto, mapStats } from './dto/asset-statistics.dto'; @@ -21,6 +23,12 @@ export enum UploadFieldName { PROFILE_DATA = 'file', } +export interface UploadRequest { + authUser: AuthUserDto | null; + fieldName: UploadFieldName; + file: UploadFile; +} + export interface UploadFile { checksum: Buffer; originalPath: string; @@ -28,16 +36,82 @@ export interface UploadFile { } export class AssetService { + private logger = new Logger(AssetService.name); private access: AccessCore; + private storageCore = new StorageCore(); constructor( @Inject(IAccessRepository) accessRepository: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, ) { this.access = new AccessCore(accessRepository); } + canUploadFile({ authUser, fieldName, file }: UploadRequest): true { + this.access.requireUploadAccess(authUser); + + const filename = file.originalName; + + switch (fieldName) { + case UploadFieldName.ASSET_DATA: + if (mimeTypes.isAsset(filename)) { + return true; + } + break; + + case UploadFieldName.LIVE_PHOTO_DATA: + if (mimeTypes.isVideo(filename)) { + return true; + } + break; + + case UploadFieldName.SIDECAR_DATA: + if (mimeTypes.isSidecar(filename)) { + return true; + } + break; + + case UploadFieldName.PROFILE_DATA: + if (mimeTypes.isProfile(filename)) { + return true; + } + break; + } + + this.logger.error(`Unsupported file type ${filename}`); + throw new BadRequestException(`Unsupported file type ${filename}`); + } + + getUploadFilename({ authUser, fieldName, file }: UploadRequest): string { + this.access.requireUploadAccess(authUser); + + const originalExt = extname(file.originalName); + + const lookup = { + [UploadFieldName.ASSET_DATA]: originalExt, + [UploadFieldName.LIVE_PHOTO_DATA]: '.mov', + [UploadFieldName.SIDECAR_DATA]: '.xmp', + [UploadFieldName.PROFILE_DATA]: originalExt, + }; + + return sanitize(`${this.cryptoRepository.randomUUID()}${lookup[fieldName]}`); + } + + getUploadFolder({ authUser, fieldName }: UploadRequest): string { + authUser = this.access.requireUploadAccess(authUser); + + let folder = this.storageCore.getFolderLocation(StorageFolder.UPLOAD, authUser.id); + if (fieldName === UploadFieldName.PROFILE_DATA) { + folder = this.storageCore.getFolderLocation(StorageFolder.PROFILE, authUser.id); + } + + this.storageRepository.mkdirSync(folder); + + return folder; + } + getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise { return this.assetRepository.getMapMarkers(authUser.id, options); } diff --git a/server/src/immich/api-v1/asset/asset.service.spec.ts b/server/src/immich/api-v1/asset/asset.service.spec.ts index 110d63f50..5528f7bcc 100644 --- a/server/src/immich/api-v1/asset/asset.service.spec.ts +++ b/server/src/immich/api-v1/asset/asset.service.spec.ts @@ -1,13 +1,6 @@ -import { - ICryptoRepository, - IJobRepository, - IStorageRepository, - JobName, - mimeTypes, - UploadFieldName, -} from '@app/domain'; +import { ICryptoRepository, IJobRepository, IStorageRepository, JobName, mimeTypes } from '@app/domain'; import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; -import { BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { BadRequestException } from '@nestjs/common'; import { assetEntityStub, authStub, @@ -102,57 +95,6 @@ const _getAssetCountByTimeBucket = (): AssetCountByTimeBucket[] => { return [result1, result2]; }; -const uploadFile = { - nullAuth: { - authUser: null, - fieldName: UploadFieldName.ASSET_DATA, - file: { - checksum: Buffer.from('checksum', 'utf8'), - originalPath: 'upload/admin/image.jpeg', - originalName: 'image.jpeg', - }, - }, - filename: (fieldName: UploadFieldName, filename: string) => { - return { - authUser: authStub.admin, - fieldName, - file: { - mimeType: 'image/jpeg', - checksum: Buffer.from('checksum', 'utf8'), - originalPath: `upload/admin/${filename}`, - originalName: filename, - }, - }; - }, -}; - -const uploadTests = [ - { - label: 'asset', - fieldName: UploadFieldName.ASSET_DATA, - filetypes: Object.keys({ ...mimeTypes.image, ...mimeTypes.video }), - invalid: ['.xml', '.html'], - }, - { - label: 'live photo', - fieldName: UploadFieldName.LIVE_PHOTO_DATA, - filetypes: Object.keys(mimeTypes.video), - invalid: ['.xml', '.html', '.jpg', '.jpeg'], - }, - { - label: 'sidecar', - fieldName: UploadFieldName.SIDECAR_DATA, - filetypes: Object.keys(mimeTypes.sidecar), - invalid: ['.xml', '.html', '.jpg', '.jpeg', '.mov', '.mp4'], - }, - { - label: 'profile', - fieldName: UploadFieldName.PROFILE_DATA, - filetypes: Object.keys(mimeTypes.profile), - invalid: ['.xml', '.html', '.cr2', '.arf', '.mov', '.mp4'], - }, -]; - describe('AssetService', () => { let sut: AssetService; let a: Repository; // TO BE DELETED AFTER FINISHED REFACTORING @@ -275,80 +217,6 @@ describe('AssetService', () => { }); }); - describe('canUpload', () => { - it('should require an authenticated user', () => { - expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException); - }); - - for (const { fieldName, filetypes, invalid } of uploadTests) { - describe(`${fieldName}`, () => { - for (const filetype of filetypes) { - it(`should accept ${filetype}`, () => { - expect(sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toEqual(true); - }); - } - - for (const filetype of invalid) { - it(`should reject ${filetype}`, () => { - expect(() => sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toThrowError( - BadRequestException, - ); - }); - } - }); - } - }); - - describe('getUploadFilename', () => { - it('should require authentication', () => { - expect(() => sut.getUploadFilename(uploadFile.nullAuth)).toThrowError(UnauthorizedException); - }); - - it('should be the original extension for asset upload', () => { - expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual( - 'random-uuid.jpg', - ); - }); - - it('should be the mov extension for live photo upload', () => { - expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.LIVE_PHOTO_DATA, 'image.mp4'))).toEqual( - 'random-uuid.mov', - ); - }); - - it('should be the xmp extension for sidecar upload', () => { - expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.SIDECAR_DATA, 'image.html'))).toEqual( - 'random-uuid.xmp', - ); - }); - - it('should be the original extension for profile upload', () => { - expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual( - 'random-uuid.jpg', - ); - }); - }); - - describe('getUploadFolder', () => { - it('should require authentication', () => { - expect(() => sut.getUploadFolder(uploadFile.nullAuth)).toThrowError(UnauthorizedException); - }); - - it('should return profile for profile uploads', () => { - expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual( - 'upload/profile/admin_id', - ); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id'); - }); - - it('should return upload for everything else', () => { - expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual( - 'upload/upload/admin_id', - ); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id'); - }); - }); - describe('uploadFile', () => { it('should handle a file upload', async () => { const assetEntity = _getAsset_1(); diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index 795a7148a..61520a9b0 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -12,9 +12,6 @@ import { mapAssetWithoutExif, mimeTypes, Permission, - StorageCore, - StorageFolder, - UploadFieldName, UploadFile, } from '@app/domain'; import { AssetEntity, AssetType } from '@app/infra/entities'; @@ -30,10 +27,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Response as Res } from 'express'; import { constants } from 'fs'; import fs from 'fs/promises'; -import path, { extname } from 'path'; -import sanitize from 'sanitize-filename'; +import path from 'path'; import { QueryFailedError, Repository } from 'typeorm'; -import { UploadRequest } from '../../app.interceptor'; import { IAssetRepository } from './asset-repository'; import { AssetCore } from './asset.core'; import { AssetBulkUploadCheckDto } from './dto/asset-check.dto'; @@ -70,7 +65,6 @@ export class AssetService { readonly logger = new Logger(AssetService.name); private assetCore: AssetCore; private access: AccessCore; - private storageCore = new StorageCore(); constructor( @Inject(IAccessRepository) accessRepository: IAccessRepository, @@ -84,69 +78,6 @@ export class AssetService { this.access = new AccessCore(accessRepository); } - canUploadFile({ authUser, fieldName, file }: UploadRequest): true { - this.access.requireUploadAccess(authUser); - - const filename = file.originalName; - - switch (fieldName) { - case UploadFieldName.ASSET_DATA: - if (mimeTypes.isAsset(filename)) { - return true; - } - break; - - case UploadFieldName.LIVE_PHOTO_DATA: - if (mimeTypes.isVideo(filename)) { - return true; - } - break; - - case UploadFieldName.SIDECAR_DATA: - if (mimeTypes.isSidecar(filename)) { - return true; - } - break; - - case UploadFieldName.PROFILE_DATA: - if (mimeTypes.isProfile(filename)) { - return true; - } - break; - } - - this.logger.error(`Unsupported file type ${filename}`); - throw new BadRequestException(`Unsupported file type ${filename}`); - } - - getUploadFilename({ authUser, fieldName, file }: UploadRequest): string { - this.access.requireUploadAccess(authUser); - - const originalExt = extname(file.originalName); - - const lookup = { - [UploadFieldName.ASSET_DATA]: originalExt, - [UploadFieldName.LIVE_PHOTO_DATA]: '.mov', - [UploadFieldName.SIDECAR_DATA]: '.xmp', - [UploadFieldName.PROFILE_DATA]: originalExt, - }; - - return sanitize(`${this.cryptoRepository.randomUUID()}${lookup[fieldName]}`); - } - - getUploadFolder({ authUser, fieldName }: UploadRequest): string { - authUser = this.access.requireUploadAccess(authUser); - - let folder = this.storageCore.getFolderLocation(StorageFolder.UPLOAD, authUser.id); - if (fieldName === UploadFieldName.PROFILE_DATA) { - folder = this.storageCore.getFolderLocation(StorageFolder.PROFILE, authUser.id); - } - - this.storageRepository.mkdirSync(folder); - - return folder; - } - public async uploadFile( authUser: AuthUserDto, dto: CreateAssetDto, diff --git a/server/src/immich/app.interceptor.ts b/server/src/immich/app.interceptor.ts index d6c4a3a7e..28c001997 100644 --- a/server/src/immich/app.interceptor.ts +++ b/server/src/immich/app.interceptor.ts @@ -1,4 +1,4 @@ -import { AuthUserDto, UploadFieldName, UploadFile } from '@app/domain'; +import { AssetService, UploadFieldName, UploadFile } from '@app/domain'; import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common'; import { PATH_METADATA } from '@nestjs/common/constants'; import { Reflector } from '@nestjs/core'; @@ -7,7 +7,6 @@ import { createHash } from 'crypto'; import { NextFunction, RequestHandler } from 'express'; import multer, { diskStorage, StorageEngine } from 'multer'; import { Observable } from 'rxjs'; -import { AssetService } from './api-v1/asset/asset.service'; import { AuthRequest } from './app.guard'; export enum Route { @@ -43,12 +42,6 @@ const callbackify = async (fn: (...args: any[]) => T, callback: Callback) } }; -export interface UploadRequest { - authUser: AuthUserDto | null; - fieldName: UploadFieldName; - file: UploadFile; -} - const asRequest = (req: AuthRequest, file: Express.Multer.File) => { return { authUser: req.user || null, From 9ef41bf1c7798654a3439d57e104e0f98ddb1636 Mon Sep 17 00:00:00 2001 From: Harry Tran Date: Sat, 15 Jul 2023 13:38:16 +1000 Subject: [PATCH 37/38] fix(web): update style of rows in user administration table (#3277) --- web/src/routes/admin/user-management/+page.svelte | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte index aa9f964d6..426420f91 100644 --- a/web/src/routes/admin/user-management/+page.svelte +++ b/web/src/routes/admin/user-management/+page.svelte @@ -187,10 +187,10 @@ : 'bg-immich-bg dark:bg-immich-dark-gray/50' }`} > - {user.email} - {user.firstName} - {user.lastName} - + {user.email} + {user.firstName} + {user.lastName} +
{#if user.externalPath} @@ -199,7 +199,7 @@ {/if}
- + {#if !isDeleted(user)}